openapi: 3.1.0
info:
  title: RaceRanger Public API
  version: "0.2.0"
  description: |
    HTTP API for managing and retrieving race, event, athlete, penalty, and
    incident data managed by RaceRanger. Org-scoped keys can create and update
    events, races, and athlete rosters; penalties and incidents are read-only.

    ## Authentication

    Every authenticated request must include a Bearer token in the
    `Authorization` header. API keys are minted from inside the RaceRanger
    app by an organization admin.

    There are two key tiers:

    - **Org-scoped keys** (`rr_o_...`): Bound to an organization. Can list
      events under the org, read all resources, and write
      events/races/athletes.
    - **Event-scoped keys** (`rr_e_...`): Bound to a single event.
      Read-only. Cannot be granted write scopes.

    ## Rate limits

    Default per-key limits are 120 requests/minute and 1000 requests/hour.
    Successful responses include `X-RateLimit-Limit-Minute` and
    `X-RateLimit-Limit-Hour`. When throttled, the API returns HTTP 429 with
    a `Retry-After` header.

servers:
  - url: https://api.raceranger.com
    description: Production
  - url: https://api-test.raceranger.com
    description: Testing

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: rr_*

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: forbidden }
            message: { type: string }
    LatLng:
      type: object
      properties:
        lat: { type: number }
        lng: { type: number }
    Event:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        sport: { type: string }
        eventSize: { type: string, enum: [small, medium, large] }
        dateFrom: { type: string, format: date-time, nullable: true }
        dateTo: { type: string, format: date-time, nullable: true }
        isOver: { type: boolean }
        locationLatLng: { $ref: "#/components/schemas/LatLng" }
        country: { type: string, nullable: true }
        city: { type: string, nullable: true }
        state: { type: string, nullable: true }
        eventUrl: { type: string, nullable: true }
        timezone: { type: string, nullable: true, description: "IANA timezone id" }
        organizationId: { type: string, nullable: true }
    Race:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        startTime: { type: string, format: date-time, nullable: true }
        isActive: { type: boolean }
        alreadyStarted: { type: boolean }
        isDemo: { type: boolean }
        gender: { type: string, enum: [male, female, open, relay] }
        swimDistanceMeters: { type: number, nullable: true }
        bikeDistanceMeters: { type: number, nullable: true }
        runDistanceMeters: { type: number, nullable: true }
        isDraftingAllowed: { type: boolean }
        penaltyTimeS: { type: integer }
        draftingTimeS: { type: integer }
        penaltyBoxEnabled: { type: boolean }
        stopAndGoEnabled: { type: boolean }
        bikePenaltyBoxEnabled: { type: boolean }
        bikePenaltyBoxAmount: { type: integer }
        numberSeries:
          type: array
          items:
            type: object
            properties:
              min: { type: integer }
              max: { type: integer }
        isTrackingEnabled: { type: boolean }
        toRolesEnabled: { type: boolean }
        reduceNotifications: { type: boolean }
        readyCheckEnabled: { type: boolean }
    RaceWrite:
      type: object
      description: Writable race fields (create and update share this surface).
      properties:
        name: { type: string, maxLength: 200 }
        gender: { type: string, enum: [male, female, open, relay] }
        startTime: { type: string, format: date-time }
        swimDistanceMeters: { type: number, minimum: 0 }
        bikeDistanceMeters: { type: number, minimum: 0 }
        runDistanceMeters: { type: number, minimum: 0 }
        isDraftingAllowed: { type: boolean }
        penaltyTimeS: { type: integer }
        draftingTimeS: { type: integer }
        penaltyBoxEnabled: { type: boolean }
        stopAndGoEnabled: { type: boolean }
        bikePenaltyBoxEnabled: { type: boolean }
        bikePenaltyBoxAmount: { type: integer }
        numberSeries:
          type: array
          items:
            type: object
            properties:
              min: { type: integer }
              max: { type: integer }
        isTrackingEnabled: { type: boolean }
        toRolesEnabled: { type: boolean }
        reduceNotifications: { type: boolean }
        readyCheckEnabled: { type: boolean }
    Athlete:
      type: object
      properties:
        id: { type: string }
        raceId: { type: string }
        raceNumber: { type: integer, nullable: true }
        name: { type: string, nullable: true }
        nationality: { type: string, nullable: true }
        gender: { type: string }
        isDNF: { type: boolean }
        isDNS: { type: boolean }
        isDSQ: { type: boolean }
        isLapped: { type: boolean }
        isEliminated: { type: boolean }
    Penalty:
      type: object
      properties:
        id: { type: string }
        raceId: { type: string }
        athleteId: { type: string, nullable: true }
        penaltyStatus: { type: string, enum: [approved, served] }
        penaltyTypeName: { type: string, nullable: true }
        timestamp: { type: string, format: date-time, nullable: true }
        athleteNumber: { type: integer, nullable: true }
        athleteNumberString: { type: string, nullable: true }
        gender: { type: string }
    Incident:
      type: object
      properties:
        id: { type: string }
        raceId: { type: string }
        athleteId: { type: string, nullable: true }
        status: { type: string }
        typeName: { type: string, nullable: true }
        timestamp: { type: string, format: date-time, nullable: true }
        athleteNumber: { type: integer, nullable: true }
        athleteNumberString: { type: string, nullable: true }
        gender: { type: string }
    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        eventTypes:
          type: array
          items: { type: string }
        createdAt: { type: string, format: date-time }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
        lastSuccessAt: { type: string, format: date-time, nullable: true }
        lastFailureAt: { type: string, format: date-time, nullable: true }
        consecutiveFailures: { type: integer }
        disabledAt: { type: string, format: date-time, nullable: true }
        disabledReason: { type: string, nullable: true }

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      description: |
        Client-generated 8-128 character key. Reusing the same key with the
        same body within 24h returns the cached response; reusing with a
        different body returns 400.
      schema:
        type: string
        minLength: 8
        maxLength: 128

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Key lacks required scope or access to the requested resource.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

