openapi: 3.1.0
info:
  title: Hyrox Results API
  description: |
    REST API for HYROX seasons, events, division results, athlete lookup, split times, statistics, and simulator benchmarks.

    **Quick start:** subscribe, create a bearer token, then search athletes with `GET /api/v1/athletes/search?q=Surname&first=Given`. Use the `id` from a hit for detail and splits.

    **Auth:** send `Authorization: Bearer <token>` on protected routes. Public routes: `GET /api/v1/health`, `GET /api/v1/openapi.yaml`.

    Identifier rules, curl examples, and rate limits: [full documentation](/documentation).
  version: 1.0.0
  contact:
    name: API support
servers:
  - url: /
    description: This deployment (same origin as these docs)
tags:
  - name: Public
    description: Health check and OpenAPI spec — no authentication required.
  - name: Account
    description: Account, subscription tier, and rate-limit introspection.
  - name: Catalog
    description: Seasons, events, divisions, and leaderboard-style result listings.
  - name: Athletes
    description: Search athletes and fetch race detail, result history, and split times.
  - name: Stats
    description: Precomputed event statistics and race-simulator benchmarks.
  - name: Admin
    description: Ingestion, cache, and aggregate maintenance — admin token required.
security:
  - bearerAuth: []
paths:
  /api/v1/health:
    get:
      tags: [Public]
      summary: Health check
      description: Returns service status and database connectivity. Responds with **503** when the database is unreachable.
      security: []
      responses:
        '200':
          description: Service healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          description: Degraded — database unreachable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
  /api/v1/openapi.yaml:
    get:
      tags: [Public]
      summary: OpenAPI specification
      description: Machine-readable OpenAPI 3.1 document (YAML). Same contract as the interactive docs.
      security: []
      responses:
        '200':
          description: YAML specification
          content:
            application/yaml:
              schema:
                type: string
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/user:
    get:
      tags: [Account]
      summary: Current account
      description: Returns the authenticated user, subscription status, plan name, and effective requests-per-minute limit. Requires a bearer token but **not** an active subscription.
      responses:
        '200':
          description: Account summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeUser'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/seasons:
    get:
      tags: [Catalog]
      summary: List seasons
      description: Returns HYROX seasons ordered for display. Use each season's **`slug`** (e.g. `season-8`) to filter events and athlete search.
      parameters:
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated season list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeSeasonList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/events:
    get:
      tags: [Catalog]
      summary: List events
      description: Returns HYROX events. Filter by season **`slug`**, country, or date range. Each event's **`slug`** can narrow athlete search; its integer **`id`** powers division results and stats endpoints.
      parameters:
        - name: season
          in: query
          description: Season slug from `GET /api/v1/seasons` (e.g. `season-8`).
          schema: { type: string }
        - name: country
          in: query
          description: ISO 3166-1 alpha-2 country code filter, e.g. `IT`.
          schema: { type: string, example: IT }
        - name: from
          in: query
          description: Event start date on or after (inclusive).
          schema: { type: string, format: date }
        - name: to
          in: query
          description: Event start date on or before (inclusive).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated event list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeEventList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/events/{slug}:
    get:
      tags: [Catalog]
      summary: Get event
      description: Returns a single event by its URL **`slug`**.
      parameters:
        - $ref: '#/components/parameters/EventSlug'
      responses:
        '200':
          description: Event record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeEvent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/events/{slug}/divisions:
    get:
      tags: [Catalog]
      summary: List divisions for an event
      description: Returns division rows for an event. The division **`id`** equals the event's internal **`id`** — pass it to `GET /api/v1/divisions/{id}/results`.
      parameters:
        - $ref: '#/components/parameters/EventSlug'
      responses:
        '200':
          description: Division rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeDivisionList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/events/{slug}/ingest-status:
    get:
      tags: [Catalog]
      summary: Ingestion status for an event
      description: |
        Where this event stands in the pipeline, so you can tell "results not published
        yet" apart from "published, still processing" apart from "complete" — instead of
        polling a stats endpoint and inferring it from the payload shape.

        `rankings.status` is one of `idle`, `queued`, `in_progress`, `awaiting_results`,
        `needs_review`, `capped`, `done`; `rankings.complete` is true only for `done`.
        `field_splits.status` is one of `idle`, `queued`, `in_progress`, `done`, `failed`.

        A race is almost always a multi-day weekend, and each division is a separate event
        with its own race day inside that window — so an empty ranking often just means
        that division has not run yet. The `race` object says which: `race.race_date` is
        the division's own race day (null when it cannot be derived), `race.phase` is
        `scheduled`, `racing`, `finished` or `unknown`, and `race.results_due_on` is the
        date from which a complete finisher list is expected. `rankings.awaiting_race_day`
        is the short answer — when true, keep polling; nothing is wrong.

        Typical timeline after a race: finisher lists land within about 48h of the
        DIVISION finishing (nightly discovery re-checks races still `awaiting_results`),
        and gender and age-group buckets are available as soon as the rankings are `done`.
      parameters:
        - $ref: '#/components/parameters/EventSlug'
      responses:
        '200':
          description: Ingestion status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/divisions/{id}/results:
    get:
      tags: [Catalog]
      summary: List division results
      description: Cursor-paginated finishers for an event. **`id`** is the event's integer **`id`** from `GET /api/v1/events` or `GET /api/v1/events/{slug}/divisions` — not a search race ID.
      parameters:
        - $ref: '#/components/parameters/EventInternalId'
        - name: sex
          in: query
          description: Filter by athlete sex.
          schema: { type: string }
        - name: age_class
          in: query
          description: Filter by age group label.
          schema: { type: string }
        - name: nation
          in: query
          description: Filter by athlete nationality code.
          schema: { type: string }
        - name: name
          in: query
          description: Substring match on athlete display name.
          schema: { type: string }
        - name: cursor
          in: query
          description: Opaque cursor from the previous response's `meta.cursor`.
          schema: { type: string }
        - name: limit
          in: query
          description: Page size (max 100).
          schema: { type: integer, default: 50, maximum: 100 }
      responses:
        '200':
          description: Result rows with cursor pagination
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeResultList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/athletes/search:
    get:
      tags: [Athletes]
      summary: Search athletes
      description: |
        Find athletes by name. Returns one row per matching race.

        **Response fields:**
        - **`id`** — race ID for detail and splits.
        - **`person_ref`** — person ref for result history and splits.

        Serves catalog matches first (`meta.data_source`: `database`); otherwise resolves live (`live`).
      parameters:
        - name: last
          in: query
          required: true
          description: Surname / family name (required).
          schema: { type: string }
          example: McIntyre
        - name: first
          in: query
          required: true
          description: Given name (required).
          schema: { type: string }
          example: Hunter
        - name: q
          in: query
          required: false
          deprecated: true
          description: >-
            Deprecated alias for `last`, kept so existing integrations keep working.
            Use `last` instead: `q` reads like a full-text query when it only ever
            meant the surname.
          schema: { type: string }
        - name: season
          in: query
          description: Optional season slug to limit results (from `GET /api/v1/seasons`).
          schema: { type: string }
          example: season-8
        - name: event
          in: query
          description: Optional event slug to limit results (from `GET /api/v1/events`).
          schema: { type: string }
        - name: slug
          in: query
          description: Optional profile slug override (lowercase letters, digits, hyphens). Defaults from `q` and `first`.
          schema: { type: string }
      responses:
        '200':
          description: Matching race rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeAthleteSearch'
              examples:
                hit:
                  summary: Single race hit
                  value:
                    data:
                      - id: eyJ0IjoicmFjZSI…
                        person_ref: eyJ0IjoicGVyc29uI…
                        display_name: Hunter McIntyre
                        nationality: USA
                        sex: M
                        event_slug: season-8-2026-mechelen-2026-mechelen
                        event_name: 2026 Mechelen
                        total_time_ms: 3420000
                        rank_overall: 1
                    meta:
                      data_source: live
                      count: 1
                    errors: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          $ref: '#/components/responses/BadGateway'
  /api/v1/athletes/{id}:
    get:
      tags: [Athletes]
      summary: Get race detail
      description: |
        Returns detail for one race result.

        **`id`** accepts:
        - **Race ID** from search (`id` field) — recommended.
        - **Stored athlete ID** from ingested catalog data.

        Does **not** accept a person ref — use `GET /athletes/{id}/results` for cross-race history, or pass the race ID from search.
      parameters:
        - $ref: '#/components/parameters/RaceIdPath'
      responses:
        '200':
          description: Race detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeAthleteDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          $ref: '#/components/responses/BadGateway'
  /api/v1/athletes/{id}/results:
    get:
      tags: [Athletes]
      summary: List athlete results
      description: |
        Returns race results for an athlete.

        **`id`** accepts:
        - **Person ref** from search — all races for that athlete.
        - **Race ID** from search — single matching race.
        - **Stored athlete ID** from ingested catalog data.
      parameters:
        - $ref: '#/components/parameters/PersonOrRaceIdPath'
        - name: season
          in: query
          description: Optional season slug filter.
          schema: { type: string }
        - name: cursor
          in: query
          description: Opaque cursor for pagination.
          schema: { type: string }
        - name: limit
          in: query
          description: Page size (max 100).
          schema: { type: integer, maximum: 100 }
      responses:
        '200':
          description: Result rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeResultList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          $ref: '#/components/responses/BadGateway'
  /api/v1/athletes/{id}/splits:
    get:
      tags: [Athletes]
      summary: Get split times
      description: |
        Returns station-by-station split times for a race.

        **`id`** accepts:
        - **Race ID** from search — recommended.
        - **Person ref** from search — resolves to the athlete's primary race when multiple exist.
        - **Stored athlete ID** — pass **`result_id`** to select which stored result.

        Each split includes `canonical_key`, `label_original`, `time_ms`, and optional `place`.
      parameters:
        - $ref: '#/components/parameters/PersonOrRaceIdPath'
        - name: result_id
          in: query
          description: When `id` is a stored athlete ID, select a specific `results.id`. Ignored for race IDs and person refs.
          schema: { type: string }
      responses:
        '200':
          description: Split rows ordered by station
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeSplitList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          $ref: '#/components/responses/BadGateway'
  /api/v1/stats/divisions/{id}:
    get:
      tags: [Stats]
      summary: Event division statistics
      description: |
        Precomputed aggregate statistics for an event. **`id`** is the event's integer **`id`**.

        Requires a prior aggregate run (`POST /api/v1/admin/aggregates/recompute`). Returns **404** when stats are not yet computed.

        Pass optional **`division`** to filter to one division bucket by `division_key`.

        `buckets` are keyed by division x sex x age group. Per-station field medians
        (`buckets[].splits`) are a **Scale-plan** feature: on other plans the object is
        present but empty. `meta.field_splits.included` tells you which you received, and
        `meta.ingest` reports how far this event's ingestion has progressed.
      parameters:
        - $ref: '#/components/parameters/EventInternalId'
        - name: division
          in: query
          description: Division key filter (case-insensitive).
          schema: { type: string }
      responses:
        '200':
          description: Aggregate statistics payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/stats/divisions/{id}/splits:
    get:
      tags: [Stats]
      summary: Per-station field medians for an event
      description: |
        Median, average and count **per station** for every division x sex x age-group
        bucket in one event — the field comparison behind "you were 14s slower than the
        Chiba men's median on sled push".

        **Scale plan only.** Accounts created before this endpoint existed keep access on
        their current plan. Individual athlete splits remain available on every plan via
        `GET /api/v1/athletes/{id}/splits`, and global per-division station benchmarks via
        `GET /api/v1/simulator/division-benchmarks`.

        Producing this requires fetching one detail page per finisher, so the first
        request for an event **queues a harvest and returns 202**. Rankings are frozen
        once posted, so a race is harvested only once; afterwards responses are immediate.
        Poll this endpoint or watch `meta.ingest.field_splits.status`
        (`idle` / `queued` / `in_progress` / `done` / `failed`).
      parameters:
        - $ref: '#/components/parameters/EventInternalId'
      responses:
        '200':
          description: Per-station medians per bucket (may be partial while a harvest finishes)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '202':
          description: Harvest queued or running; no station splits stored yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/simulator/benchmarks:
    get:
      tags: [Stats]
      summary: Event simulator benchmarks
      description: |
        Returns simulator benchmark data derived from precomputed event aggregates.

        Omit **`event_id`** to list events that have benchmark snapshots. Pass **`event_id`** to load full division buckets for one event.

        Returns **404** when no snapshot exists — run ingestion and `POST /api/v1/admin/aggregates/recompute` first.
      parameters:
        - name: event_id
          in: query
          description: Event integer ID. Omit to list available events.
          schema: { type: integer }
      responses:
        '200':
          description: Benchmark payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/simulator/division-benchmarks:
    get:
      tags: [Stats]
      summary: Global division benchmarks
      description: |
        Returns default segment means and slider ranges for a HYROX division code — not tied to a specific event.

        Example codes: `HYROX_MEN`, `HYROX_PRO_MEN`, `DOUBLES_MEN`.
      parameters:
        - name: dg
          in: query
          required: true
          description: Division code.
          schema: { type: string }
          example: HYROX_MEN
        - name: nocache
          in: query
          description: Bypass server cache for this request.
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Division benchmark payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /api/v1/admin/sync/season:
    post:
      tags: [Admin]
      summary: Sync season events
      description: Queues discovery and crawl jobs for all events in a season.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [season_slug]
              properties:
                season_slug:
                  type: string
                  description: Season slug (e.g. `season-8`).
      responses:
        '200':
          description: Job dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/sync/discover:
    post:
      tags: [Admin]
      summary: Discover seasons
      description: Queues a job to refresh the season catalog from HYROX.
      responses:
        '200':
          description: Job dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/sync/full:
    post:
      tags: [Admin]
      summary: Full ingestion
      description: Queues the full ingestion pipeline (season discovery, event crawls, and detail fetches).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                discover_seasons:
                  type: boolean
                  default: true
                  description: When true, refresh the season catalog before crawling.
      responses:
        '200':
          description: Chain dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/sync/event:
    post:
      tags: [Admin]
      summary: Sync event results
      description: Queues a results-list crawl for one event. Requires `HYROX_PERSIST_RESULTS=true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_slug]
              properties:
                event_slug:
                  type: string
                  description: Event slug from `GET /api/v1/events`.
      responses:
        '200':
          description: Job dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Persistence disabled (`HYROX_PERSIST_RESULTS=false`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/cache/clear:
    post:
      tags: [Admin]
      summary: Clear cache
      description: Flushes application cache. Optionally pass tag names to flush a subset.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                tags:
                  type: array
                  items: { type: string }
                  description: Optional cache tags to flush.
      responses:
        '200':
          description: Cache cleared
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeGeneric'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/aggregates/recompute:
    post:
      tags: [Admin]
      summary: Recompute aggregates
      description: Queues recomputation of division statistics and simulator benchmark snapshots. Omit `event_id` to recompute all events.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event_id:
                  type: integer
                  nullable: true
                  description: Event integer ID. Omit for all events.
      responses:
        '200':
          description: Job dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/admin/hyresult/event-division-stats:
    post:
      tags: [Admin]
      summary: Fetch supplemental division stats
      description: Queues a fetch of embedded division statistics for an event results page. Used to enrich aggregate snapshots.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_slug]
              properties:
                event_slug:
                  type: string
                  description: Event slug from `GET /api/v1/events`.
                source_path:
                  type: string
                  description: Optional override path for the results page.
      responses:
        '200':
          description: Job dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeJobDispatched'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum personal access token
      description: Personal access token from your account dashboard.
  parameters:
    PerPage:
      name: per_page
      in: query
      description: Page size (max 100).
      schema: { type: integer, default: 50, maximum: 100 }
    EventSlug:
      name: slug
      in: path
      required: true
      description: Event slug from `GET /api/v1/events`.
      schema: { type: string }
    EventInternalId:
      name: id
      in: path
      required: true
      description: Event integer ID from `GET /api/v1/events` — not a search race ID.
      schema: { type: integer }
    RaceIdPath:
      name: id
      in: path
      required: true
      description: Race ID from search (`id` field) or stored athlete ID from ingested catalog data.
      schema: { type: string }
    PersonOrRaceIdPath:
      name: id
      in: path
      required: true
      description: Race ID or person ref from search, or stored athlete ID from ingested catalog data.
      schema: { type: string }
  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    PaymentRequired:
      description: Valid token but no active subscription
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Forbidden:
      description: Authenticated but not permitted (admin routes require admin account)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    BadRequest:
      description: Invalid or missing parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    TooManyRequests:
      description: Rate limit exceeded — see Retry-After header
      headers:
        Retry-After:
          description: Seconds until the limit resets
          schema: { type: integer }
        X-RateLimit-Limit:
          description: Requests allowed per minute for your plan
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Remaining requests in the current window
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    BadGateway:
      description: Live data temporarily unavailable
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ServiceUnavailable:
      description: Feature disabled or temporarily unavailable
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    HealthResponse:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded] }
        checks:
          type: object
          additionalProperties: { type: string }
        version: { type: string }
    ErrorEnvelope:
      type: object
      properties:
        data: { type: 'null' }
        meta: { type: object }
        errors:
          type: object
          properties:
            type: { type: string }
            title: { type: string }
            status: { type: integer }
            detail: { type: string, nullable: true }
    DataEnvelope:
      type: object
      properties:
        data: {}
        meta: { type: object }
        errors: { type: 'null' }
    PaginationMeta:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        last_page: { type: integer }
    CursorMeta:
      type: object
      properties:
        cursor: { type: string, nullable: true }
        has_more: { type: boolean }
    DataSourceMeta:
      type: object
      properties:
        data_source:
          type: string
          enum: [database, live]
          description: Whether the payload was served from catalog (`database`) or resolved on demand (`live`).
        count:
          type: integer
          description: Number of rows in `data` (search and results endpoints).
        season:
          type: string
          description: Season slug when the request was scoped to one season.
    User:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string, format: email }
        is_admin: { type: boolean }
        subscribed: { type: boolean }
        api_tier:
          type: string
          nullable: true
          description: Tier key (starter, pro, scale)
        plan: { type: string, nullable: true, description: Human-readable plan name }
        rate_limit_per_minute: { type: integer }
    Season:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string, description: Use as season filter on events and athlete search }
        label: { type: string }
        sort_order: { type: integer }
    Event:
      type: object
      properties:
        id: { type: integer, description: Use on division results and stats endpoints }
        slug: { type: string, description: Use as event filter on athlete search }
        season_id: { type: integer }
        name: { type: string }
        city: { type: string, nullable: true }
        country_code: { type: string, nullable: true }
        start_date: { type: string, format: date, nullable: true }
        end_date: { type: string, format: date, nullable: true }
        remote_event_code: { type: string }
        results_count: { type: integer, nullable: true }
    Division:
      type: object
      properties:
        id: { type: integer, description: Same as the parent event id }
        event_id: { type: integer }
        name: { type: string }
        remote_event_code: { type: string }
    ResultRow:
      type: object
      properties:
        id: { type: integer }
        athlete_id: { type: string, nullable: true, description: Stored athlete ID }
        athlete_name: { type: string, nullable: true }
        bib: { type: string, nullable: true }
        age_group: { type: string, nullable: true }
        total_time_ms: { type: integer, nullable: true }
        rank_overall: { type: integer, nullable: true }
        parse_status: { type: string, nullable: true }
        race_ref: { type: string, nullable: true, description: Race ID for this result }
        event_slug: { type: string, nullable: true }
        event_name: { type: string, nullable: true }
        division_name: { type: string, nullable: true }
    SplitRow:
      type: object
      properties:
        canonical_key:
          type: string
          description: Normalized station key (e.g. ski_erg, run_1)
        label_original: { type: string, nullable: true }
        order_index: { type: integer, nullable: true }
        time_ms: { type: integer, nullable: true }
        time_text: { type: string, nullable: true }
        place: { type: integer, nullable: true }
        result_id: { type: integer, nullable: true }
    AthleteSearchHit:
      type: object
      required: [id, display_name]
      properties:
        id:
          type: string
          description: Race ID — pass to `GET /athletes/{id}` and `GET /athletes/{id}/splits`.
        person_ref:
          type: string
          nullable: true
          description: Person ref — pass to `GET /athletes/{id}/results` and `GET /athletes/{id}/splits`.
        display_name: { type: string }
        nationality: { type: string, nullable: true }
        sex: { type: string, nullable: true }
        event_slug: { type: string, nullable: true }
        event_name: { type: string, nullable: true }
        total_time_ms: { type: integer, nullable: true }
        rank_overall: { type: integer, nullable: true }
    AthleteRaceDetail:
      type: object
      properties:
        id: { type: string, description: The race ID passed in the path }
        display_name: { type: string, nullable: true }
        nationality: { type: string, nullable: true }
        sex: { type: string, nullable: true }
        bib: { type: string, nullable: true }
        age_group: { type: string, nullable: true }
        total_time_ms: { type: integer, nullable: true }
        rank_overall: { type: integer, nullable: true }
        rank_gender: { type: integer, nullable: true }
        rank_age_group: { type: integer, nullable: true }
        race_name: { type: string, nullable: true }
        division_name: { type: string, nullable: true }
    EnvelopeUser:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/User'
    EnvelopeSeasonList:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Season'
            meta:
              $ref: '#/components/schemas/PaginationMeta'
    EnvelopeEventList:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Event'
            meta:
              $ref: '#/components/schemas/PaginationMeta'
    EnvelopeEvent:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/Event'
    EnvelopeDivisionList:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/Division'
    EnvelopeResultList:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/ResultRow'
            meta:
              allOf:
                - $ref: '#/components/schemas/CursorMeta'
                - $ref: '#/components/schemas/DataSourceMeta'
    EnvelopeSplitList:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/SplitRow'
            meta:
              $ref: '#/components/schemas/DataSourceMeta'
    EnvelopeAthleteDetail:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/AthleteRaceDetail'
            meta:
              $ref: '#/components/schemas/DataSourceMeta'
    EnvelopeAthleteSearch:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/AthleteSearchHit'
            meta:
              $ref: '#/components/schemas/DataSourceMeta'
    EnvelopeJobDispatched:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
        - type: object
          properties:
            data:
              type: object
              additionalProperties: true
              description: Dispatch result, e.g. sync_run_id and dispatched job count
    EnvelopeGeneric:
      allOf:
        - $ref: '#/components/schemas/DataEnvelope'
