openapi: 3.1.0
info:
  title: Live Tennis API
  version: "1.9.3"
  contact:
    name: Live Tennis API
    url: https://livetennisapi.com
  license:
    name: MIT
    url: https://github.com/livetennisapi/openapi/blob/main/LICENSE
  termsOfService: https://livetennisapi.com/terms
  description: |
    Real-time tennis scores, player data, match-winner market prices, and
    model-driven match analysis. Read-only. Coverage spans ATP, WTA,
    Challenger, ITF and the junior Grand Slam draws — depth differs by tour
    and surface; `GET /history/coverage` states the measured numbers.

    Access is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes
    everything in the tiers below it; the concrete deltas are:

    `FREE` — self-serve, no card (https://livetennisapi.com/subscribe/free).
    Live and upcoming matches, current scores, players, fixtures, the
    tournament catalogue (`/tournaments`), and your own usage stats.
    30 requests/minute, 100/day. No historical results, no market prices,
    no model fields, no WebSocket.

    `BASIC` — adds historical data: the completed-match listing
    (`/history/matches`, and `status=completed` on `/matches`), the
    per-match point-by-point tape with the model win-probability on the
    rows where the model ran
    (`/history/matches/{matchId}`), the measured completeness rollup
    (`/history/coverage`), and the results archive (1968–2022) —
    deep results (`/history/archive/matches`), archive player bios
    (`/history/archive/players`), career aggregates
    (`/history/archive/career`) and head-to-head (`/h2h`).
    60 requests/minute, 1,000/day.

    `PRO` — adds match events (`/matches/{matchId}/events`), market prices
    (`/markets`, `/markets/{matchId}/prices`, `/matches/{matchId}/prices`),
    the pre-built monthly bulk history packages (`/history/packages`) and the
    rank-ordered rankings listing (`/rankings?system=`).
    300 requests/minute, 10,000/day.

    `ULTRA` — adds model analysis (`/matches/{matchId}/analysis`), the live
    model fields (`win_probability_p1`, `danger`) on every score object,
    in-play match statistics (`/matches/{matchId}/statistics`), per-player
    as-of ranking records (`/rankings?player=`), the as-of Elo tape
    (`/rankings?system=elo` — both modes, plus `kind=elo` bulk packages),
    rally construction
    (`/rally/matches`, shot-by-shot charted data), career and per-match
    charting stats (`/charting/players`, `/charting/matches/{chartingMatchId}`),
    the reconstructed 2013–2022 archive tape
    (`/history/archive/matches/{archiveId}/tape` — also opened by ANY active
    History plan, Starter included), the WebSocket live feed at `/ws` and the
    high-fan-out push feed (`/ws-token`), and outbound webhooks (direct keys).
    600 requests/minute, 500,000/day.

    History runs in two continuous halves, deliberately non-overlapping: the
    point-by-point tape (2023→now) covers January 2023 to now, match by
    match, point by point; the results archive (1968–2022) covers 1968
    through 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at
    the time — no point-by-point). The archive ends exactly where the tape
    begins, so no match is ever served from two datasets.

    Archive results played **2013–2022** additionally carry a RECONSTRUCTED
    point-by-point tape at `/history/archive/matches/{archiveId}/tape` — the
    score sequence behind the published result, rebuilt from the public record
    after the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022
    ONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT
    ONE of them has a tape, because there is no public point-by-point record of
    those years to rebuild and we do not manufacture one. Write the range as
    2013–2022, never as "pre-2023" — the second phrasing reads as 1968 onward
    and is wrong by 45 seasons.

    Nobody watched those matches, and the data says so: `timestamp`,
    `win_probability_p1` and `danger` are null on EVERY row and cannot be
    filled in later — the production table has no timestamp column at all, and
    the promotion script refuses to run if one ever appears.
    Contrast the 2023→now tape, which is our own recording: the rows we
    actually watched carry a real clock, and most of them a model probability.
    Coverage of the era is real but partial — 19.3% of archive matches played
    2013–2022 and 44.9% of tour-level play; main-draw tour buckets run
    91.6–98.7%, ATP Challenger main draws 55.3% and Challenger qualifying
    33.6%, slam QUALIFYING only 16.0% (ATP) / 18.1% (WTA), and ITF/futures
    effectively nothing (25 of 116,575 ATP futures matches). It is not a
    complete record of the era and is not sold as one.

    Two different gates, on purpose: the per-match tape needs core ULTRA **or
    any active History plan, Starter included**; the per-year bulk files
    (`/history/packages?kind=archive_tape`, 2013–2022, JSONL + CSV) need core
    ULTRA **or** a History Pro/Business subscription (an active one-off package
    window counts). Core PRO carries NEITHER — it reads the archive RESULT and
    is refused the tape.

    A call above your tier returns `403 {"error":"upgrade_required"}` — never
    a silent empty result.

    CORS is enabled across the REST surface: every response carries
    `Access-Control-Allow-Origin: *` (GET/OPTIONS, no credentials mode — there
    is no cookie or session, and a wildcard origin is incompatible with
    credentials by design). Putting a FREE key in browser code is acceptable —
    it is capped and revocable; a paid key belongs server-side only.

    The `/history/*` endpoints are also sold standalone as the **Historical
    Data API** (no live-API subscription required): **Starter** — single-match
    point-by-point tape reads via the API (tape plus the model win-probability
    per point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per
    request, no bulk downloads; **Pro** — everything in Starter plus bulk
    monthly package
    downloads and higher rate limits; **Business** — everything in Pro plus
    year-scale archive exports, top rate limits and priority support. One-off
    1-month and 1-year access passes are available without a subscription.
    The results archive (1968–2022) endpoints (`/history/archive/*`, `/h2h`)
    ride with the same entitlement — any active History plan, Starter
    included, opens them alongside the tape endpoints, and that includes the
    reconstructed 2013–2022 archive tape. The per-year `archive_tape` bulk
    files do not: those need Pro, Business or an active one-off package pass,
    because a Starter grant reads tapes one at a time and does not download
    years of them.
    Plans and prices: https://livetennisapi.com/historical-tennis-data-api

    All timestamps are UTC ISO 8601 with a `Z` suffix. List endpoints return
    `{data, meta}`; single resources return the object directly. Ignore
    unknown fields — additive changes land within v1.

    A native WebSocket live feed (ULTRA) exists at `/ws` under the same base
    URL. Subscribe with one JSON frame whose keys are `topics` and
    (optionally) `signals`: `{"topics":["live-scores"]}` — `topics` may also
    name `"match:<id>"`. The server acks with a `subscribed` frame, then
    pushes `score` frames on every change plus a `ping` heartbeat roughly
    every 15s. Score frames carry the ULTRA model fields
    (`win_probability_p1`, `danger`) live; a null there means the model had
    no output for that point, not that the field is REST-only. Opt into extra
    signals with `{"topics":["live-scores"],"signals":["break_point"]}` to
    also receive `break_point` and `break_point_result` frames (schemas
    `BreakPoint` / `BreakPointResult`). Without `signals`, score frames only.

    `signals` may also name `points` — the live per-point event stream: one
    `point` frame (schema `PointFrame`) per persisted point of your
    subscribed matches, ordered per match by `seq`. The signal is
    config-gated and ships OFF by default; the `subscribed` ack echoes the
    signals actually active, so `points` present in the ack means point
    frames will flow and missing means they will not. Frames arrive only for
    matches with `pbp_coverage: "point"` — a `game`-coverage match sends
    none, honestly. Best-effort with NO replay: on reconnect (or to join
    mid-match) catch up via `GET /matches/{matchId}/points?after_seq=` and
    dedup by `seq`.

    Max 2 concurrent connections per key. For high fan-out, `GET /ws-token`
    mints a token for the separate push feed.

    Getting a match id: it is the `id` field on any match object returned by
    `GET /matches`, `GET /fixtures` or `GET /history/matches`, and the same value
    works on every route that takes `matchId`.

servers:
  - url: https://api.livetennisapi.com/api/public/v1
security:
  - bearerAuth: []
  - apiKeyHeader: []
paths:
  /health:
    get:
      summary: Liveness probe (no auth)
      operationId: healthCheck
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  version: { type: string, const: v1 }
  /matches:
    get:
      summary: List matches by lifecycle status (FREE)
      description: >-
        `status=live` and `status=upcoming` are the FREE current-state picture.
        `status=completed` pages historical results and is part of the paid
        History product — it requires BASIC (the same rule as
        `/history/matches`) and returns `403 upgrade_required` on a FREE key.
        The `player`, `country`, `from`/`to`, `tour` and `draw` filters are
        optional, AND-composed, applied inside the query (before pagination),
        and work on every status — omitting them returns exactly what the
        endpoint returned before they existed.
      operationId: listMatches
      parameters:
        - name: status
          in: query
          description: >-
            `live` (default) and `upcoming` are the FREE current-state picture.
            `completed` and `cancelled` are terminal LISTINGS, part of the
            history product (BASIC, or any History plan on a free key).
            `cancelled` covers feed-cancelled, walkover-with-no-stated-winner
            and postponed-never-played matches; a walkover that named its
            winner is `completed`. `cancelled` pages with `limit`/`offset`
            (optionally `from`/`to`) and does NOT accept `updated_since`
            (400 `bad_request`). Any other value is a 400 `bad_status`
            carrying the accepted list in `allowed`.
          schema: { type: string, enum: [live, upcoming, completed, cancelled], default: live }
        - $ref: "#/components/parameters/tour"
        - $ref: "#/components/parameters/draw"
        - $ref: "#/components/parameters/player"
        - $ref: "#/components/parameters/country"
        - $ref: "#/components/parameters/tournamentId"
        - $ref: "#/components/parameters/playedFrom"
        - $ref: "#/components/parameters/playedTo"
        - $ref: "#/components/parameters/updatedSince"
        - $ref: "#/components/parameters/cursor"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Matches with latest score
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Match" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}:
    get:
      summary: Full match detail (FREE; +market PRO, +analysis ULTRA)
      operationId: getMatch
      parameters: [ { $ref: "#/components/parameters/matchId" } ]
      responses:
        "200":
          description: Match with score; `market` embed at PRO+, `analysis` embed at ULTRA
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MatchDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/score:
    get:
      summary: Current score only — lowest-latency REST read (FREE)
      description: >-
        This is a POINT-IN-TIME SNAPSHOT: the single current state, overwritten
        on every score commit. It carries no history and no accumulated
        statistics.
        For the SEQUENCE of states — who served each game, hold/break, every
        score state in forward order — use
        `/history/matches/{matchId}?sequence=clean`, which works on a LIVE
        match, not only a completed one. For in-play statistics use
        `/matches/{matchId}/statistics` (ULTRA); they are deliberately not on
        this object, because they can be further behind the match than the
        score and must carry their own `as_of`.
      operationId: getMatchScore
      parameters: [ { $ref: "#/components/parameters/matchId" } ]
      responses:
        "200":
          description: Current score (ULTRA adds win_probability_p1 + danger)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Score" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/events:
    get:
      summary: Match events, newest first (PRO)
      operationId: listMatchEvents
      parameters:
        - $ref: "#/components/parameters/matchId"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/analysis:
    get:
      summary: Model analysis for a match (ULTRA)
      description: >-
        The model's thesis and profile for one match.


        COVERAGE IS NOT UNIVERSAL, and a polling client should plan for that.
        Analysis is produced per match by the model pipeline rather than
        emitted for every fixture: over the seven days to 2026-08-27, 1,225 of
        2,863 matches that went live or completed carried one (42.8%). A match
        that has none yet returns `404 {"error":"no_analysis"}` (since
        2026-09-02; before that the body was a bare `not_found`) — that is the
        documented absence, not a fault, and it can turn into a 200 later in
        the same match once the pipeline has run. Never treat this 404 as a
        reason to retry harder. The body names which absence it is: `not_found`
        is an id that does not exist; `no_analysis` carries `match_id` and
        `coverage: "none"` for a real match with nothing computed.


        FILTER THE SLATE FIRST. Every row of `GET /matches` and the detail
        carries `has_analysis` (every tier), the same fact this endpoint
        answers 404 about — read it there and call only the matches that carry
        one, instead of spending one 404 per match.


        ONE CALL INSTEAD OF THREE. `GET /matches/{matchId}` carries the same
        thesis and profile in its `analysis` key on ULTRA, alongside `market`
        and `market_price` on PRO and above, next to the live score. It answers
        `200` whether or not analysis and a market exist — the keys are `null`
        instead — so a per-match poll built on the detail route replaces the
        score, analysis and prices calls with one request and never spends a
        call on a 404.
      operationId: getMatchAnalysis
      parameters: [ { $ref: "#/components/parameters/matchId" } ]
      responses:
        "200":
          description: Thesis + profile (either may be null)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Analysis" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404":
          description: >-
            `error: not_found` — no such match id. `error: no_analysis` (with
            `match_id`, `coverage: "none"`, `detail`) — the match exists and
            nothing has been computed for it; `has_analysis` on the match list
            says so without a probe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/statistics:
    get:
      summary: In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)
      description: >-
        In-play statistics for one match, in TWO families that are deliberately
        not merged.


        DERIVED (the top level of `players.pN`) are rebuilt from the
        point-by-point record: service and return games played and won, hold
        and break percentage, break points faced, saved and converted, service
        and return points.


        MEASURED (`players.pN.measured`) are counted upstream, so they include
        what no point record can yield — ACES AND DOUBLE FAULTS, the first- and
        second-serve split, winners and unforced errors. Both families name
        some of the same quantities, computed two entirely different ways; that
        is a cross-check, not a duplication to collapse.


        Measured coverage is not uniform and every measured field is optional —
        an absent field is OMITTED, never zero-filled, so read the keys you are
        given. Aces and double faults are present across every tour. The serve
        split and break points saved are present on the main tours and absent
        on ITF singles. Winners and unforced errors historically appeared on
        a minority of main-tour matches and have not been delivered upstream
        since 2026-07-12 (measured 2026-08-17).


        `freshness.derived` and `freshness.measured` each carry their own
        `coverage` (`live` | `final` | `stale` | `none` | `diverged`;
        `final` = the closing figures of a completed match — a finished match
        cannot be "stale", so its `age_seconds` is null), `as_of`,
        `age_seconds` and `describes` — the match state the numbers describe.
        On `diverged` the measured VALUES are withheld and
        `freshness.measured_divergence` says why; the top-level `coverage` only
        summarises the response. `none` on both returns 200 with null players,
        not 404 — the match exists and holding nothing for it is the honest
        answer.


        THE TWO AGES USE DIFFERENT CLOCKS AND MUST NOT BE COMPARED. The derived
        age is measured against the newest SCORE row, because between points
        there is no new score either and wall-clock age would report staleness
        that does not exist. The measured age is wall clock, because those are
        fetched on a fixed cadence.


        Tiebreak games are excluded from the DERIVED family and counted
        separately; the live record collapses a whole tiebreak onto one entry,
        so most of its points are lost.
      operationId: getMatchStatistics
      parameters: [ { $ref: "#/components/parameters/matchId" } ]
      responses:
        "200":
          description: Statistics with their own coverage and as_of
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MatchStatistics" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/points:
    get:
      summary: Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)
      description: >-
        The live per-point event stream of one match, in `seq` order. The
        WebSocket `point` frames are best-effort with NO replay, so this
        endpoint is how you join mid-match and how you recover a dropped
        connection: subscribe the WS first, then GET with `after_seq` set to
        the last seq you hold, then dedup everything by `seq` — it is
        per-match, monotonic and never skips a value, so it is the whole
        reconciliation key.


        READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream
        is per-point ONLY where a point-level feed covers it:
        `pbp_coverage: "point"` means this match has a true per-point stream;
        `"game"` means only the snapshot score path covers it — `points` is
        empty and that is an answer, not an error. Per-point coverage is
        never promised slate-wide; ITF and qualifying coverage in particular
        is partial. `quality: "revised"` means the upstream feed rewrote an
        already-served prefix at least once during this match; served rows
        are never edited (append-only).


        Each row is the state AFTER a played point: `score`/`sets`/`games`
        (tiebreaks carry the running count in `score` with `games` frozen at
        the pre-breaker score), its position (`set`/`game`/`number`),
        `server` (of the next point), the derived `winner` (null when not
        attributable to a single point — never guessed), and `ts` — CAPTURE
        time, when our pipeline committed the state, because no feed asserts
        a per-point clock and we fabricate none.


        Up to 500 rows per page; `after_seq=last_seq` fetches the next page
        while `has_more` is true. 404 unknown match; 400 `points_disabled`
        while the surface is switched off server-side.


        COMPLETED MATCHES: live capture is inherently partial — the stream
        serves what arrived while the match ran, and the match-closing point
        never streams live. Where a measured-complete recorded point sequence
        of the finished match exists, this endpoint serves THAT instead — the
        complete sequence projected into the same point-frame shape, love-love
        opener through the match-closing point, `seq` contiguous 1..N. The
        response field `basis` says which base served the page: `live` (the
        persisted live stream rows — every live match, and any completed match
        without a measured-complete recorded sequence) or `reconstruction`
        (the projected complete sequence; `quality` is `clean`, every
        transition measured legal). Completeness beats the partial live
        capture wholesale — the two sequences are never interleaved (they
        share no key, so any merge would fabricate an order). On projected
        frames `ts` is null on every row: the recorded sequence carries no
        per-point clock and we fabricate none. `after_seq` pagination and
        `seq` dedup work identically on either basis, but the two bases are
        different sequences: after a match completes and flips to
        `reconstruction`, re-read from `after_seq=0` rather than resuming a
        live cursor into it.
      operationId: getMatchPoints
      parameters:
        - $ref: "#/components/parameters/matchId"
        - name: after_seq
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
          description: >-
            Return only points with `seq` greater than this — the resume
            cursor. Pass the `last_seq` of the previous page (or the last seq
            your WS stream delivered) to continue; 0 or absent reads from the
            start of the match. A non-integer or negative value is a 400
            `bad_after_seq`.
      responses:
        "200":
          description: The point events page, seq order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MatchPoints" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /players:
    get:
      summary: Search players by name (FREE)
      operationId: searchPlayers
      parameters:
        - name: search
          in: query
          schema: { type: string }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Players (ranked first; no stats object on the list)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Player" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /players/{playerId}:
    get:
      summary: One player's bio + ranking + cached stats (FREE)
      operationId: getPlayer
      parameters:
        - name: playerId
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Player with `stats` ({ratings, season})
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Player" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /tournaments:
    get:
      summary: Tournament catalogue — the id space `Match.tournament_id` joins (FREE)
      description: >-
        Stable tournament identity, one row per tournament × event type, stable
        across seasons. `city`/`country` come from a curated table and
        `category` only where our catalogues agree unambiguously on an
        exact-name join — each is null otherwise, never derived from the
        tournament name.
      operationId: listTournaments
      parameters:
        - name: search
          in: query
          schema: { type: string }
          description: Case-insensitive substring match on the tournament name.
        - $ref: "#/components/parameters/tour"
        - $ref: "#/components/parameters/draw"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Tournaments, name order
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Tournament" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /tournaments/{tournamentId}:
    get:
      summary: One tournament by its stable id (FREE)
      operationId: getTournament
      parameters:
        - name: tournamentId
          in: path
          required: true
          schema: { type: string }
          description: The `tournament_id` carried on match objects.
      responses:
        "200":
          description: The tournament
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tournament" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /markets:
    get:
      summary: Match-winner market(s) for a match (PRO)
      operationId: listMarkets
      parameters:
        - name: match_id
          in: query
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Markets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Market" }
                  meta:
                    type: object
                    properties:
                      match_id: { type: integer }
                      count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /markets/{matchId}/prices:
    get:
      summary: Market + recent price ticks per side, newest first (PRO)
      description: >-
        The mapped match-winner market for one match, with its recent price
        ticks per side, newest first.


        404 with `error: no_market` (since 2026-09-02; `match_id` and
        `coverage: "none"` alongside) when no match-winner market is mapped to
        the match — a market is mapped only where one is published for that
        fixture, so a client walking every live match will meet this. A bare
        `not_found` means the match id itself does not exist. Every row of
        `GET /matches` carries `has_market`, so a slate can be filtered before
        any call here. `GET /matches/{matchId}`
        answers `200` for the same match with `market` and `market_price` set
        to `null` instead, and carries the live score in the same response, so
        a per-match poll built on the detail route never spends a call on a
        404.
      operationId: getMarketPrices
      parameters:
        - $ref: "#/components/parameters/matchId"
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Market with `prices`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Market" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404":
          description: >-
            `error: not_found` — no such match id. `error: no_market` (with
            `match_id`, `coverage: "none"`, `detail`) — the match exists and no
            match-winner market is mapped to it; `has_market` on the match list
            says so without a probe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /matches/{matchId}/prices:
    get:
      summary: Bare price ticks of the mapped match-winner market, newest first (PRO)
      description: >-
        Recent ticks only (no market wrapper). `limit` caps at 500; `minutes`
        bounds the lookback window. 404 when the match has no mapped market.
      operationId: listMatchPrices
      parameters:
        - $ref: "#/components/parameters/matchId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: minutes
          in: query
          schema: { type: integer, minimum: 1, maximum: 1440 }
      responses:
        "200":
          description: Price ticks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Price" }
                  meta:
                    type: object
                    properties:
                      match_id: { type: integer }
                      count: { type: integer }
                      has_more:
                        type: boolean
                        description: >-
                          The window was clipped at limit — older ticks exist.
                          There is no offset here; raise limit or narrow
                          minutes.
                      limit: { type: integer }
                      minutes: { type: [integer, "null"] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/matches:
    get:
      summary: Completed matches, newest first, with derived winner and tape coverage (BASIC)
      description: >-
        Requires BASIC on the live API, or any Historical Data API plan
        (Starter and up — see https://livetennisapi.com/historical-tennis-data-api).
        All tours, January 2023 → now (deeper results live in the results
        archive, 1968–2022, at `/history/archive/matches`). Filter to a date
        range with `from`/`to`, and by `tour`, `draw` (singles/doubles),
        `player` (either participant) and `country` — same vocabulary as
        `/matches`. Each item carries a
        `tape` object saying what point-by-point data we hold for that match,
        so a whole page can be qualified in one call instead of one request per
        match. NOTE `?coverage=` is applied AFTER the page is cut, so a
        filtered page is routinely shorter than `limit` (and may be empty)
        while later pages still hold matching matches — a short filtered page
        is not an end-of-data signal; `?points_complete=` filters the same
        way.
      operationId: listCompletedMatches
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
        - $ref: "#/components/parameters/playedFrom"
        - $ref: "#/components/parameters/playedTo"
        - $ref: "#/components/parameters/tour"
        - $ref: "#/components/parameters/draw"
        - $ref: "#/components/parameters/player"
        - $ref: "#/components/parameters/country"
        - $ref: "#/components/parameters/tournamentId"
        - name: coverage
          in: query
          required: false
          schema: { $ref: "#/components/schemas/Coverage" }
          description: >-
            Keep only matches whose tape has this coverage. An unknown value is
            a 400 `bad_coverage` listing the accepted values in `allowed`.
        - name: points_complete
          in: query
          required: false
          schema: { type: string, enum: ["true", "false"] }
          description: >-
            Keep only matches whose measured point-completeness ledger verdict
            is this value — best-basis (the served tape OR an on-disk
            reconstruction measured point-complete; fetch the latter with
            `?points=complete` on the per-match tape). The ledger is a
            per-match cache reconverged nightly. A match not yet measured
            matches NEITHER value; anything but true/false is a 400
            `bad_points_complete`. Applied AFTER the page is cut, exactly like
            `?coverage=`.
      responses:
        "200":
          description: Completed matches (`winner` = 1|2|null, from final sets), each with its tape coverage
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryMatch" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/coverage:
    get:
      summary: Measured completeness rollup per tour × draw bucket (BASIC)
      description: >-
        Requires BASIC on the live API, or any Historical Data API plan
        (Starter and up — see https://livetennisapi.com/historical-tennis-data-api).
        The numbers to read BEFORE choosing what to backtest, in one call
        instead of paging the archive. A PREBUILT snapshot rebuilt nightly
        right after the completeness ledger reconverges — never computed at
        read time — so `as_of` (= `built_at`) dates every number, and
        `ledger_max_computed_at` is the newest underlying per-match
        measurement. Buckets are atp/wta/challenger/itf/juniors ×
        singles/doubles plus `other` (team ties, mixed, exhibitions, and
        matches with no stated event type — counted, never dropped, so the
        totals cannot lie), derived from the same registries as the `tour` and
        `draw` fields. `method` states the full measurement rule in one
        paragraph, so every number carries its own definition. As of
        2026-08-18 the headline spread it exposes: 51.1% of ITF singles
        matches are point-complete on the best basis against 3.5% of ITF
        doubles — do not extrapolate a completeness rate across a tour group.
      operationId: getHistoryCoverage
      responses:
        "200":
          description: The rollup artifact, dated by its own as_of
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CoverageRollup" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: >-
            coverage_unavailable — the artifact has not been built yet (or is
            unreadable). Honest and temporary; retry after the nightly build.
            The rollup is never computed inline.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /history/matches/{matchId}:
    get:
      summary: Per-match tape — point-by-point score + per-point model probabilities (BASIC)
      description: >-
        The tape is the point-by-point score sequence we hold for this match —
        every recorded score row including the model fields
        `win_probability_p1` and `danger` at that point — plus match metadata
        and the model profiles produced during the match. The model fields here
        are part of the paid History product by design, distinct from the
        ULTRA-gated LIVE model fields. One match per request. Requires BASIC on
        the live API, or the Historical Data API Starter plan and up.


        The tape is NOT guaranteed to cover the whole match — check
        `meta.coverage` and `meta.point_source` before backtesting. Rows
        expanded after the fact from a finished-match point-by-point record
        carry a null `timestamp` and null model fields; nothing is ever
        synthesised.


        WORKS ON A LIVE MATCH, not only a completed one. The tape is assembled
        from whatever has been committed so far, so it is how you read the
        point-by-point history of a match in progress — including games played
        before you started watching, where we were already watching them. The
        LIST endpoint is completed-only; get live ids from
        `/matches?status=live`. `/matches/{matchId}/score` is one state; this
        is the sequence of states.
      operationId: getMatchTape
      parameters:
        - $ref: "#/components/parameters/matchId"
        - name: sequence
          in: query
          required: false
          schema: { type: string, enum: [raw, clean], default: raw }
          description: >-
            `raw` (default) is every row we committed, in commit order —
            deliberately non-monotonic, since independent sources race and a
            higher-trust one may correct a lower-trust one backwards. `clean`
            returns one row per distinct score state, keeping the last
            assertion of each, ordered by MATCH PROGRESS (the same ranking
            our arbiter uses), not by wall clock — so array order is the
            authoritative point order, and `timestamp` values may locally
            disagree with it by a few seconds where a stale re-assertion
            carried a later clock. Within a game, deuce states legitimately
            cycle (40-40 ↔ AD) and rank equally, so consecutive clean rows
            are not guaranteed point-monotonic there; `point_winner` is only
            set where the transition is a single attributable point. An
            unknown value is a 400 `bad_sequence`.
        - name: points
          in: query
          required: false
          schema: { type: string, enum: [default, complete], default: default }
          description: >-
            `default` serves observed rows first — what our own pipeline
            committed, a SAMPLED record of the match. `complete` is the
            explicit opt-out of that precedence for consumers who want every
            point: where a whole-match reconstruction exists it is served
            WHOLE, in its own point order, with `point_winner` on every row
            and null timestamps/model fields per the reconstruction contract.
            Where none exists, the response is exactly the default read plus
            `meta.points` (whose `available_complete` tells the cases apart) —
            no error. Cannot combine with `sequence=clean` (400
            `bad_combination` — the state-key collapse would delete the
            repeated deuce states a complete point sequence contains). An
            unknown value is a 400 `bad_points`; where not yet enabled,
            `complete` answers 400 `points_read_disabled` rather than silently
            serving the default. `coverage` and `meta.points` are orthogonal
            axes: coverage says how the rows were OBTAINED, points says how
            COMPLETE the sequence is — completeness is only ever claimed per
            match, as measured.
      responses:
        "200":
          description: The full tape (`match` + `tape` + `profiles` + coverage `meta`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoryTape" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/archive/matches:
    get:
      summary: Results archive (1968–2022) — deep historical results (BASIC)
      description: >-
        Completed-match RESULTS from a licensed historical corpus — ATP and
        WTA main draws, qualifying/challengers and futures tiers, 1968 through
        2022. Winner/loser-shaped records with final score, round, seeds, the
        players' ranks AT THE TIME, and per-match serve statistics where the
        era recorded them. Requires BASIC on the live API, or any Historical
        Data API plan (Starter and up).


        A SEPARATE id space from `/matches` — archive people are identified by
        the corpus person id and by name, never by roster player ids — and the
        archive ends where our own point-by-point coverage begins (2023-01),
        so no match is ever served from two datasets. `event_date` is the
        TOURNAMENT START date, the only date records of this era carry.
      operationId: listArchiveMatches
      parameters:
        - name: tour
          in: query
          schema: { type: string, enum: [atp, wta] }
          description: The archive covers the ATP and WTA corpora only.
        - name: name
          in: query
          schema: { type: string, minLength: 3 }
          description: >-
            Case-insensitive substring match on EITHER player's name (min 3
            chars).
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Earliest tournament start date (`YYYY-MM-DD`).
        - name: to
          in: query
          schema: { type: string, format: date }
          description: Latest tournament start date (`YYYY-MM-DD`).
        - name: round
          in: query
          schema:
            type: string
            enum: [F, SF, QF, R16, R32, R64, R128, RR, BR, Q1, Q2, Q3, Q4, ER]
          description: The archive's controlled round vocabulary.
        - name: level
          in: query
          schema: { type: string }
          description: >-
            Source tier code: G=grand slam, M=masters, A=tour, F=finals,
            D=davis cup, C=challenger, O=olympics; the futures tiers carry
            their category codes (e.g. 15, 25) as published.
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Archive results, newest tournament first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ArchiveMatch" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/archive/matches/{archiveId}:
    get:
      summary: One archive result, with serve statistics where recorded (BASIC)
      description: >-
        Same entitlement as the archive listing. `stats` is null for the
        (mostly pre-1991) rows the source never recorded statistics for —
        never synthesised. A result played 2013–2022 may also have a
        reconstructed point-by-point tape at
        `/history/archive/matches/{archiveId}/tape`; the RESULT stays on BASIC
        either way.
      operationId: getArchiveMatch
      parameters:
        - name: archiveId
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The archive record, `stats` included where the era recorded them
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArchiveMatch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/archive/matches/{archiveId}/tape:
    get:
      summary: >-
        Reconstructed 2013–2022 point-by-point tape for one archive result
        (ULTRA, or any History plan)
      description: >-
        The score sequence behind a 2013–2022 archive result, REBUILT FROM THE
        PUBLIC RECORD. The result tells you what happened; this tells you how.


        NOBODY WATCHED THESE MATCHES, and every field that would imply
        otherwise is null. `timestamp` is null on every row — not by
        convention, but because the production table has no timestamp column at
        all, so no repair, backfill or re-archive can put a wall clock on a
        2016 row, and the promotion script refuses to run if such a column ever
        appears.
        `win_probability_p1` and `danger` are null on every row: the model was
        not running in 2016 and nothing has been back-computed and served as if
        it had been (`meta.model_rows` and `meta.observed_rows` are both 0,
        always). `profiles` is always `[]`. The rule we publish for the 2023+
        tape — a non-null `timestamp` means we watched that point live — holds
        across both halves of the product.


        YEARS: 2013–2022 ONLY. 97,901 matches and 14,340,663 rows. The results
        archive holds a further 977,903 matches from 1968–2012 and NONE of them
        has a tape — there is no public point-by-point record of those years to
        rebuild and we do not manufacture one. A match with no tape answers
        404, which is also the answer when a tape exists and failed the proof
        below; the two are deliberately not distinguished.


        COVERAGE OF THE ERA, thin spots beside the strong ones: 19.3% of
        archive matches played 2013–2022 overall, 44.9% of tour-level play.
        Main-draw tour buckets run 91.6–98.7% (ATP Masters 98.7%, ATP slam
        98.0%, WTA Premier Mandatory 97.9%, WTA slam 97.4%, ATP 250–500 95.4%,
        WTA Premier 94.1%, WTA International 91.6%); ATP Challenger main draws
        55.3% and Challenger qualifying 33.6%; slam QUALIFYING is thin at 16.0%
        (ATP) and 18.1% (WTA); ITF and futures are effectively absent — 25 of
        116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25
        — and this product does not cover them. If your work is ITF, this is
        the wrong dataset and we would rather you knew now. 31.0% of the corpus
        is qualifying-draw play, which is unusual at this scale and is the part
        you are least likely to hold already.


        HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is
        silent, permanent and poisons anything trained on it, so each one
        proves five things before publication: the EDITION binds by majority
        vote onto one archive tournament; the NAMES resolve to exactly one
        archive row with an unambiguous orientation; the ROUND agrees through
        an explicit map (this is what separates the qualifying and main-draw
        meetings of the same pair inside one edition); the SET SPINE derived
        from the rows equals the published final score exactly (a retirement
        matches on a one-directional prefix rule — a tape may stop short, never
        run past the printed score); and the vendor DATE lies inside the
        tournament's window. The tape is then walked step by step against 23
        interior invariants — games and sets never regress or jump, a closed
        set holds a score a set can actually finish on, a tiebreak is flagged
        only where one can be played, points never run backwards inside a game,
        `seq` is contiguous. A tape that fails any clause or any invariant is
        REFUSED WHOLESALE rather than published against a guess, which is why
        this endpoint answers 404 instead of serving a probable match.


        `archiveId` is an `archive_matches.id` (the `id` on a
        `/history/archive/matches` row), NOT a match id — the two are separate,
        overlapping integer spaces, so passing one to the other's routes
        resolves a different, real record without erroring. The response names
        it `meta.archive_match_id` for exactly that reason. Rows are
        WINNER-FIRST (`sets[0]` / `games[0]` / `points[0]` are the winner's,
        `server: 1` means the winner was serving), unlike the p1/p2-shaped
        2023+ tape — a live match has no winner while it is being played.


        This operation takes NO query parameters: `sequence` and `points` are
        ignored rather than refused. `meta.sequence` is always `raw` — a
        reconstructed tape has one source and no corrections, so there is
        nothing for a clean collapse to remove.


        Tier: core ULTRA, or any active History plan (Starter included, which
        opens it on a FREE core key). The archive RESULT itself
        (`GET /history/archive/matches/{archiveId}`) stays on BASIC — core
        BASIC and core PRO read the result and are refused the tape, and the
        refusal is `403 upgrade_required` carrying `capability: archive_tape`.
        The per-year BULK files (`?kind=archive_tape`) are a separate gate:
        core ULTRA, or a History Pro/Business subscription, or an active
        one-off package window.
      operationId: getArchiveTape
      parameters:
        - name: archiveId
          in: path
          required: true
          description: >-
            An `archive_matches.id` — the `id` on a
            `/history/archive/matches` row, never a match id.
          schema: { type: integer }
      responses:
        "200":
          description: >-
            The reconstructed tape (`match` + `tape` + `tiebreaks` +
            an always-empty `profiles` + provenance `meta`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArchiveTape" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404":
          description: >-
            No tape for this archive result — it is 1968–2012, it is
            2013–2022 and was never reconstructed, or a reconstruction exists
            and did not prove its binding. The three are deliberately not
            distinguished: "we hold something we will not stand behind" is not
            a state this product reports.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/archive/players:
    get:
      summary: Archive player bios — hand, DOB, country, height, career-high (BASIC)
      description: >-
        People of the results archive (1968–2022), in their own id space —
        `id` is the corpus person id that archive match rows carry as
        `winner.player_id` / `loser.player_id`, scoped per tour; never a
        roster id. Career-high rank and the earliest week it was reached are
        computed offline from the corpus's own weekly ranking tables. Null
        fields are the era's silence, never guessed. Requires BASIC, or any
        Historical Data API plan.
      operationId: listArchivePlayers
      parameters:
        - name: name
          in: query
          schema: { type: string, minLength: 3 }
          description: Case-insensitive substring filter (min 3 chars).
        - name: tour
          in: query
          schema: { type: string, enum: [atp, wta] }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Archive people, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ArchivePlayerBio" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/archive/career:
    get:
      summary: Career aggregates over the results archive, 1968–2022 (BASIC)
      description: >-
        One player's whole archive career in one response: W-L record
        (overall, by surface, by level, by year), titles, and the summed
        serve-stat block with derived ratios. Everything is a sum or a ratio
        of sums over rows you can fetch individually from
        `/history/archive/matches` — nothing is modelled.
        `serve.matches_with_stats` states the coverage honestly: the corpus
        records per-match serve statistics from 1991 only, so a 1970s career
        has a full W-L record and an empty serve block. Ambiguous name
        fragments are refused with candidates (same rule as `/h2h`); an
        unknown name is a 404. Requires BASIC, or any Historical Data API
        plan.
      operationId: getArchiveCareer
      parameters:
        - name: name
          in: query
          required: true
          schema: { type: string, minLength: 3 }
          description: >-
            Player name (fragment, min 3 chars — must resolve to one person).
      responses:
        "200":
          description: The career aggregate body
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArchiveCareer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /h2h:
    get:
      summary: >-
        Head-to-head across the results archive (1968–2022) and our own
        completed matches (2023→now) (BASIC)
      description: >-
        The record between two players, assembled from BOTH halves of the
        product: the results archive, where the winner is a stored column, and
        our own completed matches, where the winner is derived from the final
        recorded state. Names are the keys — archive people have no roster
        ids. A fragment matching more than one player is refused with the
        candidate list (`400 ambiguous_name`), because two people summed into
        one record is a wrong answer, not a convenience. Totals count meetings
        with a KNOWN winner; `undecided` counts the rest. Walkovers and
        retirements are part of the record, and each meeting carries `outcome`
        so you can exclude them. Requires BASIC, or any Historical Data API
        plan. On ULTRA, a per-player `stats` block adds serve/return/break-point
        aggregates over the pairing: `archive_serve` (serve-side, from 1991)
        and `current` (2023+, adding return and break-point conversion, aces
        and winners), each with `meetings_with_stats`.
      operationId: getHeadToHead
      parameters:
        - name: p1
          in: query
          required: true
          schema: { type: string, minLength: 3 }
          description: First player name (fragment, min 3 chars).
        - name: p2
          in: query
          required: true
          schema: { type: string, minLength: 3 }
          description: Second player name (fragment, min 3 chars).
      responses:
        "200":
          description: >-
            The head-to-head record; empty totals when no player matches the
            names
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadToHead" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/packages:
    get:
      summary: List the pre-built monthly bulk history packages (PRO)
      description: >-
        Bulk downloads are a heavier product than single-match tape reads.
        Requires PRO on the live API, or the Historical Data API Pro plan and
        up, or a one-off package access pass. A key that can read the tape but
        is not package-entitled receives `403 upgrade_required`.
      operationId: listHistoryPackages
      parameters:
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [tape, rankings, rally, archive, elo, archive_tape], default: tape }
          description: >-
            Package family. `tape` (default) = monthly point-by-point match
            tapes; `rankings` = as-of ranking records (ULTRA); `rally` = the
            charted rally corpus (shot-by-shot) as YEARLY exports (ULTRA);
            `archive` = the results archive (1968–2022) as YEARLY exports,
            same entitlement as the tape packages; `elo` = the as-of Elo tape
            as YEARLY exports (ULTRA); `archive_tape` = the RECONSTRUCTED
            2013–2022 point-by-point tape as YEARLY exports — ten periods, 2013
            through 2022, each with a JSONL and a CSV file, all `ready`,
            needing ULTRA or a
            History Pro/Business subscription or an active one-off package
            window (a History Starter grant reads a tape per call and does not
            download years of them). The yearly kinds' `period`
            is `YYYY`, one file per year, because a fixed historical corpus is
            not an accruing monthly stream. The default means a tape-only
            client never sees a new kind of row appear.
        - name: year
          in: query
          required: false
          schema: { type: string, pattern: '^\d{4}$' }
          description: >-
            Year archive listing — every published month of the year (History
            Business, a 1-year package, or ULTRA).
      responses:
        "200":
          description: Ready packages, newest period first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryPackage" }
                  meta:
                    type: object
                    properties:
                      count: { type: integer }
                      year:
                        type: string
                        description: Echoed only when `?year=` was supplied.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/packages/{period}:
    get:
      summary: One monthly package — manifest, or the bulk file itself (PRO)
      description: >-
        Without `format` returns the package manifest (file set, counts,
        sha256). With `format=jsonl` or `format=csv` streams that file as an
        attachment. Same entitlement as `/history/packages`. 404 when the
        month has not been built yet — list available months first.
      operationId: getHistoryPackage
      parameters:
        - name: period
          in: path
          required: true
          description: >-
            Calendar month, `YYYY-MM` — except for the yearly kinds (`rally`,
            `archive`, `elo`, `archive_tape`), where it is the bare year
            `YYYY` (`400 bad_period` otherwise).
          schema: { type: string, pattern: '^\d{4}(-\d{2})?$' }
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [tape, rankings, rally, archive, elo, archive_tape], default: tape }
          description: >-
            Package family; `rankings`, `rally`, `elo` and `archive_tape` require ULTRA, a History
            Pro/Business subscription, or an active one-off package window (changed 2026-08-19).
            `rally` = the yearly charted rally corpus exports; `archive` =
            the yearly results archive (1968–2022) exports, same entitlement
            as the tape packages; `elo` = the yearly as-of Elo tape exports;
            `archive_tape` = the yearly reconstructed 2013–2022 point-by-point
            tape exports (`period` 2013 through 2022 — no other year exists).
        - name: format
          in: query
          required: false
          description: Omit for the JSON manifest; set to download the file.
          schema: { type: string, enum: [jsonl, csv] }
      responses:
        "200":
          description: >-
            The manifest (no `format`), or the bulk file as an attachment
            (`format=jsonl` streams NDJSON, `format=csv` streams CSV). A
            gzipped file (see the manifest's `compression`) is served as
            `application/gzip`, never with `Content-Encoding: gzip` — the
            manifest's `sha256` covers the exact bytes you receive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoryPackage" }
            application/x-ndjson:
              schema: { type: string, format: binary }
            text/csv:
              schema: { type: string, format: binary }
            application/gzip:
              schema: { type: string, format: binary }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /fixtures:
    get:
      summary: Upcoming scheduled fixtures, earliest first (FREE)
      operationId: listFixtures
      parameters:
        - $ref: "#/components/parameters/tour"
        - $ref: "#/components/parameters/draw"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: >-
            Fixtures with start time and player ids where resolved — the
            nulls are real states, not gaps (names are always present)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Fixture" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /usage:
    get:
      summary: Your own usage vs quota (FREE — any tier)
      description: >-
        Durable daily usage for the calling key: tier, limits, today's calls
        (current to the second) and a 30-day history. The per-minute window is
        on the X-RateLimit-* headers of every response, not here. Calls to this
        endpoint are quota-exempt — checking your usage never consumes it.
      operationId: getUsage
      responses:
        "200":
          description: Usage summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /rankings:
    get:
      summary: Rankings and Elo — rank-ordered listing (PRO) or per-player as-of records (ULTRA); the as-of Elo tape is ULTRA in both modes
      description: >-
        Returns, per ranking system, the newest record effective ON OR BEFORE
        `as_of` — never one dated after it. Every other ranking field in this
        API is the player's CURRENT value joined at read time, so replaying an
        old match elsewhere shows today's ranks; this endpoint is the
        point-in-time answer. Systems are never collapsed into a single "rank":
        ATP/WTA and the ITF circuits carry rank+points, UTR carries a rating
        with null rank and points because it has neither.
        `meta.coverage.oldest_available` gives the earliest date each system
        can answer for — ITF and UTR observations reach back to 2026-06-01 (append-only per-player history accumulates from 2026-07-29) and nothing earlier can be reconstructed.


        TWO MODES — with `player` ids (ULTRA) — the per-player point-in-time
        records described above. WITHOUT `player` (PRO) — the FULL published
        table in rank order for exactly one `system`, the newest week at or
        before `as_of`; rows carry `player_name` as published and a null
        `player_id` for players outside our roster, so the table has no
        silent holes. `utr` has no listing (a rating, not a ranking).


        SYSTEM=UTR — observed ratings, honestly bounded. UTR records are
        observed from UTR's public search: a rating UTR withholds appears as
        ABSENT, never as 0, and `rating` is the only populated value —
        `rank` and `points` are always null. Per-player as-of ONLY — there
        is deliberately no UTR listing, because a table of only the players
        we happen to track would be a fake leaderboard. Per-player history accumulates from 2026-07-29; scattered earlier single-snapshot observations reach back to 2026-06-01. Coverage is a deliberate bias, not a roster
        mirror: the 24-hour sweep targets players with no official rank and
        no Elo rating (so it skews ITF), and among players active in the
        last 60 days it holds 931 of 5,606 ITF players (16.6%), 197 of
        1,903 Challenger (10.4%), 43 of 573 WTA (7.5%) and 15 of 525 ATP
        (2.9%) — measured 2026-08-17.


        SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own
        computed Elo for 65,622 players on four independent ladders (overall,
        hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the
        main tours plus challengers plus the futures tier. It answers what a
        player was rated BEFORE a given match, which is the only shape a
        backtest can consume. It is a HISTORICAL TAPE rather than a live
        leaderboard — the corpus behind it is fixed and no longer receives new results, so `meta.coverage.newest_available` states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as
        current. A week's results become effective 14 days after that week
        begins — strictly after the longest event in tennis — so the failure
        direction is staleness, never look-ahead. `rating` is the Elo.
        `rank` is LISTING MODE ONLY and is null in per-player mode, because
        an Elo has no global rank at a past instant until you say which
        field and which activity window you mean. `points` is always null.
        `matches` is the count on THAT ladder, published so that a rating
        still near its 1500 cold start is visible rather than inferred. A
        ladder a player has never played is omitted, never substituted.
        Ratings are on our own scale, are not comparable with Elo published
        elsewhere, and do not decay — a surface played only a few weeks a
        year (grass in particular) moves slowly, so a rising player can sit
        below an established one for several seasons while beating them. A
        CURRENT per-player Elo remains free on `GET /players/{id}`; this is
        the point-in-time series, the leaderboard and the bulk export. That
        free rating is sourced differently and sits on a DIFFERENT scale —
        the two differ by roughly 150 Elo of per-player standard deviation —
        so never present a rating from one scale against a rating from the
        other. The Elo listing REQUIRES `tour` — the ATP and WTA walks are
        disjoint, so a combined leaderboard would not be comparable — and
        takes exactly one `surface` (default `overall`). `elo` is never
        implicit — omitting `system` returns the official systems only.
      operationId: listRankings
      parameters:
        - name: player
          in: query
          required: false
          schema: { type: array, items: { type: integer }, maxItems: 50 }
          explode: true
          description: >-
            Player id — repeatable, max 50 (ULTRA per-player mode). OMIT it
            for the rank-ordered listing mode (PRO), which then requires
            exactly one `system`.
        - name: archive_player
          in: query
          required: false
          schema: { type: array, items: { type: integer }, maxItems: 50 }
          explode: true
          description: >-
            Historical-corpus person id — repeatable, max 50, `system=elo`
            only, and REQUIRES `tour` because that id is only unique within
            one tour. It is the same id `GET /history/archive/players`
            returns as its `id`. Use it to reach the ~62,000 rated people
            who have no row in our player roster; `player` reaches the rest.
        - name: tour
          in: query
          required: false
          schema: { type: string, enum: [atp, wta] }
          description: >-
            REQUIRED for an `elo` listing and for `archive_player`. The ATP
            and WTA Elo walks are disjoint universes whose players never
            meet, so a combined leaderboard would not be comparable.
        - name: surface
          in: query
          required: false
          schema:
            type: array
            items: { type: string, enum: [overall, hard, clay, grass] }
          explode: true
          description: >-
            `system=elo` only. Listing mode takes exactly one (default
            `overall`); per-player mode is repeatable and defaults to all
            four ladders. A ladder the player has never played is omitted,
            never substituted.
        - name: min_matches
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 20 }
          description: >-
            `system=elo` listing only. Minimum matches on that ladder. Every
            ladder starts at 1500, so without a floor the top of the table
            is players who won a handful of matches from the starting
            rating. Echoed back in `meta.coverage.qualified`.
        - name: activity_weeks
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 104, default: 52 }
          description: >-
            `system=elo` listing only. The ladder must have moved within
            this many weeks of `as_of`. Elo does not decay, so without an
            activity window every leaderboard is topped permanently by
            players who have stopped playing. Echoed back in
            `meta.coverage.qualified`.
        - name: as_of
          in: query
          required: false
          schema: { type: string, format: date }
          description: YYYY-MM-DD. Omit for the latest known record.
        - name: system
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
              enum: [atp, wta, itf_jt, itf_mt, itf_wt, utr, elo]
          explode: true
          description: >-
            Restrict to one or more systems. Omit for all of the official
            systems — `elo` is NEVER included implicitly and must be named,
            so an existing request's response is unchanged. Naming a system
            your plan does not cover refuses the whole call with 403 rather
            than silently returning the part you are entitled to.
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/offset" }
      responses:
        "200":
          description: Ranking records in force at `as_of`
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RankingRecord" }
                  meta: { $ref: "#/components/schemas/RankingListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /rally/matches:
    get:
      summary: Charted matches with shot-by-shot data (ULTRA)
      description: >-
        Charted matches, newest first. RALLY CONSTRUCTION IS THE LAYER BELOW
        THE TAPE: the tape says what the score became after each point, this
        says how the point was played.


        It has its OWN id space. The charted corpus and our own match table
        are different populations — the corpus reaches back decades and
        concentrates on the biggest events, while our matches begin when our
        own collection did. Keying this on our match ids would hide most of
        it. Ask this endpoint for the authoritative coverage list rather than
        assuming a match is charted: charting is human work, so coverage is
        deep, not universal.
      operationId: listRallyMatches
      parameters:
        - name: player
          in: query
          required: false
          schema: { type: string }
          description: Substring match on either player name.
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
          description: "`YYYY-MM-DD`."
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
          description: "`YYYY-MM-DD`."
        - name: surface
          in: query
          required: false
          schema: { type: string }
        - name: gender
          in: query
          required: false
          schema: { type: string, enum: [M, W] }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: Charted matches, with per-match parse-quality counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RallyMatch" }
                  meta: { $ref: "#/components/schemas/ListMeta" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /rally/matches/{rallyMatchId}:
    get:
      summary: Rally construction for one charted match (ULTRA)
      description: >-
        One charted match with its points, in play order. Paged with
        `limit`/`offset`; `meta.total` is the match's full point count.
      operationId: getRallyMatch
      parameters:
        - name: rallyMatchId
          in: path
          required: true
          schema: { type: integer }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: The charted match with its `rally` points
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/RallyMatch" }
                  - type: object
                    properties:
                      meta: { $ref: "#/components/schemas/ListMeta" }
                      rally:
                        type: array
                        items: { $ref: "#/components/schemas/RallyPoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /history/matches/{matchId}/rally:
    get:
      summary: Rally construction by OUR match id (ULTRA)
      description: >-
        Rally construction addressed by OUR match id, resolved through the
        optional link. Answers `404 {"error":"not_charted"}` when we hold the
        match but nobody charted it — deliberately distinct from "no such
        match", because most of our matches are not charted and a consumer
        walking the archive must tell them apart.
      operationId: getMatchRally
      parameters:
        - $ref: "#/components/parameters/matchId"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/offset"
      responses:
        "200":
          description: The charted match with its `rally` points
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/RallyMatch" }
                  - type: object
                    properties:
                      meta: { $ref: "#/components/schemas/ListMeta" }
                      rally:
                        type: array
                        items: { $ref: "#/components/schemas/RallyPoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /charting/players:
    get:
      summary: Career shot-level charting aggregate for one player (ULTRA)
      description: >-
        The deepest serve/return profile we hold, from the Match Charting
        Project: serve placement (deuce/ad × wide/body/T), return depth and
        outcomes, net and serve-and-volley conversion, clutch
        break/game/set-point serving and returning, winners and unforced
        errors by wing, and rally-length and shot-direction tendencies —
        summed over the player's charted matches. `name` (min 3 chars) is the
        key; a fragment matching more than one charted person is refused with
        the candidate list, and `gender=men|women` disambiguates. Every field
        is a raw SUM over the player's Total rows and `matches_charted` states
        the sample. COVERAGE IS CURATED — 11,646 charted matches across both
        tours back to the 1960s, concentrated on the majors, NOT full-slate
        coverage.
      operationId: getChartingPlayer
      parameters:
        - name: name
          in: query
          required: true
          schema: { type: string, minLength: 3 }
          description: Player name (fragment, min 3 chars).
        - name: gender
          in: query
          required: false
          schema: { type: string, enum: [men, women] }
          description: Disambiguates a fragment that matches one charted person per tour side.
      responses:
        "200":
          description: The player's summed charting families with the sample size
          content:
            application/json:
              schema:
                type: object
                properties:
                  player: { type: object }
                  matches_charted: { type: integer }
                  coverage: { type: string }
                  families:
                    type: object
                    description: Per-family summed numeric columns.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /charting/matches/{chartingMatchId}:
    get:
      summary: One charted match, every stat family for both players (ULTRA)
      description: >-
        Every Match Charting Project stat family for one charted match, both
        players, with the per-set split (row/set 1, 2, Total) exactly as
        charted. `chartingMatchId` is this product's own id space (1960–2026,
        mostly matches with no counterpart in the live table).
      operationId: getChartingMatch
      parameters:
        - name: chartingMatchId
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The charted match's stat families, both players, per set
          content:
            application/json:
              schema:
                type: object
                properties:
                  charting_match_id: { type: integer }
                  mcp_id: { type: string }
                  gender: { type: string }
                  players: { type: object }
                  families: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /webhooks:
    post:
      summary: Register an outbound webhook (ULTRA, direct keys only)
      description: >-
        We POST the same frames the WebSocket sends to your HTTPS endpoint on
        every live score commit. Up to 3 webhooks per key (`409 webhook_limit`
        past that). The response is the ONLY time the signing secret is shown —
        store it.


        Each delivery carries `X-LTAPI-Signature` (`sha256=<hex>` — HMAC-SHA256
        of the RAW request body with your webhook secret; verify with a
        constant-time compare), `X-LTAPI-Timestamp` (Unix seconds at send time
        — reject stale replays at your edge) and `X-LTAPI-Event` (the frame
        type: `score`, `break_point`, `break_point_result` or `point`).


        Delivery is best-effort, at-most-once, no replay: one attempt per frame
        with a ~3s timeout and redirects disabled. Every `score` frame is the
        complete current score, so a missed delivery self-corrects on the next
        commit. A `point` frame is an EVENT, not a state — a missed one does
        NOT self-correct; recover it with
        `GET /matches/{matchId}/points?after_seq=` and dedup by `seq`. After
        25 consecutive failures the webhook is disabled
        automatically (`enabled:false`, `last_error` set — visible in
        `GET /webhooks`); delete and re-register to resume.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  description: HTTPS only, publicly routable
                events:
                  type: array
                  items: { type: string, enum: [score, break_point, point] }
                  default: [score]
                  description: >-
                    `score` = every accepted live-score commit; `break_point`
                    = the derived `break_point` / `break_point_result`
                    frames; `point` = one POST per live point (the
                    `PointFrame` shape), where that surface is enabled and
                    the match has `pbp_coverage: "point"`.
      responses:
        "201":
          description: Created — includes `secret` (shown exactly once)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "409":
          description: Webhook limit reached (3 per key) — delete an existing webhook first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      summary: List your webhooks (ULTRA, direct keys only; never includes the secret)
      operationId: listWebhooks
      responses:
        "200":
          description: Your webhooks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
                  meta:
                    type: object
                    properties:
                      count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /webhooks/{webhookId}:
    delete:
      summary: Remove one of your webhooks (ULTRA, direct keys only)
      operationId: deleteWebhook
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /ws-token:
    get:
      summary: Mint a connection token for the high-fan-out push feed (ULTRA)
      description: >-
        Returns a short-lived signed token plus the push WebSocket URL and the
        channel vocabulary: `match:{match_id}` per-match streams and
        `slate:all` for every live score frame. Frames are the same allowlist
        score objects the polling endpoints return. This is a separate surface
        from the native `/ws` feed described above — same ULTRA gate, built
        for high fan-out (no shared connection ceiling), and the recommended
        home for continuous/production streaming.


        The endpoint speaks the **Centrifugo client protocol** (v2, JSON).
        Easiest path: the official Python (`livetennisapi` ≥ 1.4.0) and JS
        (≥ 1.5.0) SDKs ship a built-in `PushStream` client — no extra
        dependency. Raw protocol, if you prefer your own client: (1) open a
        WebSocket to `ws_url`; (2) send
        `{"connect": {"token": "<token>"}, "id": 1}` — the token goes INSIDE
        this JSON frame, never as a raw first message; (3) subscribe per
        channel with `{"subscribe": {"channel": "slate:all"}, "id": 2}`;
        (4) publications arrive as
        `{"push": {"channel": ..., "pub": {"data": <frame>}}}`; (5) the
        server's heartbeat is an empty JSON object `{}` — reply with `{}`
        promptly or you will be disconnected. Messages may batch several
        newline-delimited JSON objects. Tokens are short-lived and the
        connection closes around token expiry: mint a fresh token on EVERY
        reconnect and re-subscribe.


        The `channels` object lists only channels that will actually deliver
        for your key right now (a channel name in this response is a
        promise). Where enabled server-side, additional channel families
        appear: `point:match:{match_id}` / `point:slate` (per-point events),
        listed — as `point_match` / `point_slate` in the vocabulary — only
        for keys whose plan carries the point surface, and
        `signal:match:{match_id}` / `signal:slate` (derived `break_point`,
        `break_point_result` and `divergence` events). A family absent from
        the response will not deliver for your key right now.
        Deliberately separate channels: a `slate:all` subscriber asked
        for score states and never starts receiving events unasked. Point
        and signal frames are events, not states — a missed point does NOT
        self-correct on the next frame; recover it via
        `GET /matches/{matchId}/points?after_seq=` and dedup by `seq`.
      operationId: createWsToken
      responses:
        "200":
          description: Connection token, push URL and channel vocabulary
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expires_in: { type: integer }
                  ws_url:
                    type: string
                    description: The push WebSocket URL to connect to with the token.
                  channels:
                    type: object
                    description: >-
                      Channel vocabulary — `match` is the per-match pattern
                      (`match:{id}`), `slate` is the every-live-score channel
                      (`slate:all`). A channel listed here will actually
                      deliver for your key; one missing will not.
                    properties:
                      match: { type: string }
                      slate: { type: string }
                      point_match:
                        type: string
                        description: >-
                          Per-match point events (`point:match:{match_id}`).
                          Present only when the point feed is enabled
                          server-side and the key's plan carries the point
                          surface.
                      point_slate:
                        type: string
                        description: >-
                          Every point frame (`point:slate`). Present only
                          when the point feed is enabled server-side and the
                          key's plan carries the point surface.
                example:
                  token: "eyJhbGciOi..."
                  expires_in: 3600
                  ws_url: "wss://api.livetennisapi.com/connection/websocket"
                  channels:
                    match: "match:{match_id}"
                    slate: "slate:all"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Direct API key: Authorization: Bearer <key>"
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
  parameters:
    matchId:
      name: matchId
      description: |
        The match id. Every match route shares ONE id space, so the same value works everywhere `matchId` appears.

        Get it from any match list, where it is the `id` field of each match object:
        `GET /matches?status=live` (in progress), `GET /matches?status=upcoming` or `GET /fixtures` (scheduled), `GET /matches?status=completed` or `GET /history/matches` (finished).

        Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
      in: path
      required: true
      schema: { type: integer }
    limit:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
    offset:
      name: offset
      in: query
      schema: { type: integer, default: 0, minimum: 0 }
    playedFrom:
      name: from
      in: query
      required: false
      description: >-
        Earliest play date, `YYYY-MM-DD` or an ISO-8601 UTC datetime. A bare
        date covers that whole day. An unparseable value is a 400, never a
        silently unfiltered 200.
      schema: { type: string }
    playedTo:
      name: to
      in: query
      required: false
      description: >-
        Latest play date, same formats as `from` (a bare date includes
        everything played that day). `from` after `to` is a 400.
      schema: { type: string }
    tour:
      name: tour
      in: query
      description: >-
        Restrict results to one tour. Each value covers its singles and doubles
        draws, so `atp` includes ATP doubles and `juniors` covers the boys' and
        girls' Grand Slam draws. Omit for all tours. An unrecognised value is a
        400 rather than a silent pass-through, so a caller never receives a tour
        it did not ask for. Applied before pagination, so `meta.count` reflects
        the filtered set.
      schema:
        type: string
        enum: [atp, wta, challenger, itf, juniors]
    draw:
      name: draw
      in: query
      schema:
        type: string
        enum: [singles, doubles]
      description: >-
        Draw filter (added 2026-08-18) — the axis the tour filter deliberately
        collapses; the two compose (`?tour=itf&draw=doubles` is the ITF doubles
        slice). Same vocabulary as the `draw` field on Match, decided by the
        same shared definition, so filter and field cannot disagree. A row
        whose draw is null — a team tie, or no stated event type and no
        doubles-team participant — matches NEITHER value: null is an answer,
        not a wildcard. Two honesty notes: on /tournaments the answer comes
        from the event type alone (a tournament row has no participants to
        supply the doubles-team evidence matches have), and draw=doubles alone
        also returns mixed and exhibition doubles that no tour value reaches.
        An unknown value is a 400 bad_draw with the allowed values.
    player:
      name: player
      in: query
      required: false
      schema: { type: array, items: { type: integer }, maxItems: 50 }
      explode: true
      description: >-
        Filter to matches where this player id is EITHER participant.
        Repeatable (max 50 ids); multiple values return the deduplicated
        union. An unknown id returns an honest empty list, not an error; a
        non-integer value is a 400 `bad_request`. Before 2026-08-03 this
        parameter was accepted and silently ignored — treat any integration
        written against that behaviour as unfiltered.
    tournamentId:
      name: tournament_id
      in: query
      required: false
      schema:
        type: string
        pattern: '^[0-9]{1,20}$'
      description: >-
        Filter to one tournament — exact match on the stable numeric id every
        match row publishes as `tournament_id` and `/tournaments` publishes as
        `id`, so the value round-trips straight off any response. Works on
        `/matches` (every status) and `/history/matches`, and composes with
        every other filter: `?tour=atp&draw=singles&tournament_id=1217` is one
        slam's men's singles, where `?tour=` alone still mixes concurrent
        events during a slam fortnight. There is no separate edition or
        occurrence id: `tournament_id` is stable across seasons, so one
        EDITION is `tournament_id` plus a `from`/`to` window around its dates.
        Matches whose `tournament_id` is null (a tournament not yet in the
        catalogue) never match this filter; a date-window listing without it
        still returns them. Unknown-but-numeric ids return an empty list, not
        an error; a non-numeric value is a 400 `bad_tournament_id`.
    updatedSince:
      name: updated_since
      in: query
      required: false
      schema:
        type: string
      description: >-
        CHANGE FEED. A UTC date (YYYY-MM-DD) or ISO-8601 datetime. Returns the
        rows whose `updated_at` is at or after it, OLDEST FIRST, with
        `meta.next_cursor` and `meta.watermark`. Poll from `meta.watermark`,
        not from your own clock — it trails the server by 5s on purpose,
        because a row committed while your request was being served can carry
        a timestamp below your `now` and still not be in your result. Overlap
        ~60s and dedupe by `id`; the feed is at-least-once by design. CANNOT
        be combined with `from`/`to` (400 `bad_updated_since`): those filter
        when a match was PLAYED and sort by play time, this filters when its
        row CHANGED, and a change cursor cannot resume a play-time ordering.
        Not accepted with `status=cancelled` (400 `bad_request`). Anything
        unparseable is a 400, never accepted and ignored. A physically DELETED
        row can never appear in the feed; cancellations, walkovers and
        withdrawals are status CHANGES and do appear.
    cursor:
      name: cursor
      in: query
      required: false
      schema:
        type: string
      description: >-
        Echo back `meta.next_cursor` to fetch the next page of a change feed.
        Opaque — do not parse it, its contents will change. Supplies both the
        floor and the tie-break position, so it replaces `updated_since` on
        follow-up pages rather than accompanying it.
    country:
      name: country
      in: query
      required: false
      schema: { type: string, minLength: 3, maxLength: 3 }
      description: >-
        Filter to matches where EITHER participant's `player.country` equals
        this lowercase 3-letter code — the same vocabulary the Player object
        returns (IOC-style codes, e.g. `ned`, `sui`, `gre`; NOT ISO-3166).
        Players with no recorded country never match, so a country filter
        excludes unknown-nationality matches rather than guessing. A value
        that is not 3 letters is a 400 `bad_country`.
  responses:
    BadRequest:
      description: Bad query parameter
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, unknown, or disabled credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UpgradeRequired:
      description: Your tier doesn't unlock this endpoint
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource, or no data yet
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: >-
        Rate limit exceeded (Retry-After header present). Three body shapes,
        told apart by `error` and `scope`: the per-MINUTE limit
        (`rate_limited`, with `upgrade_url`, `tier` and `price` naming the
        next tier up); the per-DAY quota (`rate_limited` with `scope: "day"`,
        `limit_per_day`, and `resets_at` — the absolute ISO instant the daily
        window resets); and `abuse_throttled` with `retry_at_epoch` — a
        24-hour block applied to clients that keep hammering far past their
        cap, which a well-behaved retry loop never sees. Fix the loop rather
        than retrying through it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: >-
            Stable machine-readable code, e.g. bad_date, bad_coverage,
            bad_sequence, bad_points, bad_points_complete, bad_combination,
            points_read_disabled, bad_after_seq, points_disabled, bad_period,
            bad_year, bad_format, bad_tour, bad_country, ambiguous_name,
            not_charted, not_found, no_analysis, no_market, upgrade_required,
            rate_limited, abuse_throttled.
        detail:
          type: string
          description: Human-readable explanation, when one adds anything.
        allowed:
          type: array
          items: { type: string }
          description: >-
            On a rejected enumerated parameter, the values that would have been
            accepted (e.g. the coverage vocabulary for bad_coverage).
    ListMeta:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        count: { type: integer }
        total:
          type: [integer, "null"]
          description: >-
            Size of the whole filtered set. Null when it cannot be counted
            cheaply.
        has_more:
          type: boolean
          description: >-
            More results exist beyond this page. Read this rather than
            comparing count to limit. For a complete enumeration of a filtered
            set, page `offset` forward by `limit` until this is false; on
            `completed`/`cancelled` listings `total` is null and this is the
            only end-of-data signal.
        next_cursor:
          type: [string, "null"]
          description: >-
            Change-feed only (`?updated_since=` / `?cursor=`). Opaque token to
            pass back as `?cursor=`. Null means you are caught up — stop
            paging and switch to polling from `watermark`.
        watermark:
          type: [string, "null"]
          format: date-time
          description: >-
            Change-feed only. The floor to use on your NEXT poll. Deliberately
            trails the server clock by 5s so a row committed during this
            request cannot fall through the gap.
    Score:
      type: object
      description: ULTRA adds win_probability_p1 + danger.
      properties:
        sets:
          type: array
          items: { type: integer }
        games:
          type: array
          description: "[games_p1, games_p2]; each a per-set list"
          items:
            type: array
            items: { type: integer }
        points:
          type: array
          description: >-
            In-game points as tennis strings ("0", "15", "30", "40", "AD") —
            EXCEPT during a tiebreak, where they are the running tiebreak
            count as plain integer strings ("0", "1", "2", …); `is_tiebreak`
            tells the two notations apart. Entries can be NULL — observed
            live on completed matches, which also carry empty games arrays.
            Do not decode into non-nullable strings.
          items: { type: [string, "null"] }
        server: { type: [integer, "null"], enum: [1, 2, null] }
        is_tiebreak: { type: boolean }
        win_probability_p1: { type: [number, "null"] }
        danger: { type: [number, "null"] }
        timestamp: { type: [string, "null"], format: date-time }
    Player:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        tour:
          type: [string, "null"]
          description: >-
            The record's OWN tour, which is NOT the `tour` filter vocabulary.
            It is granular (`juniors_boys`, `juniors_girls`, `challenger_men`)
            where the filter is grouped (`juniors`, `challenger`), and a doubles
            team reports it UPPERCASE (`ATP`) where an individual reports
            lowercase (`atp`). Treat it as an opaque string; do not parse it into
            the filter enum.
        country: { type: [string, "null"] }
        ranking:
          type: [integer, "null"]
          description: >-
            Official singles ranking POSITION (the ordinal: 1, 25, 100) — never
            points, seed, race, UTR, Elo or a doubles rank. Men from the official
            ATP table, women from the WTA table, chosen by the player (a Challenger
            or ITF man who holds an ATP ranking carries it here). Refreshed from
            the standings ahead of each match the player has with us; a player who
            has not played recently keeps their last refreshed position. `null`
            when no ranking is held (doubles teams, most ITF/junior-only players).
            Always the CURRENT record, even on historical matches — use
            `/rankings?as_of=` for the rank in force on a date.
        ranking_points:
          type: [integer, "null"]
          description: Points on the same official table, refreshed with `ranking`.
        ranking_movement: { type: [string, "null"], enum: [up, down, same, null] }
        hand: { type: [string, "null"], enum: [R, L, null] }
        backhand: { type: [integer, "null"], enum: [1, 2, null] }
        birthday: { type: [string, "null"], format: date }
        is_doubles_team: { type: boolean }
        data_completeness:
          type: object
          description: >-
            How much biographical detail is known for this player, so a consumer
            can distinguish "not in the feed" from "not yet fetched" without
            probing. Present on every player in a match payload. Lower tours
            carry far less of it than main tour.
          properties:
            known:
              type: [integer, "null"]
              description: >-
                Fields populated, of `of`. NULL on a doubles team, where
                per-player biography does not apply — distinct from `0`, which
                means the fields apply and none are known. Do not decode this
                into a non-nullable integer.
            of:
              type: [integer, "null"]
              description: Fields considered. NULL on a doubles team, as for `known`.
            missing:
              type: array
              description: Names of the unpopulated fields, e.g. ["backhand", "hand"].
              items: { type: string }
            note:
              type: string
              description: >-
                Present only when the object is not applicable, explaining why
                (e.g. a doubles team).
        stats:
          type: object
          description: Single-player endpoint only
          properties:
            ratings: { type: [object, "null"] }
            season: { type: [array, "null"] }
    Match:
      type: object
      properties:
        id: { type: integer }
        tournament: { type: string }
        tour:
          type: [string, "null"]
          enum: [atp, wta, challenger, itf, juniors, null]
          description: >-
            The tour, in the SAME vocabulary the `tour` query filter accepts —
            both are derived from one registry, so a match selected by
            `?tour=X` always carries that value in `tour`. Null when the feed
            never stated a tour or the event type has no public tour name
            (exhibitions, team and mixed events) — never guessed. Safe to
            group and filter on; never parse the tournament name for this.
        tournament_id:
          type: [string, "null"]
          description: >-
            Stable tournament identity — one id per tournament × event type,
            stable across seasons. Joins `GET /tournaments/{tournamentId}`.
            Null when the tournament is not in the catalogue at all (UTR
            events), and on the small residue of matches discovered through a
            secondary source whose tournament name could not be matched to
            exactly one catalogue edition — never guessed. Measured over
            August 2026 terminal matches: ATP 1 of 531 and WTA 3 of 513 null;
            ITF about 2.5% after the 2026-09-04 backfill (was ~25%); UTR 100%.
            (Edge case — a match mislabelled as qualifying by the
            feed has its id re-pointed to the main-draw tournament when
            detected, so the id can change once, early, in that direction
            only.)
        surface: { type: [string, "null"], enum: [hard, clay, grass, null] }
        indoor: { type: boolean }
        format: { type: [string, "null"], enum: [BO3, BO5, null] }
        round: { type: [string, "null"] }
        round_code:
          type: [string, "null"]
          enum: [F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null]
          description: >-
            The round in the archive's controlled vocabulary, normalized from
            the free-text label above (`Q` = qualifying round the feed does
            not number). This is the field to branch on; it matches
            `/history/archive/matches?round=` exactly. Null when the label is
            unrecognised — never guessed.
        status:
          type: string
          enum: [upcoming, live, completed, cancelled]
          description: >-
            The settled lifecycle. `completed` is asserted only for a match
            we observed being played or whose match-winner market settled
            decisively (one side paid out) — a closed market alone never
            finishes a match (rule stated 2026-08-23; rows mis-published
            before it were corrected). `cancelled` with `event_status: null`
            means we hold positive evidence the match was not played as
            scheduled (the market settled void) but no vendor word for why —
            `outcome` and the fixture `reason` stay null rather than guessed,
            and the row upgrades to a completed walkover with `winner` /
            `withdrew` if a `Walk Over` with a stated winner lands later. The
            flip `upcoming → cancelled` runs through a gate (well past the
            scheduled time, no play recorded), never inline on a feed flap.
        event_status:
          type: [string, "null"]
          enum: [Retired, Cancelled, Walk Over, Postponed, Interrupted, null]
          description: >-
            How the match ended (or paused) when it did not run its course:
            retirement, cancellation, walkover, postponement, or an in-play
            suspension (`Interrupted` — rain/darkness/medical; the match is
            paused, not over). NULL means the match completed normally OR the
            outcome was never resolved — the feed does not distinguish those.
            Two honest caveats: the value is cleared if a suspended match
            resumes (no record of the pause survives), and for
            `Retired`/`Walk Over` the withdrawing player is reported in
            `withdrew`, where derivable.
        event_status_updated_at:
          type: [string, "null"]
          format: date-time
          description: >-
            When `event_status` last CHANGED, UTC (ISO-8601, `Z`) — added
            2026-08-19. The instant WE recorded the walkover / retirement /
            cancellation / postponement / suspension (or its clearing), not
            when the tournament desk or the feed did: this is the field to
            measure our admin-status latency with. Bumps only on a change of
            value (a re-read of the same status never moves it; a clear back
            to null does). Null while `event_status` has never changed since
            the field was introduced (2026-08-19) — never backfilled, never
            guessed.
        is_doubles:
          type: boolean
          description: >-
            Doubles match — kept for compatibility, and LOSSY. Evidence order:
            a doubles-team participant proves true regardless of the event
            type; otherwise the feed's event type decides. The loss: false
            also covers "unknown" — a match with no stated event type and no
            team participant reads false here, which is not a claim of
            singles. Prefer `draw`, whose null says so honestly.
        draw:
          type: [string, "null"]
          enum: [singles, doubles, null]
          description: >-
            The honest THREE-VALUED draw (added 2026-08-18) — same vocabulary
            as the `?draw=` filter, decided by the same shared definition, so
            filter and field cannot disagree. Evidence order as is_doubles: a
            doubles-team participant proves doubles over any event type;
            otherwise the feed's event type decides. Null means neither says
            anything — the feed stated no event type, or the match is part of
            a team tie (Davis Cup / BJK Cup / United Cup class), where one
            event type covers both singles and doubles rubbers and we will not
            guess which this is. Null is NOT singles.
        scheduled_time: { type: [string, "null"], format: date-time }
        players:
          type: object
          properties:
            p1: { $ref: "#/components/schemas/Player" }
            p2: { $ref: "#/components/schemas/Player" }
        score:
          oneOf:
            - { $ref: "#/components/schemas/Score" }
            - { type: "null" }
        winner:
          type: [integer, "null"]
          description: >-
            Completed matches only — derived from final sets. Served for the
            full archive age: a match older than the live-table window reads
            its final state from the same store the tape serves, so old
            completed matches carry a winner too.
        withdrew:
          type: [integer, "null"]
          description: >-
            Completed matches only — which player retired or conceded the
            walkover (1|2). Present only when `event_status` is
            `Retired`/`Walk Over` and the winner is derivable; the withdrawer
            is the loser by the rules of the sport. Absent means "not a
            withdrawal, or no evidence" — never a guess.
        has_analysis:
          type: boolean
          description: >-
            Whether a model thesis or profile exists for this match — on every
            list row and the detail, every tier (since 2026-09-02). Filter the
            slate on this before calling `/matches/{matchId}/analysis`, which
            answers `404 no_analysis` about the same fact.
        has_market:
          type: boolean
          description: >-
            Whether a match-winner market is mapped to this match (every tier,
            since 2026-09-02). Same role for `/markets/{matchId}/prices`
            (`404 no_market`).
    MatchDetail:
      allOf:
        - { $ref: "#/components/schemas/Match" }
        - type: object
          properties:
            analysis:
              $ref: "#/components/schemas/Analysis"
              description: ULTRA only (absent below)
            market:
              oneOf:
                - { $ref: "#/components/schemas/Market" }
                - { type: "null" }
              description: PRO+ only (absent below)
    Analysis:
      type: object
      properties:
        thesis:
          type: [object, "null"]
          properties:
            pick_side: { type: integer, enum: [1, 2] }
            confidence: { type: [number, "null"] }
            win_probability_pick: { type: [number, "null"] }
            state: { type: [string, "null"], enum: [valid, confirmed, weakened, broken, null] }
            reasoning: { type: [string, "null"] }
            notes:
              type: object
              properties:
                matchup: { type: [string, "null"] }
                environment: { type: [string, "null"] }
                fatigue: { type: [string, "null"] }
            scenario_playbook: { type: [array, "null"] }
            created_at: { type: [string, "null"], format: date-time }
        profile:
          type: [object, "null"]
          properties:
            win_probability_p1: { type: [number, "null"] }
            expected_closeness: { type: [number, "null"] }
            volatility_rating: { type: [string, "null"], enum: [low, med, high, null] }
            stage:
              type: [string, "null"]
              enum: [pregame, live, null]
              description: >-
                Did this forecast already see the match? null = UNKNOWN, never
                pregame.
            model_version:
              type: [string, "null"]
              description: >-
                The prompt-scaffold version this forecast was produced under,
                so a forecast can be evaluated against the framing that
                produced it and a scaffold change reads as a regime break
                rather than unexplained drift. Null on profiles written before
                the stamp existed.
            input_state:
              type: [object, "null"]
              description: >-
                For an in-play forecast (stage=live), the score it was actually
                looking at, so it can be evaluated against the position rather
                than only excluded. Null on a pregame profile (it saw no score)
                and null wherever the state could not be established. NOT
                reconstructed from the score at created_at — that timestamp is
                written after the model finishes, so it lands later than what
                the model saw.
              properties:
                capture:
                  type: string
                  enum: [writer, recovered]
                  description: >-
                    writer = stamped as the forecast was produced, from the
                    score row itself (carries live_score_id and stats).
                    recovered = reconstructed from the stored prompt text of
                    that same forecast; the score is exact but there is no
                    live_score_id and no stats.
                live_score_id: { type: [integer, "null"] }
                total_games: { type: [integer, "null"] }
                score:
                  type: [object, "null"]
                  description: >-
                    server is the serving SIDE (1 or 2); on doubles that is the
                    team, not the individual.
                  properties:
                    sets: { type: [array, "null"], items: { type: integer } }
                    games: { type: [array, "null"] }
                    points: { type: [array, "null"] }
                    server: { type: [integer, "null"] }
                    is_tiebreak: { type: [boolean, "null"] }
                    timestamp: { type: [string, "null"], format: date-time }
                stats: { type: [object, "null"] }
            key_factors: { type: [array, "null"], items: { type: string } }
            created_at: { type: [string, "null"], format: date-time }
    Market:
      type: object
      properties:
        id: { type: integer }
        question: { type: [string, "null"] }
        status: { type: [string, "null"], enum: [active, resolved, closed, null] }
        volume: { type: [number, "null"] }
        liquidity: { type: [number, "null"] }
        end_date: { type: [string, "null"], format: date-time }
        prices:
          type: array
          description: Prices endpoint / match detail only; newest first
          items: { $ref: "#/components/schemas/Price" }
    Price:
      type: object
      description: >-
        Prediction-market top-of-book quote (probability-like, [0,1]); reflects
        market trading, not an official line, and can lag live scores. Tagged
        so a synthesised quote is never mistaken for a live book.
      properties:
        side:
          type: [integer, "null"]
          description: 1 = p1's outcome, 2 = p2's
        bid: { type: [number, "null"] }
        ask: { type: [number, "null"] }
        mid: { type: [number, "null"] }
        spread: { type: [number, "null"] }
        price_source:
          type: [string, "null"]
          description: Feed category, e.g. prediction_market
        synthetic:
          type: [boolean, "null"]
          description: >-
            true = bid/ask estimated from mid (not a live order book);
            false = real top-of-book; null = unknown (older ticks)
        timestamp: { type: [string, "null"], format: date-time }
    Event:
      type: object
      properties:
        type: { type: string, enum: [break, set_won, game_won, momentum_run] }
        player: { type: [integer, "null"], enum: [1, 2, null] }
        timestamp: { type: [string, "null"], format: date-time }
    Fixture:
      type: object
      properties:
        id: { type: integer }
        event_date: { type: [string, "null"], format: date }
        start_time:
          type: [string, "null"]
          format: date-time
          description: >-
            Scheduled start (UTC). Null until the order of play assigns a
            time — a date-only fixture is a real state, and rows written
            before 2026-08-03 stay null.
        player1_id:
          type: [integer, "null"]
          description: >-
            Our player id, when the participant is in our roster (exact-key
            resolution, never a name match). Null otherwise — names are
            always present regardless.
        player2_id:
          type: [integer, "null"]
          description: As `player1_id`, for the second participant.
        tour:
          type: [string, "null"]
          description: >-
            The record's OWN tour, which is NOT the `tour` filter vocabulary.
            It is granular (`juniors_boys`, `juniors_girls`, `challenger_men`)
            where the filter is grouped (`juniors`, `challenger`), and a doubles
            team reports it UPPERCASE (`ATP`) where an individual reports
            lowercase (`atp`). Treat it as an opaque string; do not parse it into
            the filter enum.
        tournament: { type: [string, "null"] }
        round: { type: [string, "null"] }
        round_code:
          type: [string, "null"]
          enum: [F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null]
          description: >-
            Normalized round — same vocabulary as `Match.round_code`. Null
            when the label is unrecognised, never guessed.
        surface: { type: [string, "null"] }
        player1_name: { type: [string, "null"] }
        player2_name: { type: [string, "null"] }
        status: { type: [string, "null"] }
    ModelProfile:
      type: object
      description: >-
        One model win-probability profile. On the history tape these are the
        profiles produced during the match, oldest first.
      properties:
        win_probability_p1: { type: [number, "null"] }
        expected_closeness: { type: [number, "null"] }
        volatility_rating: { type: [string, "null"], enum: [low, med, high, null] }
        stage:
          type: [string, "null"]
          enum: [pregame, live, null]
          description: >-
            Did this forecast already see the match? null = UNKNOWN, never
            pregame.
        model_version:
          type: [string, "null"]
          description: >-
            The prompt-scaffold version this forecast was produced under. Null
            on profiles written before the stamp existed.
        key_factors: { type: [array, "null"], items: { type: string } }
        created_at: { type: [string, "null"], format: date-time }
    HistoryTapeRow:
      type: object
      description: >-
        One row of the score sequence. Rows we watched live carry a real
        `timestamp`. Rows expanded after the fact from a finished-match
        point-by-point record carry a null `timestamp` AND null model fields,
        because neither a wall clock nor a model output ever existed for them —
        nothing is synthesised. A null `timestamp` is the reliable row-level
        marker of a reconstructed row; the model fields alone are not, since
        they are stamped best-effort and an observed row may lack them.
        `meta.point_source` / `meta.coverage` say which kind of tape you are
        holding.
      properties:
        sets:
          type: array
          items: { type: integer }
        games:
          type: array
          items:
            type: array
            items: { type: integer }
        points:
          type: array
          description: >-
            In-game points as tennis strings ("0", "15", "30", "40", "AD") —
            EXCEPT during a tiebreak, where they are the running tiebreak
            count as plain integer strings ("0", "1", "2", …); `is_tiebreak`
            tells the two notations apart.
          items: { type: string }
        server:
          type: [integer, "null"]
          enum: [1, 2, null]
          description: >-
            Who is serving — null where the feed never asserted it. On
            `?sequence=clean` the server is SETTLED per non-tiebreak game
            (the server is physically constant within a game, so the game's
            majority assertion fills nulls and corrects transient
            disagreements; a tie settles nothing). The raw sequence carries
            every row exactly as committed, so a transient wrong-server read
            can appear there and be corrected by a later row.
        is_tiebreak: { type: boolean }
        win_probability_p1: { type: [number, "null"] }
        danger: { type: [number, "null"] }
        timestamp: { type: [string, "null"], format: date-time }
        point_winner:
          type: [integer, "null"]
          enum: [1, 2, null]
          description: >-
            Who won the point this row records — PRESENT ONLY on
            `?sequence=clean` and `?points=complete` rows, and only where the
            transition from the previous row of the served sequence is a
            single attributable point; null on gaps, torn rows and the first
            row. Never on the plain raw sequence (raw is deliberately
            non-monotonic: consecutive raw rows are corrections, not points —
            a `points=complete` read is the exception, because there the
            served order IS point order). Derived at read time, never stored
            or guessed.
    HistoryTape:
      type: object
      description: >-
        The full per-match history tape (BASIC, or Historical Data API
        Starter+). `tape` rows INCLUDE the model fields `win_probability_p1`
        and `danger` per point — on this product they are part of the tape
        itself, not an ULTRA upsell.
      properties:
        match:
          $ref: "#/components/schemas/Match"
          description: Match metadata with the final score embedded.
        tape:
          type: array
          description: >-
            The score sequence — see HistoryTapeRow. Array order is the
            authoritative order: commit order on `raw`, match-progress order
            on `?sequence=clean` (see the `sequence` parameter).
          items: { $ref: "#/components/schemas/HistoryTapeRow" }
        tiebreaks:
          type: [array, "null"]
          description: >-
            Per-set tiebreak final scores from OBSERVED states only, aligned
            to the sets of the final scoreline: `{"p1", "p2"}` for a 7-6 set
            whose observed maximum tiebreak state is a valid terminal shape
            (max >= 7, margin >= 2), null per set otherwise — a breaker whose
            closing point the feed skipped reads null rather than an
            under-report. Null when the match has no 7-6 set. Present on
            `raw` and `clean` alike.
          items:
            type: [object, "null"]
            properties:
              p1: { type: integer }
              p2: { type: integer }
        profiles:
          type: array
          description: Model profiles produced during the match, oldest first.
          items: { $ref: "#/components/schemas/ModelProfile" }
        meta:
          type: object
          properties:
            match_id: { type: integer }
            rows:
              type: integer
              description: Rows RETURNED — after any `sequence=clean` collapse.
            coverage: { $ref: "#/components/schemas/Coverage" }
            point_source:
              type: [string, "null"]
              enum: [observed, reconstructed, mixed, null]
              description: >-
                Where the rows came from — `observed` (every row watched live),
                `reconstructed` (every row expanded after the fact), `mixed` (a
                reconstructed opening followed by what we watched), or null on
                an empty tape. Reported once here and never per row, so the row
                shape cannot carry data-source provenance.
            raw_rows:
              type: integer
              description: >-
                Rows BEFORE any collapse — equals `rows` when `sequence=raw`.
            unique_states:
              type: integer
              description: >-
                Distinct score states in the raw tape. `raw_rows` minus this is
                pure repetition.
            sequence:
              type: string
              enum: [raw, clean]
              description: Echoes the requested `?sequence=`.
            from_archive:
              type: boolean
              description: >-
                True when the tape was served from the immutable archive
                rather than the live-score table. Content is identical.
            points:
              $ref: "#/components/schemas/PointsMeta"
            model_rows:
              type: integer
              description: >-
                How many of the RETURNED rows carry a model probability. The
                model stamp is best-effort in the live path, so no coverage
                value implies it — this is the count for THIS response,
                derived per read and never stored.
            generated_at: { type: string, format: date-time }
    PointsMeta:
      type: object
      description: >-
        Measured point-completeness of the sequence ACTUALLY RETURNED,
        computed at read time — never a stored blanket claim, and only ever a
        per-match measurement. Always present on `?points=complete` responses
        (with `available_complete`); on default reads it appears where
        enabled. Orthogonal to `coverage`: coverage says how the rows were
        OBTAINED, this says how COMPLETE the sequence is.
      properties:
        complete:
          type: boolean
          description: >-
            The contract bit: the sequence opens at 0-0, EVERY transition is a
            legal single-point step, it carries at least as many point
            transitions as the final scoreline implies the match contained,
            it reaches a finished legal final scoreline (or the match ended
            early — retirement/walkover), and it is not known-truncated.
        completeness:
          type: [number, "null"]
          description: >-
            Legal single-point transitions over a LOWER-BOUND expected point
            count derived from the final scoreline. 1.0 for any complete
            tape, low for a sampled one. Descriptive only — the denominator
            is a floor, not the true point count — and null when the final
            scoreline is unreadable.
        rows:
          type: integer
          description: Rows measured — always equals `meta.rows`.
        transitions_legal:
          type: integer
          description: >-
            Transitions attributable to exactly one side winning one point.
        transitions_total:
          type: integer
          description: All row-to-row transitions (rows − 1).
        server_rows:
          type: integer
          description: Rows carrying a server.
        starts_at_love:
          type: boolean
          description: The first row is the 0-0 opening state.
        ends_at_final:
          type: boolean
          description: The last row is a complete, legal final scoreline.
        basis:
          type: string
          enum: [raw, clean, recon]
          description: >-
            Which serve was measured: `raw` | `clean` (the default read, as
            returned) or `recon` (the whole reconstruction,
            `?points=complete`).
        available_complete:
          type: [boolean, "null"]
          description: >-
            `?points=complete` responses only. The nightly ledger's verdict
            on whether a point-complete reconstruction exists for this match:
            true/false when measured, null when not yet measured — never
            guessed.
    HistoryPackage:
      type: object
      description: >-
        Manifest of one pre-built bulk package (PRO, Historical Data API
        Pro+, or a one-off package pass). Tape coverage is not a contiguous
        run of months and is still being extended backwards, so treat the
        packages listing as the authoritative set of periods that exist. For
        `kind=tape` the JSONL file holds ONE LINE PER MATCH (a whole
        HistoryTape object per line, coverage meta included), not one line
        per point; the CSV is flattened to one row per point and carries no
        coverage columns. A month may also list the complete-basis ADDENDUM
        files (tennis_history_points_complete_<period>.jsonl.gz / .csv.gz,
        gzipped and marked by `compression`, added 2026-08-18). The two
        bases, plainly: the BASE files carry every match's DEFAULT read —
        already the complete tape for most point-complete matches — and the
        ADDENDUM carries, for exactly the matches whose complete point
        sequence exists only as the on-disk reconstruction, the same tape
        `?points=complete` serves (reconstruction contract: null timestamps
        and null model fields). Base files are never rewritten by the
        addendum; their sha256 values do not move.
        For `kind=rally` (the charted rally corpus, ULTRA)
        the JSONL holds one line per charted match with its full point list
        and the CSV one row per point, one file per YEAR. For `kind=archive`
        (the results archive, 1968–2022) the JSONL holds one line per archive
        result (the ArchiveMatch shape) and the CSV one row per result, one
        file per YEAR. For `kind=archive_tape` (the reconstructed 2013–2022
        point-by-point tape; ULTRA, History Pro/Business, or an active one-off
        package window) the JSONL record is byte-for-byte what
        `GET /history/archive/matches/{archiveId}/tape` returns — one whole
        ArchiveTape object per line, `meta` included — so a parser written
        against one match runs over a whole year unchanged; the CSV is the flat
        per-row view keyed on `archive_match_id` and deliberately carries NO
        `timestamp`, `win_probability_p1` or `danger` column, because a column
        empty in 100% of a file invites the belief that it is sometimes
        populated. Ten periods, `period` 2013 through 2022, each with a JSONL
        and a CSV file, all `ready`; no earlier year exists, because no tape
        earlier than 2013 exists.
        For `kind=elo` (the as-of Elo tape; ULTRA or History Pro/Business) the JSONL
        holds one rating record per line — the endpoint's field set plus
        `archive_player_id`, the stable person key a bulk join needs — and
        the CSV one row per record, one file per YEAR; the files are written
        in primary-key order, so a rebuild of the same data is byte-identical
        and the published `sha256` is a real checksum.
      properties:
        period:
          type: string
          description: >-
            Calendar month, `YYYY-MM` — or the bare year `YYYY` on the yearly
            `rally`/`archive`/`elo`/`archive_tape` kinds.
        status:
          type: string
          enum: [ready]
          description: Only built months are listed or served.
        match_count: { type: [integer, "null"] }
        row_count: { type: [integer, "null"] }
        files:
          type: array
          description: One entry per downloadable format.
          items:
            type: object
            properties:
              format: { type: string, enum: [jsonl, csv] }
              filename: { type: string }
              bytes: { type: integer }
              sha256: { type: string }
              compression:
                type: string
                enum: [gzip]
                description: >-
                  Present only on compressed files (the yearly kinds and the
                  points_complete addendum ship gzipped). `bytes` and `sha256`
                  cover the COMPRESSED bytes — exactly what you download.
        built_at: { type: [string, "null"], format: date-time }
        kind:
          type: string
          enum: [tape, rankings, rally, archive, elo, archive_tape]
          description: >-
            Present only on non-tape packages, so the shape a tape client
            already parses is unchanged. On a rankings package `match_count` is
            the number of players covered and `row_count` the number of ranking
            records; on a rally package the counts are charted matches and
            points; on an archive package the counts are archive results; on
            an `elo` package `row_count` is the number of rating records; on an
            `archive_tape` package `match_count` is the reconstructed tapes in
            that year and `row_count` their score rows (97,901 and 14,340,663
            across the ten years).
    Coverage:
      type: string
      enum: [from_start, partial, reconstructed, reconstructed_partial, none]
      description: >-
        How a tape came to exist, and specifically whether we WATCHED it.
        `from_start` — we watched this match live from 0-0; every row was
        committed by our own pipeline while the point was being played, so
        every row carries a real timestamp. It is a statement about how the
        rows were obtained, NOT a synonym for "complete".
        `partial` — we watched it, but recording began after play had started
        and no reconstruction is available to repair the opening.
        `reconstructed` — the tape opens with rows expanded after the fact from
        a finished-match point-by-point record: either the whole tape (we never
        watched the match) or its unwatched opening followed by what we did
        watch. True about the score, silent about the clock.
        `reconstructed_partial` — as `reconstructed`, AND the reconstruction is
        known not to cover the whole match. That happens two ways: it OPENS
        AFTER 0-0 (the upstream record begins mid-match), or it STOPS SHORT of
        the official final score (the record truncated, usually by the deciding
        game). Every row is still a point somebody recorded — nothing is
        synthesised to close either gap — but the match is NOT complete and
        must not be backtested as if it were.
        `none` — no rows; the match was on the calendar and was neither watched
        nor reconstructable.
        This vocabulary describes the 2023+ tape. The 2013–2022 archive tape
        (`ArchiveTape`) reuses it restricted to `reconstructed` and
        `reconstructed_partial`, and derives the second DIFFERENTLY: there a
        per-GAME tape carries that label however complete it is — see
        `ArchiveTape.meta.coverage` before reading it the same way.
    CoverageRollup:
      type: object
      description: >-
        The measured completeness rollup — what the archive HOLDS, per tour ×
        draw bucket, as of the artifact's own build clock.
      properties:
        as_of:
          type: string
          format: date-time
          description: >-
            Equal to `built_at`, verbatim — the artifact's own clock, and the
            date to quote with every number in this response.
        built_at:
          type: string
          format: date-time
          description: When the rollup was built (UTC).
        ledger_max_computed_at:
          type: [string, "null"]
          format: date-time
          description: >-
            The newest underlying per-match measurement in the completeness
            ledger.
        method:
          type: string
          description: >-
            The full measurement rule for point_complete, in one paragraph —
            every number carries its own definition.
        buckets:
          type: object
          description: >-
            One CoverageBucket per tour × draw bucket (`atp_singles` …
            `juniors_doubles`, plus `other`). A bucket with zero completed
            matches is OMITTED rather than emitted as zeros — read a missing
            key as "nothing to count", not an error.
          additionalProperties: { $ref: "#/components/schemas/CoverageBucket" }
        totals:
          $ref: "#/components/schemas/CoverageBucket"
    CoverageBucket:
      type: object
      description: The five verifiable numbers for one bucket.
      properties:
        completed:
          type: integer
          description: Completed matches in the bucket.
        any_tape:
          type: integer
          description: Matches with at least one tape row on any basis.
        point_complete:
          type: integer
          description: >-
            Measured point-complete on the BEST basis — what
            `?points=complete` can serve.
        complete_on_default_read:
          type: integer
          description: >-
            Point-complete on the default read alone. The difference against
            `point_complete` is the set of matches where `?points=complete` is
            load-bearing.
        share:
          type: [number, "null"]
          description: point_complete / completed. Null on an empty bucket.
    HistoryMatch:
      allOf:
        - { $ref: "#/components/schemas/Match" }
        - type: object
          properties:
            tape:
              type: object
              description: What point-by-point data we hold for this match.
              properties:
                coverage: { $ref: "#/components/schemas/Coverage" }
                rows:
                  type: integer
                  description: >-
                    Rows we OBSERVED (watched live). This is not the length of
                    the tape you will be served: a reconstructed or mixed tape
                    also includes reconstructed rows, and for a
                    partially-watched match only a prefix of the reconstruction
                    is spliced in front. Use `meta.rows` on the per-match tape
                    for the served length.
                reconstructed_rows:
                  type: integer
                  description: Reconstructed rows available for this match.
                model_rows:
                  type: integer
                  description: >-
                    How many of the OBSERVED rows carry a non-null
                    `win_probability_p1`. The model stamp is best-effort and
                    no coverage value — `from_start` included — implies it;
                    read this number to select tapes the model actually
                    scored.
                points_complete:
                  type: [boolean, "null"]
                  description: >-
                    Measured point-completeness, best basis: true when the
                    served tape OR an on-disk reconstruction of the match
                    measured point-complete (fetch the latter with
                    `?points=complete` on the per-match tape); null when the
                    match has not been measured yet — never a guess. Read
                    from a per-match ledger reconverged nightly. Present
                    where enabled.
                completeness:
                  type: [number, "null"]
                  description: >-
                    The better of the two measured completeness fractions
                    (0..1, lower-bound denominator — see the per-match tape's
                    `meta.points`). Null when not measured. Present where
                    enabled.
                starts_at_love:
                  type: [boolean, "null"]
                  description: >-
                    Whether a measured basis OPENS at the 0-0 state — the SAME
                    best-basis rule as points_complete: true when either basis
                    starts at love, so if any on-disk sequence opens at 0-0
                    you can obtain one that does. Null when the match has not
                    been measured. Present where enabled.
                computed_at:
                  type: [string, "null"]
                  format: date-time
                  description: >-
                    When the ledger last measured this match (UTC). Every
                    field in this block is a nightly-reconverged cache; this
                    is the as-of to quote with any of them. Null when the
                    match has not been measured. Present where enabled.
    Usage:
      type: object
      properties:
        principal: { type: string, description: "Opaque ref to your own key" }
        tier: { type: string, enum: [free, basic, pro, ultra] }
        base_tier:
          type: string
          description: >-
            Subscription tier; equals `tier` unless a temporary grant is active
        tier_expires_at:
          type: [string, "null"]
          format: date-time
          description: When a temporary tier grant reverts, else null
        channel: { type: string }
        limits:
          type: object
          properties:
            per_minute: { type: [integer, "null"] }
            per_day: { type: [integer, "null"] }
        today:
          type: object
          properties:
            calls: { type: integer }
            errors: { type: integer }
            remaining_day: { type: [integer, "null"] }
        history:
          type: array
          description: Last 30 days, oldest first
          items:
            type: object
            properties:
              day: { type: string, format: date }
              calls: { type: integer }
              errors: { type: integer }
        as_of: { type: string, format: date-time }
    MatchStatisticsSide:
      type: object
      description: >-
        One player's in-play statistics, in TWO families that are deliberately
        not merged. The fields at this level are DERIVED from the
        point-by-point record. `measured` holds counts taken upstream,
        including the ones no point record can yield: aces, double faults, the
        serve split, winners and unforced errors. Both families name some of
        the same quantities, computed two entirely different ways — that is a
        cross-check, not a duplication to be collapsed.
      properties:
        measured:
          $ref: "#/components/schemas/MatchStatisticsMeasured"
        service_games_played: { type: integer }
        service_games_won: { type: integer }
        hold_pct:
          type: [integer, "null"]
          description: >-
            null when no service game was played — never 0, so a present 0 is a
            real measured zero
        return_games_played: { type: integer }
        return_games_won: { type: integer }
        break_pct: { type: [integer, "null"] }
        break_points_faced: { type: integer }
        break_points_saved: { type: integer }
        break_points_saved_pct: { type: [integer, "null"] }
        break_points_played: { type: integer }
        break_points_converted: { type: integer }
        break_points_converted_pct: { type: [integer, "null"] }
        service_points_played: { type: integer }
        service_points_won: { type: integer }
        service_points_won_pct: { type: [integer, "null"] }
        return_points_played: { type: integer }
        return_points_won: { type: integer }
        return_points_won_pct: { type: [integer, "null"] }
        points_played: { type: integer }
        points_won: { type: integer }
    MatchStatisticsMeasured:
      type: object
      description: >-
        Measured counting statistics for one player. These are COUNTED
        upstream, not derived from the point record — which is why they can
        include aces and double faults, and the derived fields above cannot.
        They are MATCH TOTALS only — one whole-match rollup per player; there
        are no per-set measured statistics.

        EVERY FIELD IS OPTIONAL AND AN ABSENT FIELD IS OMITTED, never
        zero-filled — absent means NOT MEASURED, and a present 0 is a real
        measured zero. Do not assume a fixed shape; read the keys you are
        given. Measured over 24,552 stored payloads (2026-07-31), coverage
        falls into three tiers:

        TIER 1 — present on 98.6-100% of payloads, on every tour including
        ITF: aces, double faults, points won, service and return points won,
        break points won, service games won, longest point and game runs,
        return points.

        TIER 2 — present on essentially all ATP, WTA and Challenger matches and
        on NO ITF singles match: the first and second serve split, break points
        saved, tiebreaks won, games won, service games played.

        TIER 3 — the winners / unforced-errors / forced-errors family, in
        total and by shot type. Historically delivered on ~43% of ATP singles,
        ~24% of WTA singles and ~47% of tour doubles, and on NONE of
        Challenger, ITF or juniors — those zeros are hard, not sampling noise.
        The upstream feed has NOT DELIVERED this group at all since 2026-07-12
        (measured 2026-08-17: 0 of 4,513 August payloads carry it, live ATP
        Masters included), so expect it absent on current matches until the
        upstream feed resumes it.

        A `_of` suffix is the denominator of its base field and a `_pct` suffix
        is the percentage, recomputed from the two counts rather than read from
        the upstream rounding.
      properties:
        aces: { type: [integer, "null"] }
        break_points_won: { type: [integer, "null"] }
        double_faults: { type: [integer, "null"] }
        first_return_points_won: { type: [integer, "null"] }
        first_return_points_won_of: { type: [integer, "null"] }
        first_return_points_won_pct: { type: [integer, "null"] }
        max_games_in_row: { type: [integer, "null"] }
        max_points_in_row: { type: [integer, "null"] }
        points_won: { type: [integer, "null"] }
        return_points_won: { type: [integer, "null"] }
        second_return_points_won: { type: [integer, "null"] }
        second_return_points_won_of: { type: [integer, "null"] }
        second_return_points_won_pct: { type: [integer, "null"] }
        service_games_won: { type: [integer, "null"] }
        service_points_won: { type: [integer, "null"] }
        break_points_saved: { type: [integer, "null"] }
        break_points_saved_of: { type: [integer, "null"] }
        break_points_saved_pct: { type: [integer, "null"] }
        first_serve_points_won: { type: [integer, "null"] }
        first_serve_points_won_of: { type: [integer, "null"] }
        first_serve_points_won_pct: { type: [integer, "null"] }
        first_serves_in: { type: [integer, "null"] }
        first_serves_in_of: { type: [integer, "null"] }
        first_serves_in_pct: { type: [integer, "null"] }
        games_won: { type: [integer, "null"] }
        second_serve_points_won: { type: [integer, "null"] }
        second_serve_points_won_of: { type: [integer, "null"] }
        second_serve_points_won_pct: { type: [integer, "null"] }
        second_serves_in: { type: [integer, "null"] }
        second_serves_in_of: { type: [integer, "null"] }
        second_serves_in_pct: { type: [integer, "null"] }
        service_games_played: { type: [integer, "null"] }
        tiebreaks_won: { type: [integer, "null"] }
        backhand_errors:
          type: [integer, "null"]
          description: Forced errors off the backhand.
        backhand_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors off the backhand.
        backhand_winners:
          type: [integer, "null"]
          description: Winners off the backhand.
        drop_shot_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors on the drop shot.
        drop_shot_winners:
          type: [integer, "null"]
          description: Winners on the drop shot.
        errors_total:
          type: [integer, "null"]
          description: >-
            The total of FORCED errors, not of all errors. Verified over 3,766
            stored payload sides (June-July 2026): it equals the forehand +
            backhand + return + overhead error sum in 96.2% of sides, it is
            SMALLER than `unforced_errors_total` in 11.7% of sides (impossible
            for a superset), and per-match points accounting — points won =
            own winners + opponent forced errors + opponent unforced errors —
            closes with median residual 0 over 367 matches only under the
            forced reading. Total errors = `errors_total` +
            `unforced_errors_total`; there is no separate forced-errors field
            to wait for, because these fields ARE the forced counts.
        forehand_errors:
          type: [integer, "null"]
          description: Forced errors off the forehand.
        forehand_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors off the forehand.
        forehand_winners:
          type: [integer, "null"]
          description: Winners off the forehand.
        groundstroke_errors:
          type: [integer, "null"]
          description: >-
            `forehand_errors` + `backhand_errors` — a rollup of the two, not
            an additive extra.
        groundstroke_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors off the ground strokes.
        groundstroke_winners:
          type: [integer, "null"]
          description: Winners off the ground strokes.
        lob_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors on the lob.
        lob_winners:
          type: [integer, "null"]
          description: Winners on the lob.
        overhead_errors:
          type: [integer, "null"]
          description: Forced errors on the overhead — the upstream's
            overhead-stroke count.
        overhead_winners:
          type: [integer, "null"]
          description: Winners on the overhead.
        return_errors:
          type: [integer, "null"]
          description: Forced errors on the return.
        return_winners:
          type: [integer, "null"]
          description: Winners on the return.
        unforced_errors_total:
          type: [integer, "null"]
          description: The total of unforced errors. Total errors =
            `errors_total` + `unforced_errors_total`.
        volley_unforced_errors:
          type: [integer, "null"]
          description: Unforced errors on the volley.
        volley_winners:
          type: [integer, "null"]
          description: Winners on the volley.
        winners_total:
          type: [integer, "null"]
          description: Total winners.
    MatchStatisticsFreshness:
      type: object
      description: >-
        Per-family coverage and age. Branch on this rather than on the
        top-level `coverage`, which only summarises the response. The two ages
        use DIFFERENT clocks and must not be compared: `derived.age_seconds` is
        relative to the newest score row (between points there is no new score
        either, so wall-clock age would report staleness that does not exist),
        while `measured.age_seconds` is wall clock, because those are fetched
        on a fixed cadence.
      properties:
        measured_divergence:
          type: [object, "null"]
          description: >-
            Null when the families agree. Otherwise why the measured values
            were withheld, with both match states.
          properties:
            reason: { type: string }
            games_in_statistics: { type: integer }
            games_in_score: { type: integer }
            delta_games:
              type: integer
              description: >-
                positive = statistics ahead of the score, which staleness
                cannot cause
            detail: { type: string }
        derived: { $ref: "#/components/schemas/MatchStatisticsFamily" }
        measured: { $ref: "#/components/schemas/MatchStatisticsFamily" }
    MatchStatisticsFamily:
      type: object
      properties:
        coverage:
          type: string
          enum: [live, final, stale, none, diverged]
          description: >-
            `final` = the closing figures of a completed match. A finished
            match cannot be "stale", so on `final` the family's `age_seconds`
            is null; `as_of` still says when the last point/fetch was
            recorded.
        as_of: { type: [string, "null"] }
        age_seconds: { type: [integer, "null"] }
        describes:
          type: [object, "null"]
          description: >-
            The match state these statistics describe, per upstream.
            `age_seconds` says when we fetched; this says WHAT we fetched. Null
            when unavailable.
          properties:
            games_p1: { type: array, items: { type: integer } }
            games_p2: { type: array, items: { type: integer } }
            total_games: { type: integer }
    MatchStatistics:
      type: object
      properties:
        match_id: { type: integer }
        coverage: { type: string, enum: [live, final, stale, none, diverged] }
        as_of:
          type: [string, "null"]
          description: When the underlying record was last updated (UTC)
        age_seconds:
          type: [integer, "null"]
          description: Behind the newest SCORE row, not the wall clock
        games_counted: { type: integer }
        tiebreak_games_excluded:
          type: integer
          description: >-
            Tiebreaks are excluded — the live record collapses a whole tiebreak
            onto one entry
        inconsistent_games_excluded:
          type: integer
          description: >-
            Games whose recorded outcome is neither a legal hold nor a legal
            break
        sets_covered: { type: array, items: { type: integer } }
        freshness: { $ref: "#/components/schemas/MatchStatisticsFreshness" }
        detail:
          type: string
          description: Present only when coverage is none
        players:
          type: [object, "null"]
          properties:
            p1: { $ref: "#/components/schemas/MatchStatisticsSide" }
            p2: { $ref: "#/components/schemas/MatchStatisticsSide" }
    RankingRecord:
      type: object
      description: >-
        One ranking record in force at the requested instant. `system` is
        always explicit and the systems are never collapsed into a single
        "rank" — they are not comparable. ATP/WTA and the ITF circuits populate
        rank+points; UTR populates rating and leaves rank/points null, because
        UTR is a rating and has no rank.
      properties:
        player_id:
          type: [integer, "null"]
          description: >-
            Null only on listing rows for players outside our roster — the
            listing is kept independent of the roster precisely so a top-N
            has no silent holes. Always present on per-player records for
            the official systems and UTR. For `elo` it is also null on rows
            for the ~62,000 rated people with no roster row — normal, not an
            error; `player_name` is always populated, and `archive_player`
            fetches that person directly.
        player_name:
          type: [string, "null"]
          description: >-
            The name as the ranking publisher printed it — present on listing
            rows (where `player_id` may be null), absent on per-player
            records.
        system:
          type: string
          enum: [atp, wta, itf_jt, itf_mt, itf_wt, utr, elo]
        tour: { type: [string, "null"] }
        rank:
          type: [integer, "null"]
          description: >-
            Null for UTR. Null for `elo` in per-player mode too — an Elo has
            no global rank at a past instant until you say which field and
            which activity window you mean, and the per-player read is given
            neither. In `elo` listing mode it is the position in the
            qualified field of THAT call.
        points:
          type: [integer, "null"]
          description: >-
            Null for UTR and for `elo` — both are ratings, and neither has
            points.
        surface:
          type: string
          enum: [overall, hard, clay, grass]
          description: >-
            `elo` only, and absent on every other system. Which of the four
            independent ladders this rating is. A ladder the player has never
            played is omitted from the response rather than substituted with
            another.
        matches:
          type: [integer, "null"]
          description: >-
            `elo` only, and absent on every other system. Cumulative matches
            on THAT ladder at that date — not the player's career total.
            Every ladder starts at 1500, so this is what tells you whether a
            rating is an assessment or a cold start, and it is the number
            `min_matches` filters on.
        previous_rank:
          type: [integer, "null"]
          description: >-
            The rank at the immediately preceding snapshot week (ATP/WTA
            only; null when no prior week is held, and always null for
            ITF/UTR).
        rank_movement:
          type: [integer, "null"]
          description: >-
            The circuit's own signed weekly movement (ITF systems only; null
            elsewhere).
        rating:
          type: [number, "null"]
          description: >-
            UTR and `elo` only; null elsewhere. For `elo` this is the Elo
            itself, on our own scale, which is not comparable with Elo
            published anywhere else.
        effective_date:
          type: [string, "null"]
          format: date
          description: >-
            The publication week this record took effect. For records ingested
            live rather than from the official weekly publication this is
            bucketed to the observed week, so it can sit up to six days later
            than the moment the value actually took effect; where both exist
            for one week the official publication is served.
        observed_at: { type: [string, "null"], format: date-time }
    RankingListMeta:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            coverage:
              type: object
              description: >-
                What resolved against what was asked. Read it before trusting
                an empty result — ITF and UTR observations reach back to 2026-06-01 (append-only per-player history accumulates from 2026-07-29); nothing earlier can be reconstructed, so a request before `oldest_available` correctly returns nothing for those systems.
              properties:
                as_of: { type: [string, "null"], format: date }
                effective_date:
                  type: [string, "null"]
                  format: date
                  description: >-
                    Listing mode. For the official systems, the published
                    week the table is from. For `elo`, the as-of instant the
                    leaderboard was resolved at (each row carries its own
                    `effective_date`, since a sparse tape moves per player).
                players_requested:
                  type: integer
                  description: Per-player mode only.
                players_resolved:
                  type: integer
                  description: Per-player mode only.
                systems_requested: { type: array, items: { type: string } }
                systems_resolved: { type: array, items: { type: string } }
                oldest_available:
                  type: object
                  additionalProperties: { type: [string, "null"], format: date }
                  description: >-
                    Earliest effective date held, per requested system.
                newest_available:
                  type: object
                  additionalProperties: { type: [string, "null"], format: date }
                  description: >-
                    Present only when `elo` was requested. The NEWEST
                    effective date the Elo tape holds. The corpus behind it
                    is fixed and no longer receives new results, so this
                    date does not advance and will keep receding from
                    today — that is a property of a historical tape, not a
                    fault. Read it before treating an Elo leaderboard as a
                    current one.
                tour:
                  type: [string, "null"]
                  description: >-
                    Present only when `elo` was requested — which walk
                    answered.
                surface:
                  description: >-
                    Present only when `elo` was requested. The ladder
                    (listing) or the list of ladders (per-player) the
                    response covers.
                players_rated:
                  type: integer
                  description: >-
                    Present only when `elo` was requested. People with at
                    least one rating in the tape.
                players_linked:
                  type: integer
                  description: >-
                    Present only when `elo` was requested. How many of those
                    have a row in our player roster, and therefore can be
                    reached with `player`. The rest are reachable with
                    `archive_player` and appear on the leaderboard with a
                    null `player_id` and their name. The gap between these
                    two numbers is large and is reported rather than buried.
                qualified:
                  type: object
                  description: >-
                    Present only on an `elo` listing. The two filters that
                    DEFINE the field this table ranks. A top-N is not
                    quotable without them — change either and it is a
                    different top-N, and neither is more correct than the
                    other.
                  properties:
                    activity_weeks: { type: integer }
                    min_matches: { type: integer }
                model:
                  type: object
                  description: >-
                    Present only when `elo` was requested. What the numbers
                    ARE.
                  properties:
                    name: { type: string }
                    start_rating: { type: number }
                    k_base: { type: number }
                    publication_lag_days:
                      type: integer
                      description: >-
                        A result becomes effective this many days after its
                        tournament WEEK begins. The corpus dates a match by
                        its tournament start, so without the lag a Slam
                        final would be visible under the Monday the Slam
                        began. The lag clears the longest event in tennis,
                        so the failure direction is staleness and never
                        look-ahead.
                    built_at: { type: [string, "null"], format: date-time }
                    note: { type: string }
    Webhook:
      type: object
      properties:
        id: { type: integer }
        url: { type: string }
        events:
          type: array
          items: { type: string, enum: [score, break_point] }
        enabled:
          type: boolean
          description: >-
            Set to false automatically after 25 consecutive delivery failures —
            delete and re-register to resume.
        created_at: { type: [string, "null"], format: date-time }
        last_delivery_at: { type: [string, "null"], format: date-time }
        consecutive_failures: { type: integer }
        last_error: { type: [string, "null"] }
        secret:
          type: string
          description: >-
            Present ONLY on the 201 registration response — shown exactly once
        secret_note: { type: string }
    BreakPoint:
      type: object
      description: >-
        WebSocket `break_point` frame (ULTRA). Pushed the instant a break point
        arises, but only when the subscription named `break_point` in its
        `signals`. Fields sit inline (no nested score object); `prob_swing`
        mirrors the REST score's `danger`. `server` is the player serving,
        `returner` the one holding the break point(s).
      properties:
        type: { type: string, const: break_point }
        match_id: { type: integer }
        server: { type: [integer, "null"], enum: [1, 2, null] }
        returner: { type: [integer, "null"], enum: [1, 2, null] }
        break_points:
          type: [integer, "null"]
          description: How many break points are live at once (1-3).
        set: { type: [integer, "null"] }
        game: { type: [integer, "null"] }
        point: { type: [string, "null"] }
        win_probability_p1: { type: [number, "null"] }
        prob_swing: { type: [number, "null"] }
        server_side_favoured: { type: [boolean, "null"] }
        ts: { type: [string, "null"], format: date-time }
    BreakPointResult:
      type: object
      description: >-
        WebSocket `break_point_result` frame (ULTRA): a break point just
        resolved. `outcome` is `held` (server saved it) or `broken` (returner
        converted); `win_probability_p1_after` is p1's win probability once the
        game closed.
      properties:
        type: { type: string, const: break_point_result }
        match_id: { type: integer }
        server: { type: [integer, "null"], enum: [1, 2, null] }
        outcome: { type: string, enum: [held, broken] }
        win_probability_p1_after: { type: [number, "null"] }
        ts: { type: [string, "null"], format: date-time }
    LivePoint:
      type: object
      description: >-
        One PLAYED point of a live match — the expanded score state AFTER the
        point. `seq` is per-match, monotonic and never skips a value: the
        ordering, dedup and resume key across the WS frames, the webhook
        deliveries and the REST catch-up read. `ts` is CAPTURE time (when our
        pipeline committed the state), never a per-point clock — no feed
        asserts one and we fabricate none.
      properties:
        seq:
          type: integer
          description: >-
            Per-match monotonic position 1..N — dedup and resume on this.
        set: { type: [integer, "null"] }
        game:
          type: [integer, "null"]
          description: Game within the current set — 13 for a 6-6 tiebreak.
        number:
          type: [integer, "null"]
          description: >-
            Points already recorded in this game at this state — 0 for a
            game's opening state.
        tiebreak: { type: boolean }
        server:
          type: [integer, "null"]
          enum: [1, 2, null]
          description: >-
            Server of the point played FROM this state; null when the feed
            omits it.
        winner:
          type: [integer, "null"]
          enum: [1, 2, null]
          description: >-
            Who won the point that produced this state — null when not
            attributable to a single point, never guessed.
        score:
          type: object
          description: >-
            In-game score AFTER the point ('0'/'15'/'30'/'40'/'AD'; the
            running tiebreak count as integer strings when `tiebreak` is
            true).
          properties:
            p1: { type: [string, "null"] }
            p2: { type: [string, "null"] }
        sets:
          type: array
          items: { type: [integer, "null"] }
        games:
          type: array
          description: >-
            `[games_p1, games_p2]`; each a per-set list, frozen at the
            pre-breaker score during a tiebreak.
          items:
            type: array
            items: { type: integer }
        ts:
          type: [string, "null"]
          format: date-time
          description: >-
            Capture time (UTC) — when our pipeline committed this state. Null
            on every frame of a `reconstruction`-basis page — the recorded
            sequence carries no per-point clock and none is fabricated.
    MatchPoints:
      type: object
      description: >-
        One page of a match's per-point stream, seq order. `basis` says which
        base served it: the persisted live stream rows, or — for a completed
        match where a measured-complete recorded sequence exists — that
        complete sequence projected into point frames.
      properties:
        match_id: { type: integer }
        pbp_coverage:
          type: string
          enum: [point, game]
          description: >-
            `point` = this match has a true per-point stream; `game` = only
            the snapshot score path covers it (`points` is empty — an answer,
            not an error).
        quality:
          type: string
          enum: [clean, revised]
          description: >-
            `revised` = the upstream feed rewrote an already-served prefix at
            least once during this match; served rows are never edited.
        covers_from_start:
          type: [boolean, "null"]
          description: >-
            Whether the persisted stream OPENS at the match's 0-0 opener —
            seq 1 exists and is the love-love state — i.e. whether replaying
            from `after_seq=0` yields the whole match or joins it mid-play.
            Null when the match has no rows at all (nothing to judge — null
            means not measured, never "no").
        points:
          type: array
          items: { $ref: "#/components/schemas/LivePoint" }
        last_seq:
          type: integer
          description: The resume cursor — pass as `after_seq` to continue.
        has_more: { type: boolean }
        basis:
          type: string
          enum: [live, reconstruction]
          description: >-
            Which base served this page. `live` = the persisted live stream
            rows (every live match, and any completed match without a
            measured-complete recorded sequence); `reconstruction` = the
            complete recorded point sequence of a finished match, projected
            into point frames at read time — includes the match-closing point,
            `seq` contiguous 1..N, `ts` null on every frame. Completeness
            beats the partial live capture wholesale; the two bases are never
            interleaved.
    PointFrame:
      type: object
      description: >-
        WebSocket `point` frame (ULTRA, opt-in via
        `signals: ["points"]`; also the webhook `point` event body): one
        PLAYED point of a subscribed match. Ordered per match by `seq`;
        dedup/resume on `seq`. Best-effort, NO replay — catch up via
        `GET /matches/{matchId}/points?after_seq=`.
      properties:
        type: { type: string, const: point }
        match_id: { type: integer }
        point: { $ref: "#/components/schemas/LivePoint" }
        pbp_coverage: { type: string, enum: [point, game] }
        quality: { type: string, enum: [clean, revised] }
    Tournament:
      type: object
      description: >-
        One tournament of the catalogue — the stable id space
        `Match.tournament_id` joins, one row per tournament × event type,
        stable across seasons.
      properties:
        id:
          type: string
          description: The stable id `Match.tournament_id` joins.
        name: { type: [string, "null"] }
        tour:
          type: [string, "null"]
          enum: [atp, wta, challenger, itf, juniors, null]
        surface: { type: [string, "null"], enum: [hard, clay, grass, null] }
        indoor: { type: boolean }
        city:
          type: [string, "null"]
          description: Host city, from a curated table — null where not curated.
        country:
          type: [string, "null"]
          description: >-
            Host country, ISO-3166 alpha-2 — null where not curated. (NOTE
            this differs from `player.country` and the `?country=` filter,
            which use IOC-style lowercase 3-letter codes.)
        category:
          type: [string, "null"]
          enum: [grand_slam, masters_1000, tour_finals, atp_500, atp_250,
                 wta_1000, wta_500, wta_250, wta_125, challenger, itf,
                 juniors, null]
          description: >-
            Tournament category where our catalogues agree unambiguously on an
            exact-name join — null otherwise, never derived from the name.
    ArchiveMatch:
      type: object
      description: >-
        One result of the results archive (1968–2022). Winner/loser-shaped —
        results data is recorded that way at the source, so the winner is a
        field, never an inference. Its own id space; `source_id` is the
        stable corpus key. A result played 2013–2022 may additionally carry a
        reconstructed point-by-point tape (`ArchiveTape`); results from
        1968–2012 have none and never will.
      properties:
        id: { type: integer }
        source_id: { type: string }
        tour: { type: string, enum: [atp, wta] }
        level:
          type: [string, "null"]
          description: >-
            Source tier code (G/M/A/F/D/C/O, or a futures category code as
            published).
        tournament: { type: [string, "null"] }
        surface: { type: [string, "null"] }
        draw_size: { type: [integer, "null"] }
        event_date:
          type: [string, "null"]
          format: date
          description: >-
            Tournament START date — per-match dates do not exist in this
            era's records, and we do not invent them.
        round: { type: [string, "null"] }
        best_of: { type: [integer, "null"] }
        minutes: { type: [integer, "null"] }
        winner: { $ref: "#/components/schemas/ArchivePlayer" }
        loser: { $ref: "#/components/schemas/ArchivePlayer" }
        score:
          type: [string, "null"]
          description: >-
            The final score as published, e.g. "6-4 7-6(5)", "6-3 RET",
            "W/O".
        outcome:
          type: [string, "null"]
          enum: [completed, retired, walkover, default, abandoned, null]
          description: >-
            Parsed from the score's own vocabulary; null when unparseable —
            never guessed.
        stats:
          type: [object, "null"]
          description: >-
            Detail endpoint only. {"winner":{...}, "loser":{...}} with aces,
            double_faults, serve_points, first_in, first_won, second_won,
            serve_games, bp_saved, bp_faced where the source recorded them;
            null otherwise (most rows before 1991) — never synthesised.
    ArchivePlayer:
      type: object
      description: One participant of an archive result, as the corpus records them.
      properties:
        name: { type: [string, "null"] }
        hand: { type: [string, "null"] }
        country:
          type: [string, "null"]
          description: 3-letter code, same vocabulary as `player.country`.
        rank:
          type: [integer, "null"]
          description: The player's rank AT THE TIME of the match, as published.
        seed: { type: [integer, "null"] }
        player_id:
          type: [integer, "null"]
          description: >-
            The corpus person id — joins `/history/archive/players` within
            the same tour. NOT a roster player id.
        height_cm: { type: [integer, "null"] }
        age:
          type: [number, "null"]
          description: Age at the time of the match, as the corpus records it.
        entry:
          type: [string, "null"]
          description: >-
            Draw entry where recorded (WC, Q, LL, PR, SE, ...) — null for
            direct acceptances.
    ArchivePlayerBio:
      type: object
      description: >-
        One archive person — own id space (the corpus person id that archive
        match rows carry as `winner.player_id` / `loser.player_id`, scoped
        per tour), never a roster id. Career-high is computed offline from
        the corpus's own weekly ranking tables at ingest, never modelled.
        Null fields are the era's silence.
      properties:
        id: { type: integer }
        tour: { type: string, enum: [atp, wta] }
        name: { type: [string, "null"] }
        hand: { type: [string, "null"] }
        dob: { type: [string, "null"], format: date }
        country: { type: [string, "null"] }
        height_cm: { type: [integer, "null"] }
        career_high_rank: { type: [integer, "null"] }
        career_high_date:
          type: [string, "null"]
          format: date
          description: The earliest week the career-high rank was reached.
    ArchiveCareer:
      type: object
      description: >-
        Career aggregates over the results archive (1968–2022) — sums and
        ratios of sums only, nothing modelled. Serve stats exist in the
        corpus from 1991; `serve.matches_with_stats` states the coverage.
      properties:
        player:
          type: object
          properties:
            name: { type: string }
        span:
          type: object
          properties:
            first: { type: [string, "null"] }
            last: { type: [string, "null"] }
        record:
          type: object
          properties:
            wins: { type: integer }
            losses: { type: integer }
            titles:
              type: integer
              description: Finals won (excluding abandoned finals).
            by_surface:
              type: object
              additionalProperties:
                type: object
                properties:
                  wins: { type: integer }
                  losses: { type: integer }
            by_level:
              type: object
              additionalProperties:
                type: object
                properties:
                  wins: { type: integer }
                  losses: { type: integer }
        by_year:
          type: array
          items:
            type: object
            properties:
              year: { type: integer }
              wins: { type: integer }
              losses: { type: integer }
        serve:
          type: object
          description: >-
            Summed serve statistics + derived ratios; null ratios where the
            denominator is zero.
          properties:
            matches_with_stats: { type: integer }
            aces: { type: integer }
            double_faults: { type: integer }
            serve_points: { type: integer }
            first_in: { type: integer }
            first_won: { type: integer }
            second_won: { type: integer }
            serve_games: { type: integer }
            bp_saved: { type: integer }
            bp_faced: { type: integer }
            first_in_pct: { type: [number, "null"] }
            first_won_pct: { type: [number, "null"] }
            second_won_pct: { type: [number, "null"] }
            bp_saved_pct: { type: [number, "null"] }
            aces_per_match: { type: [number, "null"] }
    ArchiveTape:
      type: object
      description: >-
        The reconstructed 2013–2022 point-by-point tape for one archive result
        (ULTRA, or any active History plan). Same envelope as HistoryTape so
        one parser reads both halves of the tape product, with the differences
        that are TRUE: `match` is the archive result row (winner/loser-shaped,
        so the rows are winner-first rather than p1/p2), `profiles` is always
        empty, `meta` has no `match_id` because an archive id is not a match
        id, and every row carries a null `timestamp` and null model fields
        because nobody watched these matches and no model ever scored them.
      properties:
        match:
          $ref: "#/components/schemas/ArchiveMatch"
          description: The archive result the tape was proved to belong to.
        tape:
          type: array
          description: >-
            The score sequence in `seq` order, WINNER-FIRST — `sets[0]` /
            `games[0]` / `points[0]` are the winner's, and `server: 1` means
            the winner was serving. Row shape is HistoryTapeRow, with
            `timestamp`, `win_probability_p1` and `danger` null throughout.
            `point_winner` is present on every row and is null throughout a
            per-GAME tape, where consecutive rows differ by a whole game and
            no single point is attributable.
          items: { $ref: "#/components/schemas/HistoryTapeRow" }
        tiebreaks:
          type: [array, "null"]
          description: >-
            Per-set tiebreak final scores, same rule and shape as on
            HistoryTape. Usually PRESENT even on a per-game tape — the
            2013–2022 public record expands points inside tiebreaks even where
            it does not elsewhere.
          items:
            type: [object, "null"]
            properties:
              p1: { type: integer }
              p2: { type: integer }
        profiles:
          type: array
          description: >-
            Always empty. The key is kept so the envelope matches HistoryTape;
            no model analysis exists, or could exist, for a 2013–2022 match.
          items: { type: object }
        meta:
          type: object
          properties:
            archive_match_id:
              type: integer
              description: >-
                The `archive_matches.id` this tape belongs to. Named in full
                because it is NOT a match id — passing it to a `/matches/…`
                route resolves a different, real record without erroring.
            rows:
              type: integer
              description: >-
                Rows returned. Read it next to `granularity` before comparing
                with a 2023+ tape's row count: across the corpus a tape
                averages 146 rows (median 138, range 4–570), and a per-game
                tape is far shorter than a point tape of the same match.
            coverage:
              type: string
              enum: [reconstructed, reconstructed_partial]
              description: >-
                The Coverage vocabulary restricted to the two values that can
                be true here — `from_start` and `partial` are structurally
                unreachable, because both assert we watched the match.
                `reconstructed` (94,307 matches, 96.3% of the corpus) = the
                tape reproduces the published final score exactly, and that
                score is a match that finished. `reconstructed_partial` (3,594,
                3.7%) HAS TWO CAUSES and the label does not say which: measured
                over the whole table, 3,038 are point-granular tapes of matches
                that genuinely stopped early (3,027 retirements, 11 defaults) —
                the larger cause — and the remaining 556 carry the label only
                because their tape is per-GAME (542 of complete matches, 14
                that are both at once). So it does NOT on its own mean
                incomplete match coverage: read `granularity` and the match's
                own score/outcome for that. On the 2023+ tape the same word
                means only short-at-either-end.
            granularity:
              type: string
              enum: [point, game]
              description: >-
                What one row resolves to. `point` = the record advances within
                a game — 99.4% of the corpus (97,293 matches). `game` = one row
                per game, points expanded only inside tiebreaks — 556 matches,
                555 of them in 2013 and one in 2014; every season from 2015 on
                is 100% point-granular. A per-game tape is a true record of the
                match and is published as one, never under a word that reads as
                per-point. Orthogonal to `coverage` as an axis, though a `game`
                tape is always labelled `reconstructed_partial`.
            point_source:
              type: string
              enum: [reconstructed]
              description: >-
                Always `reconstructed`. `observed` and `mixed` are unreachable
                here.
            point_source_detail:
              type: string
              description: >-
                Always `reconstruction from public record`. Stated in the
                payload so the provenance travels with the data rather than
                living only in a customer's memory of these docs.
            model_rows:
              type: integer
              description: Always 0 — no model ever scored a row of this corpus.
            observed_rows:
              type: integer
              description: Always 0 — no row here was watched live.
            sequence:
              type: string
              enum: [raw]
              description: >-
                Always `raw`. A reconstructed tape has one source and no
                corrections, so `?sequence=clean` has nothing to collapse; the
                parameter is ignored, not refused.
            generated_at: { type: string, format: date-time }
    HeadToHead:
      type: object
      description: >-
        The record between two players across both halves of the product —
        the results archive (1968–2022) and our own completed matches
        (2023→now). Totals count meetings with a KNOWN winner; `undecided`
        counts the rest.
      properties:
        players:
          type: [object, "null"]
          description: >-
            The resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no
            player matches the fragments.
        totals:
          type: object
          properties:
            p1_wins: { type: integer }
            p2_wins: { type: integer }
            meetings: { type: integer }
            undecided:
              type: integer
              description: >-
                Meetings with no derivable winner (never counted in wins).
        by_surface:
          type: object
          description: >-
            Per-surface win split of the decided meetings; keys are surface
            names plus 'unknown'.
          additionalProperties:
            type: object
            properties:
              p1: { type: integer }
              p2: { type: integer }
        meetings:
          type: array
          description: >-
            Newest first, capped at 200. `era` says which half served the
            row — 'archive' rows carry `archive_match_id`/`level`/`score`;
            'current' rows carry `match_id`/`round_code` and read their score
            from the match endpoints. `winner` is 1|2 OF THIS H2H (p1/p2 as
            requested), null when underivable.
          items:
            type: object
            properties:
              era: { type: string, enum: [archive, current] }
              date: { type: [string, "null"] }
              tournament: { type: [string, "null"] }
              level: { type: [string, "null"] }
              round: { type: [string, "null"] }
              surface: { type: [string, "null"] }
              score: { type: [string, "null"] }
              outcome: { type: [string, "null"] }
              winner: { type: [integer, "null"] }
        stats:
          type: [object, "null"]
          description: >-
            ULTRA only — per-player serve/return/break-point aggregates over
            the pairing, keyed `p1`/`p2`. Each side carries `archive_serve`
            (serve-side figures, meetings from 1991) and `current` (2023+,
            adding return and break-point conversion, aces and winners), each
            with its own `meetings_with_stats` sample size. Absent below ULTRA.
    RallyMatch:
      type: object
      description: >-
        One charted match of the rally-construction corpus — its own id
        space, keyed on `rally_match_id`.
      properties:
        rally_match_id:
          type: integer
          description: The id this product is keyed on.
        source_id: { type: string }
        match_id:
          type: [integer, "null"]
          description: >-
            OUR match id, when the charted match is also one we hold. Null
            otherwise — most charted matches predate our own collection.
        date: { type: [string, "null"], format: date }
        tournament: { type: [string, "null"] }
        round: { type: [string, "null"] }
        surface: { type: [string, "null"] }
        gender: { type: [string, "null"], enum: [M, W, null] }
        best_of: { type: [integer, "null"] }
        players:
          type: array
          maxItems: 2
          items:
            type: object
            properties:
              name: { type: [string, "null"] }
              hand: { type: [string, "null"], enum: [R, L, U, A, null] }
        points:
          type: integer
          description: Charted points in this match.
        points_parsed:
          type: integer
          description: >-
            How many of them our parser read cleanly — the per-match quality
            number.
    RallyPoint:
      type: object
      description: >-
        One charted point. `raw` is the charter's own string, verbatim, and
        is ALWAYS present; the parsed fields are our reading of it. `parsed`
        is false when the notation contained something we could not read
        cleanly — the recognised part is still returned. A consumer who wants
        only unambiguous rows filters on `parsed`.
      properties:
        point: { type: integer }
        set:
          type: array
          items: { type: [integer, "null"] }
          maxItems: 2
        games:
          type: array
          items: { type: [integer, "null"] }
          maxItems: 2
        score:
          type: [string, "null"]
          description: e.g. '30-40'.
        game: { type: [integer, "null"] }
        is_tiebreak: { type: boolean }
        server: { type: [integer, "null"], enum: [1, 2, null] }
        point_winner: { type: [integer, "null"], enum: [1, 2, null] }
        raw:
          type: [string, "null"]
          description: >-
            The charter's shot string; both serves joined by ';' when the
            first was a fault.
        parsed: { type: boolean }
        serve_number: { type: [integer, "null"], enum: [1, 2, null] }
        serve_direction:
          type: [string, "null"]
          enum: [wide, body, down_the_t, null]
        rally_length:
          type: [integer, "null"]
          description: >-
            Strokes including the serve. An ace is 1, a double fault 0.
        outcome:
          type: [string, "null"]
          enum: [winner, forced_error, unforced_error, error, other, null]
          description: >-
            `error` = the charter recorded a miss without saying whether it
            was forced. Never guessed.
        error_location:
          type: [string, "null"]
          enum: [net, wide, deep, wide_and_deep, null]
        ending_stroke: { type: [string, "null"] }
        ending_wing: { type: [string, "null"] }
        is_ace: { type: boolean }
        is_double_fault: { type: boolean }
        is_serve_and_volley: { type: boolean }
        shots:
          type: array
          items: { $ref: "#/components/schemas/RallyShot" }
    RallyShot:
      type: object
      description: >-
        One stroke. Shots are numbered from the serve: serve 1, return 2, the
        server's next ball 3.
      properties:
        number: { type: integer }
        code:
          type: string
          description: The charter's raw code, e.g. 'f'.
        stroke:
          type: [string, "null"]
          enum: [serve, groundstroke, slice, volley, half_volley,
                 swinging_volley, overhead, drop_shot, lob, trick, unknown,
                 null]
        wing:
          type: [string, "null"]
          enum: [forehand, backhand, null]
          description: The side it was struck FROM.
        direction:
          type: [string, "null"]
          enum: [forehand_side, middle, backhand_side, null]
          description: Where the ball was sent.
        depth:
          type: [string, "null"]
          enum: [shallow, mid, deep, null]
        position:
          type: [string, "null"]
          enum: [approaching, at_net, baseline, null]
