{
 "openapi": "3.1.0",
 "info": {
  "title": "Live Tennis API",
  "version": "1.13.4",
  "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\nmodel-driven match analysis. Read-only. Coverage spans ATP, WTA,\nChallenger, ITF and the junior Grand Slam draws — depth differs by tour\nand surface; `GET /history/coverage` states the measured numbers.\n\nAccess is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes\neverything in the tiers below it; the concrete deltas are:\n\n`FREE` — self-serve, no card (https://livetennisapi.com/subscribe/free).\nLive and upcoming matches, current scores, players, fixtures, the\ntournament catalogue (`/tournaments`), and your own usage stats.\n30 requests/minute, 100/day. No historical results, no market prices,\nno model fields, no WebSocket.\n\n`BASIC` — adds historical data: the completed-match listing\n(`/history/matches`, and `status=completed` on `/matches`), the\nper-match point-by-point tape with the model win-probability on the\nrows where the model ran\n(`/history/matches/{matchId}`), the measured completeness rollup\n(`/history/coverage`), and the results archive (1968–2022) —\ndeep results (`/history/archive/matches`), archive player bios\n(`/history/archive/players`), career aggregates\n(`/history/archive/career`) and head-to-head (`/h2h`).\n60 requests/minute, 1,000/day.\n\n`PRO` — adds match events (`/matches/{matchId}/events`), market prices\n(`/markets`, `/markets/{matchId}/prices`, `/matches/{matchId}/prices`),\nthe pre-built monthly bulk history packages (`/history/packages`) and the\nrank-ordered rankings listing (`/rankings?system=`).\n300 requests/minute, 10,000/day.\n\n`ULTRA` — adds model analysis (`/matches/{matchId}/analysis`), the live\nmodel fields (`win_probability_p1`, `danger`) on every score object,\nin-play match statistics (`/matches/{matchId}/statistics`), per-player\nas-of ranking records (`/rankings?player=`), the as-of Elo tape\n(`/rankings?system=elo` — both modes, plus `kind=elo` bulk packages),\nrally construction\n(`/rally/matches`, shot-by-shot charted data), career and per-match\ncharting stats (`/charting/players`, `/charting/matches/{chartingMatchId}`),\nthe reconstructed 2013–2022 archive tape\n(`/history/archive/matches/{archiveId}/tape` — also opened by ANY active\nHistory plan, Starter included), the WebSocket live feed at `/ws` and the\nhigh-fan-out push feed (`/ws-token`), and outbound webhooks (direct keys).\n600 requests/minute, 500,000/day.\n\nHistory runs in two continuous halves, deliberately non-overlapping: the\npoint-by-point tape (2023→now) covers January 2023 to now, match by\nmatch, point by point; the results archive (1968–2022) covers 1968\nthrough 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at\nthe time — no point-by-point). The archive ends exactly where the tape\nbegins, so no match is ever served from two datasets.\n\nArchive results played **2013–2022** additionally carry a RECONSTRUCTED\npoint-by-point tape at `/history/archive/matches/{archiveId}/tape` — the\nscore sequence behind the published result, rebuilt from the public record\nafter the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022\nONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT\nONE of them has a tape, because there is no public point-by-point record of\nthose years to rebuild and we do not manufacture one. Write the range as\n2013–2022, never as \"pre-2023\" — the second phrasing reads as 1968 onward\nand is wrong by 45 seasons.\n\nNobody watched those matches, and the data says so: `timestamp`,\n`win_probability_p1` and `danger` are null on EVERY row and cannot be\nfilled in later — the production table has no timestamp column at all, and\nthe promotion script refuses to run if one ever appears.\nContrast the 2023→now tape, which is our own recording: the rows we\nactually watched carry a real clock, and most of them a model probability.\nCoverage of the era is real but partial — 19.3% of archive matches played\n2013–2022 and 44.9% of tour-level play; main-draw tour buckets run\n91.6–98.7%, ATP Challenger main draws 55.3% and Challenger qualifying\n33.6%, slam QUALIFYING only 16.0% (ATP) / 18.1% (WTA), and ITF/futures\neffectively nothing (25 of 116,575 ATP futures matches). It is not a\ncomplete record of the era and is not sold as one.\n\nTwo different gates, on purpose: the per-match tape needs core ULTRA **or\nany active History plan, Starter included**; the per-year bulk files\n(`/history/packages?kind=archive_tape`, 2013–2022, JSONL + CSV) need core\nULTRA **or** a History Pro/Business subscription (an active one-off package\nwindow counts). Core PRO carries NEITHER — it reads the archive RESULT and\nis refused the tape.\n\nA call above your tier returns `403 {\"error\":\"upgrade_required\"}` — never\na silent empty result.\n\nCORS is enabled across the REST surface: every response carries\n`Access-Control-Allow-Origin: *` (GET/OPTIONS, no credentials mode — there\nis no cookie or session, and a wildcard origin is incompatible with\ncredentials by design). Putting a FREE key in browser code is acceptable —\nit is capped and revocable; a paid key belongs server-side only.\n\nThe `/history/*` endpoints are also sold standalone as the **Historical\nData API** (no live-API subscription required): **Starter** — single-match\npoint-by-point tape reads via the API (tape plus the model win-probability\nper point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per\nrequest, no bulk downloads; **Pro** — everything in Starter plus bulk\nmonthly package\ndownloads and higher rate limits; **Business** — everything in Pro plus\nyear-scale archive exports, top rate limits and priority support. One-off\n1-month and 1-year access passes are available without a subscription.\nThe results archive (1968–2022) endpoints (`/history/archive/*`, `/h2h`)\nride with the same entitlement — any active History plan, Starter\nincluded, opens them alongside the tape endpoints, and that includes the\nreconstructed 2013–2022 archive tape. The per-year `archive_tape` bulk\nfiles do not: those need Pro, Business or an active one-off package pass,\nbecause a Starter grant reads tapes one at a time and does not download\nyears of them.\nPlans and prices: https://livetennisapi.com/historical-tennis-data-api\n\nAll timestamps are UTC ISO 8601 with a `Z` suffix. List endpoints return\n`{data, meta}`; single resources return the object directly. Ignore\nunknown fields — additive changes land within v1.\n\nA native WebSocket live feed (ULTRA) exists at `/ws` under the same base\nURL. Subscribe with one JSON frame whose keys are `topics` and\n(optionally) `signals`: `{\"topics\":[\"live-scores\"]}` — `topics` may also\nname `\"match:<id>\"`. The server acks with a `subscribed` frame, then\npushes `score` frames on every change plus a `ping` heartbeat roughly\nevery 15s. Score frames carry the ULTRA model fields\n(`win_probability_p1`, `danger`) live; a null there means the model had\nno output for that point, not that the field is REST-only. Opt into extra\nsignals with `{\"topics\":[\"live-scores\"],\"signals\":[\"break_point\"]}` to\nalso receive `break_point` and `break_point_result` frames — and\n`signals:[\"stoppages\"]` (2026-09-12) for the stoppage family: medical\ntimeouts, trainer calls, toilet breaks, whole-match stops and clock-inferred\npauses, each an Event object plus `match_id` — (schemas\n`BreakPoint` / `BreakPointResult`). Without `signals`, score frames only.\n\n`signals` may also name `points` — the live per-point event stream: one\n`point` frame (schema `PointFrame`) per persisted point of your\nsubscribed matches, ordered per match by `seq`. The signal is\nconfig-gated and ships OFF by default; the `subscribed` ack echoes the\nsignals actually active, so `points` present in the ack means point\nframes will flow and missing means they will not. Frames arrive only for\nmatches with `pbp_coverage: \"point\"` — a `game`-coverage match sends\nnone, honestly. Best-effort with NO replay: on reconnect (or to join\nmid-match) catch up via `GET /matches/{matchId}/points?after_seq=` and\ndedup by `seq`.\n\nMax 2 concurrent connections per key. For high fan-out, `GET /ws-token`\nmints a token for the separate push feed.\n\nGetting a match id: it is the `id` field on any match object returned by\n`GET /matches`, `GET /fixtures` or `GET /history/matches`, and the same value\nworks on every route that takes `matchId`.\n"
 },
 "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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     }
    }
   }
  },
  "/events": {
   "get": {
    "summary": "Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)",
    "operationId": "listSlateEvents",
    "description": "Added 2026-09-13. The rows of GET /matches/{matchId}/events for EVERY match in one request, so a poller watching the whole live slate spends one request per tick rather than one per match. `after_id` returns rows with id greater than the one passed, ascending, and `meta.next_cursor` names the last id served (null on a short page = caught up); `since` (UTC instant) is the first-call lower bound; with neither the newest page is served, still ascending. `type` narrows to a comma-separated list of event types or the family name `stoppages` (stoppage_*, pause_*, medical_timeout_*, trainer_called*, toilet_break_*). Rows carry `id` and `match_id` next to the per-match fields. Measured 2026-09-13: a scorer-stated stoppage reaches the feed a median 8 s (p90 13 s) after the scorer's own instant; the WebSocket `stoppages` signal pushes the same row as it is written.",
    "parameters": [
     {
      "name": "type",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "Comma-separated event types (see Event.type), or `stoppages` for the whole stoppage family.",
      "example": "medical_timeout_start"
     },
     {
      "name": "after_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer"
      },
      "description": "Serve rows with `id` greater than this, ascending. Take it from `meta.next_cursor` or the last row's `id`."
     },
     {
      "name": "since",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "format": "date-time"
      },
      "description": "First-call lower bound, a UTC instant. Rows stamped after it, ascending.",
      "example": "2026-09-13T09:00:00Z"
     },
     {
      "$ref": "#/components/parameters/limit"
     }
    ],
    "responses": {
     "200": {
      "description": "Events across the slate, ascending id",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "data": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/SlateEvent"
           }
          },
          "meta": {
           "$ref": "#/components/schemas/ListMeta"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "bad_type, bad_after_id or bad_since"
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/UpgradeRequired"
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     }
    }
   }
  },
  "/matches/{matchId}/status-history": {
   "get": {
    "summary": "The per-match status ledger — every status / event_status transition with its UTC instant (BASIC, history)",
    "description": "Added 2026-09-12. Append-only, oldest first: one row per change of `status` and/or `event_status`, with the instant we published it, the value before and the effective value after, the derived `outcome`, and the newest score row at that instant. A correction is a new row, never an edit — a close published as `unresolved` and later confirmed shows the flip to `completed`; a completion that reopened shows `completed -> live`. `basis: observed` rows exist from 2026-09-11T22:45:48Z; `basis: backfill` rows (2026-09-12) were reconstructed from the one stamp per kind the match row kept before the ledger existed (last promotion to live from 2026-09-05, completion instant from 2026-08-21, last reopen, last event_status change) — one row per stamp, overwritten intermediate transitions are not recovered. History capability (BASIC and the Historical Data plans), like the tape.",
    "operationId": "getMatchStatusHistory",
    "parameters": [
     {
      "$ref": "#/components/parameters/matchId"
     },
     {
      "$ref": "#/components/parameters/limit"
     },
     {
      "$ref": "#/components/parameters/offset"
     }
    ],
    "responses": {
     "200": {
      "description": "Status transitions, oldest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "data": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/StatusChange"
           }
          },
          "meta": {
           "$ref": "#/components/schemas/ListMeta"
          }
         }
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/UpgradeRequired"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nCOVERAGE 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.\n\nFILTER 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.\n\nONE 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"
        }
       }
      }
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nDERIVED (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.\n\nMEASURED (`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.\n\nMeasured 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).\n\n`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.\n\nTHE 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.\n\nTiebreak 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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nREAD 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).\n\nEach 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.\n\nUp 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.\n\nCOMPLETED 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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\n404 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.\n\nTicks are kept for 30 days and then deleted: a market older than that answers with an empty `prices` array while staying mapped — retention, not a fault. For paging past 500 ticks use `/matches/{matchId}/prices` with its cursor.",
    "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"
        }
       }
      }
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nTicks are kept for 30 days and then deleted, so a match older than that answers an empty `data` while its market stays mapped — that is retention, not a fault. Past 500 ticks, page with the cursor: read a page, and while `meta.has_more` is true pass `meta.next_cursor` back as `?cursor=` for the next (older) page. Pages never overlap or skip a tick (added 2026-09-09).",
    "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
      }
     },
     {
      "name": "cursor",
      "in": "query",
      "schema": {
       "type": "string"
      },
      "description": "Keyset cursor for the next (older) page — the `meta.next_cursor` of the previous page (added 2026-09-09). Opaque; anything else is `400 bad_cursor`."
     }
    ],
    "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: pass `next_cursor` back as `?cursor=` for the next older page."
            },
            "next_cursor": {
             "type": [
              "string",
              "null"
             ],
             "description": "Present (non-null) exactly when `has_more` is true — pass it back as `?cursor=` for the next older page (added 2026-09-09)."
            },
            "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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nThe 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.\n\nWORKS 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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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).\n\nA 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.\n\nNOBODY 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.\n\nYEARS: 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.\n\nCOVERAGE 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.\n\nHOW 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.\n\n`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.\n\nThis 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.\n\nTier: 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. `corrections` (added 2026-09-10) streams the package's correction CSV — `match_id, field, before, after, tournament_key, source, corrected_at` — when stored data was repaired after the package was published; the manifest's `files` lists it with `format: corrections`, and a package without one answers 404 `no corrections file for that package`. First use: the 2023-02 → 2024-12 tape packages carry `surface` corrections for 19,439 Challenger matches (repaired 2026-09-10; the packages themselves were rebuilt the same day).",
      "schema": {
       "type": "string",
       "enum": [
        "jsonl",
        "csv",
        "corrections"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The manifest (no `format`), or the bulk file as an attachment (`format=jsonl` streams NDJSON, `format=csv` streams CSV, `format=corrections` streams the correction 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.\n\nTWO 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).\n\nSYSTEM=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.\n\nSYSTEM=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",
         "atp_doubles",
         "wta_doubles"
        ]
       }
      },
      "explode": true,
      "description": "Restrict to one or more systems. Omit for all of the official systems — `elo`, `atp_doubles` and `wta_doubles` are NEVER included implicitly and must be named (a doubles tie prints as repeated ranks, as the publisher prints it), 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.\n\nIt 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"
     },
     "410": {
      "$ref": "#/components/responses/MatchGone"
     },
     "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.\n\nEach 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`).\n\nDelivery 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",
            "status",
            "stoppages"
           ]
          },
          "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.\n\nThe 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.\n\nThe `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, and since 2026-09-12 the stoppage family: `medical_timeout_start/end`, `trainer_called/_end`, `toilet_break_start/end`, `stoppage_start/end`, `pause_start/end`). 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.\n\nGet it from any match list, where it is the `id` field of each match object:\n`GET /matches?status=live` (in progress), `GET /matches?status=upcoming` or `GET /fixtures` (scheduled), `GET /matches?status=completed` or `GET /history/matches` (finished).\n\nIds are stable for the life of a match, so one captured before it starts still resolves after it finishes.\n",
    "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"
      }
     }
    }
   },
   "MatchGone": {
    "description": "The match id existed and was merged into another match record (since 2026-09-05). The body is `MatchMerged`, not `Error`: it carries a forwarding address, which is the whole point of answering 410 rather than 404.",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/MatchMerged"
      }
     }
    }
   },
   "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": {
   "MatchMerged": {
    "type": "object",
    "description": "The 410 GONE body for a match id that was merged into another match record. Distinct from `Error` because it carries a forwarding address.",
    "properties": {
     "error": {
      "type": "string",
      "description": "Always `merged`."
     },
     "merged_into": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The id that absorbed this one — re-point your records at it. Null means the forwarding target is unknown: nothing to forward you to, drop the id. On a merge chain this is the END of the chain, never an intermediate id that would 410 in turn."
     },
     "merged_at": {
      "type": "string",
      "format": "date-time",
      "description": "When THIS id stopped existing (UTC). On a chain it is the FIRST hop — the moment your copy of this id went stale."
     },
     "detail": {
      "type": "string",
      "description": "Human-readable explanation; differs for a merge and for a retirement so the two are distinguishable without parsing prose."
     }
    }
   },
   "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"
      ]
     },
     "win_probability_p1_model": {
      "type": [
       "number",
       "null"
      ],
      "description": "ULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to; null on rows stamped before it existed (never back-filled)."
     },
     "win_probability_meta": {
      "type": [
       "object",
       "null"
      ],
      "description": "ULTRA, since 2026-09-12. Our own model-regime identifier, the UTC instant the two probabilities were generated, and whether the anchor moved win_probability_p1. Null on rows stamped before it existed.",
      "properties": {
       "model_version": {
        "type": "string",
        "example": "markov-population-2026-08-23"
       },
       "generated_at": {
        "type": [
         "string",
         "null"
        ],
        "format": "date-time"
       },
       "market_anchored": {
        "type": "boolean"
       }
      }
     },
     "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"
        ]
       },
       "ratings_as_of": {
        "type": [
         "string",
         "null"
        ],
        "format": "date",
        "description": "The date (UTC) the `ratings` block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating."
       },
       "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"
     },
     "live_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "The instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after `scheduled_time`, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again."
     },
     "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."
     },
     "outcome": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "completed",
       "retired",
       "walkover",
       "default",
       "abandoned",
       "unresolved",
       null
      ],
      "description": "The settlement outcome (added 2026-08-18), one closed vocabulary derived from `status` + `event_status` so the two can never disagree: `completed` = ran its full course; `retired` = a player retired in play; `walkover` = conceded before a ball was struck; `default` = a player was defaulted; `abandoned` = called off unfinished with no result awarded; `unresolved` (added 2026-09-10) = every source lost the match before a result and it was closed unfinished — `score` is the last state we observed, `winner` is null and no result is asserted; it flips to `completed` with the proven final when an authority confirms the result. NULL while the match is upcoming/live and on a cancelled fixture that never produced a result — null is not `completed`. Branch settlement on this field, never on `event_status` spellings. New values may be added within v1; handle unknowns gracefully."
     },
     "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 = players.p1's outcome, 2 = players.p2's - decided by our name-verified match-market mapping, never by the venue's own display order (the venue lists roughly half of all pairings the other way round). Fixed 2026-09-09: ticks served before that date followed the venue's display order, so on those markets side was reversed; the mapping is stored, so re-reading any tick now returns the correct side."
     },
     "bid": {
      "type": [
       "number",
       "null"
      ]
     },
     "ask": {
      "type": [
       "number",
       "null"
      ]
     },
     "mid": {
      "type": [
       "number",
       "null"
      ],
      "description": "The venue's observed midpoint at the instant we polled or streamed it - never a model estimate. Only mid is always real; see synthetic."
     },
     "spread": {
      "type": [
       "number",
       "null"
      ]
     },
     "price_source": {
      "type": [
       "string",
       "null"
      ],
      "description": "Feed category, e.g. prediction_market"
     },
     "synthetic": {
      "type": [
       "boolean",
       "null"
      ],
      "description": "true = bid/ask are estimated from mid (mid +/- 0.005, not a live order book) - mid itself is the venue's observed midpoint, never a model estimate; false = real top-of-book from the live order-book stream; null = unknown (older ticks). The live stream attaches when a match goes live, so pre-match ticks are synthetic: true by design."
     },
     "timestamp": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "When we observed this quote (UTC) - our observation clock, not a venue publication time."
     }
    }
   },
   "SlateEvent": {
    "description": "An Event as served by the slate-wide feed (GET /events) — the per-match row plus its id and match.",
    "allOf": [
     {
      "$ref": "#/components/schemas/Event"
     },
     {
      "type": "object",
      "properties": {
       "id": {
        "type": "integer",
        "description": "The event's id; the `after_id` cursor."
       },
       "match_id": {
        "type": "integer"
       }
      }
     }
    ]
   },
   "Event": {
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "enum": [
       "break",
       "set_won",
       "game_won",
       "momentum_run",
       "stoppage_start",
       "stoppage_end",
       "pause_start",
       "pause_end",
       "medical_timeout_start",
       "medical_timeout_end",
       "trainer_called",
       "trainer_called_end",
       "toilet_break_start",
       "toilet_break_end"
      ],
      "description": "The stoppage family (added 2026-09-12). `medical_timeout_start/end`, `trainer_called/_end` and `toilet_break_start/end` are stated by the match scorer or chair umpire, with `player` = the player concerned. Coverage (2026-09-13): WTA, WTA 125, Challenger and UTR singles — WTA and WTA 125 from the umpire console in two layers: the match state within ~20 s (`trainer_called`, `toilet_break_*`, `stoppage_*`, player null) and the console's event feed, published by the tour with a variable delay (measured 2026-09-13: about one minute to an hour behind play), which adds `medical_timeout_start/end` with the player and exact instants; Challenger and UTR from the scorer timeline (22 of 70 finished Challenger matches, median 8 s). ITF World Tennis Tour singles from the court's live-scoring state (the state, not the player: `player` is null on ITF rows; first medical timeout observed and published 2026-09-13). ATP main tour: a public live-score service's match stage is watched for its medical-timeout and interruption stages — present in its vocabulary, not yet observed on a tennis match, so ATP rows are mapped, not yet proven (`player` null, `at` = our observation instant). Not doubles. `stoppage_start/end` is a whole-match stop (`reason` weather | other when stated, unknown when only a bare suspension flag reached us). `pause_start/end` is an interruption of play measured from our own point clocks (`basis: inferred`), never labelled medical."
     },
     "player": {
      "type": [
       "integer",
       "null"
      ],
      "enum": [
       1,
       2,
       null
      ]
     },
     "timestamp": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Stoppage family — the UTC instant the stoppage happened (for a pause, its last score change)."
     },
     "detected_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "Stoppage family — when we recorded it."
     },
     "basis": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "observed",
       "inferred",
       null
      ],
      "description": "Stoppage family — observed (a source stated it) or inferred (measured from our clocks)."
     },
     "reason": {
      "type": [
       "string",
       "null"
      ],
      "description": "Stoppage family — medical_timeout | unspecified | no_treatment | taping | changeover_treatment | evaluating | failed | toilet_break | weather | other | resumed | unknown."
     },
     "position": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "point",
       "changeover",
       "setbreak",
       null
      ],
      "description": "pause rows only — where the last point left the match, which sets the silence threshold."
     },
     "score": {
      "type": [
       "object",
       "null"
      ],
      "description": "Stoppage family — sets, games, points, server at the moment of the stoppage."
     },
     "duration_seconds": {
      "type": [
       "integer",
       "null"
      ],
      "description": "end rows only."
     }
    }
   },
   "StatusChange": {
    "type": "object",
    "description": "One row of the per-match status ledger (added 2026-09-12).",
    "properties": {
     "at": {
      "type": "string",
      "format": "date-time",
      "description": "The instant we published the change (UTC)."
     },
     "status": {
      "type": "object",
      "properties": {
       "from": {
        "type": [
         "string",
         "null"
        ]
       },
       "to": {
        "type": [
         "string",
         "null"
        ]
       }
      }
     },
     "event_status": {
      "type": "object",
      "properties": {
       "from": {
        "type": [
         "string",
         "null"
        ]
       },
       "to": {
        "type": [
         "string",
         "null"
        ]
       }
      }
     },
     "outcome": {
      "type": [
       "string",
       "null"
      ],
      "description": "Derived from the after-state by the Match.outcome definition."
     },
     "basis": {
      "type": "string",
      "enum": [
       "observed",
       "backfill"
      ],
      "description": "observed = written at the instant of the change (from 2026-09-11T22:45:48Z); backfill = reconstructed on 2026-09-12 from the one stamp per kind the match row kept before the ledger existed (last promotion to live from 2026-09-05, completion instant from 2026-08-21, last reopen, last event_status change). One row per stamp; overwritten intermediate transitions are not recovered."
     },
     "score": {
      "type": [
       "object",
       "null"
      ],
      "description": "The newest score row at that instant (sets, games, points, server); null when none existed."
     }
    }
   },
   "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.\nEVERY 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:\nTIER 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.\nTIER 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.\nTIER 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.\nA `_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",
       "atp_doubles",
       "wta_doubles"
      ]
     },
     "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."
     },
     "serve": {
      "type": [
       "integer",
       "null"
      ],
      "enum": [
       1,
       2,
       null
      ],
      "description": "Added 2026-09-12. The serve the point was played on (1 first, 2 second) as an outside source states it, joined onto the row by exact state. Null = not stated for this point; never inferred. Lands a few seconds after the point's own frame (see the `point_update` frame)."
     },
     "outcome": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "ace",
       "double_fault",
       "winner",
       "forced_error",
       "unforced_error",
       null
      ],
      "description": "Added 2026-09-12. How the point ended, as an outside source states it (ATP main tour and Challenger: all five; the ITF World Tennis Tour: ace and double fault; WTA 1000/500/250/125: ace and double fault where the tour's console reports the match — read the page's `enrichment` per match; Grand Slams and UTR: none). Null = not stated."
     }
    }
   },
   "PointUpdate": {
    "type": "object",
    "description": "Added 2026-09-12. Pushed on the point opt-in (`signals:[\"points\"]`, channels `point:match:{id}` / `point:slate`) when a point's `serve` / `outcome` land after its `point` frame went out. Apply to the row with that `seq`; nothing else on the point changes.",
    "properties": {
     "type": {
      "type": "string",
      "enum": [
       "point_update"
      ]
     },
     "match_id": {
      "type": "integer"
     },
     "seq": {
      "type": "integer"
     },
     "serve": {
      "type": [
       "integer",
       "null"
      ],
      "enum": [
       1,
       2,
       null
      ]
     },
     "outcome": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "ace",
       "double_fault",
       "winner",
       "forced_error",
       "unforced_error",
       null
      ]
     }
    }
   },
   "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."
     },
     "enrichment": {
      "type": "object",
      "description": "Added 2026-09-12. What an outside source has stated for THIS match so far — `serve`: stated | none; `outcome`: full | ace_double_fault | none. Read it before building on the fields; it is never a promise about the rest of the match.",
      "properties": {
       "serve": {
        "type": "string",
        "enum": [
         "stated",
         "none"
        ]
       },
       "outcome": {
        "type": "string",
        "enum": [
         "full",
         "ace_double_fault",
         "none"
        ]
       }
      }
     },
     "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"
     },
     "published_at": {
      "type": "string",
      "format": "date-time",
      "description": "Added 2026-09-12, on EVERY data frame of the native WebSocket and the push feed (score, point, point_update, status, the signal family): the UTC instant (ms) the frame left our process for the socket or the fan-out sidecar — the third clock next to the state's `timestamp` (arbiter commit) and a point's `ts` (capture)."
     },
     "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"
     },
     "is_unreturned": {
      "type": "boolean",
      "description": "Derived (added 2026-09-05). The server won the point without the returner putting the ball in play. A double fault is never unreturned."
     },
     "unreturned_kind": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "ace",
       "serve_winner",
       "serve_forced_error",
       "return_forced_error",
       "return_unforced_error",
       "return_error",
       null
      ],
      "description": "How the serve went unreturned. `serve_winner` is a serve winner charted without a direction code (so `is_ace` is unset) — count it with the aces. `return_error` = the return missed and the charter did not say whether it was forced. Null when the return was in play or the parse could not say."
     },
     "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
      ]
     }
    }
   }
  }
 }
}