paths:
  /v1/health:
    get:
      summary: Health check
      security: []
      responses:
        "200": { description: OK }

  /v1/openapi.yaml:
    get:
      summary: OpenAPI specification (YAML)
      security: []
      responses:
        "200": { description: OK }

  /v1/events:
    get:
      summary: List events under your organization (org-scoped keys only)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create a new event (org-scoped keys, write:events scope)
      description: >
        Creates the event and seeds a DEMO race automatically. Consumes one
        organization credit of the requested eventSize (defaults to "small");
        returns 402 if the organization has no credits of that size.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, sport]
              properties:
                name: { type: string, maxLength: 200 }
                sport: { type: string, maxLength: 50 }
                eventSize: { type: string, enum: [small, medium, large], default: small }
                dateFrom: { type: string, format: date-time }
                dateTo: { type: string, format: date-time }
                locationLatLng: { $ref: "#/components/schemas/LatLng" }
                country: { type: string }
                city: { type: string }
                state: { type: string }
                eventUrl: { type: string }
                timezone: { type: string, description: "IANA timezone id, e.g. Europe/Warsaw" }
      responses:
        "201":
          description: Event created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "400": { description: Bad request }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: Insufficient organization credits for the requested eventSize }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/events/{eventId}:
    get:
      summary: Get one event
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      summary: Update mutable event fields (org-scoped, write:events)
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                dateFrom: { type: string, format: date-time }
                dateTo: { type: string, format: date-time }
                locationLatLng: { $ref: "#/components/schemas/LatLng" }
                country: { type: string }
                city: { type: string }
                state: { type: string }
                eventUrl: { type: string }
                timezone: { type: string, description: "IANA timezone id" }
      responses:
        "200":
          description: Updated event
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }

  /v1/events/{eventId}/races:
    get:
      summary: List races in an event
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Race" }
    post:
      summary: Create a race in an event (org-scoped, write:races)
      description: >
        Unspecified operational fields are filled with sane defaults so the
        race is valid in the app (e.g. penaltyTimeS 60, draftingTimeS 15,
        numberSeries [{min:1,max:1000}]). startTime is required — a race
        without one is hidden from the mobile race list, so the request is
        rejected rather than defaulted. startTime stays editable until the
        race starts.
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/RaceWrite"
                - required: [name, startTime]
      responses:
        "201":
          description: Race created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Race" }

  /v1/events/{eventId}/races/{raceId}:
    get:
      summary: Get one race
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Race" }
    patch:
      summary: Update race fields (org-scoped, write:races)
      description: >
        Once a race has started (isActive or alreadyStarted), competition-
        defining fields are frozen and a PATCH touching any of them returns
        403: gender, numberSeries, isDraftingAllowed, stopAndGoEnabled,
        penaltyBoxEnabled, bikePenaltyBoxEnabled, bikePenaltyBoxAmount.
        Everything else stays editable, matching the app's race editor:
        name, startTime, swim/bike/runDistanceMeters, penaltyTimeS,
        draftingTimeS, and the operational toggles (isTrackingEnabled,
        toRolesEnabled, reduceNotifications, readyCheckEnabled). numberSeries
        on a race that belongs to a merged group must not overlap the bib
        numbers of its sibling races at the same gender (400); a merged
        (virtual) race is read-only for numberSeries — edit its children
        (403). Lifecycle (start/stop) is managed in the app via permissions,
        not via this API.
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RaceWrite"
      responses:
        "200":
          description: Updated race
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Race" }

  /v1/events/{eventId}/races/{raceId}/athletes:
    get:
      summary: List athletes in a race
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Athlete" }

  /v1/events/{eventId}/races/{raceId}/athletes:bulkImport:
    post:
      summary: Idempotent bulk upsert of athletes into a race (org-scoped, write:athletes)
      description: |
        Upserts up to 1000 athletes in a single request. Records with a matching
        `externalAthleteId` (within the target race) are updated; the rest are
        created. Returns counts of created/updated/received.
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [athletes]
              properties:
                athletes:
                  type: array
                  maxItems: 1000
                  items:
                    type: object
                    required: [raceNumber, firstName, lastName]
                    properties:
                      externalAthleteId: { type: string, description: "Upsert key within the race." }
                      raceNumber: { type: integer }
                      firstName: { type: string }
                      lastName: { type: string, description: "Combined with firstName into the athlete's `name`." }
                      nationality: { type: string }
                      isDNS: { type: boolean, description: "Did-not-start. The only importable status; gender is per-race, isDNF is race-time, isDSQ is officials-only." }
      responses:
        "200":
          description: Counts of created and updated athletes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      received: { type: integer }
                      created: { type: integer }
                      updated: { type: integer }

  /v1/events/{eventId}/webhooks:
    parameters:
      - in: path
        name: eventId
        required: true
        schema: { type: string }
    get:
      summary: List webhook subscriptions for one event
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
    post:
      summary: Subscribe to outbound webhooks for one event (org key, manage:webhooks)
      description: |
        Webhooks are scoped to a single event. Returns the signing `secret` ONCE
        — store it to verify HMAC signatures on incoming deliveries.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, eventTypes]
              properties:
                url: { type: string, format: uri }
                eventTypes:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Webhook"
                      - type: object
                        properties:
                          secret: { type: string }

  /v1/events/{eventId}/webhooks/{webhookId}:
    parameters:
      - in: path
        name: eventId
        required: true
        schema: { type: string }
      - in: path
        name: webhookId
        required: true
        schema: { type: string }
    patch:
      summary: Update eventTypes on a subscription
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                eventTypes:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
    delete:
      summary: Remove a subscription
      responses:
        "204": { description: Deleted }

  /v1/events/{eventId}/webhooks/{webhookId}:rotateSecret:
    post:
      summary: Rotate the signing secret (returns new secret once)
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: webhookId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: New secret
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      secret: { type: string }

  /v1/events/{eventId}/webhooks/{webhookId}/deliveries:
    get:
      summary: List the last 100 delivery attempts for a subscription
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: webhookId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        eventType: { type: string }
                        attemptCount: { type: integer }
                        status: { type: string, enum: [pending, delivered, failed, dead] }
                        responseStatus: { type: integer, nullable: true }
                        lastAttemptAt: { type: string, format: date-time }

  /v1/events/{eventId}/races/{raceId}/penalties:
    get:
      summary: List penalties in a race (status approved or served only)
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Penalty" }

  /v1/events/{eventId}/races/{raceId}/incidents:
    get:
      summary: List incidents in a race
      parameters:
        - in: path
          name: eventId
          required: true
          schema: { type: string }
        - in: path
          name: raceId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Incident" }
