-Live Tennis API · docs · version 1.13.74
+Live Tennis API · docs · version 1.13.75
Historical tennis results API — 1968 onward
How far back does the historical tennis data go, and what is in it? The results archive covers 1968 to 2022: GET /history/archive/matches for results, /archive/players for bios, /archive/career for career aggregates and GET /h2h for head-to-head across both the archive and our own completed matches. All BASIC. Matches from 2013 onward additionally carry a reconstructed point-by-point tape at /archive/matches/{archiveId}/tape (ULTRA).
6 endpoints on this page. Base URL
@@ -248,7 +248,7 @@
Example
curl https://api.livetennisapi.com/api/public/v1/hist
GET /history/archive/matches/{archiveId}/tape
Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)
Plan required: ULTRA · operationId: getArchiveTape
-The score sequence behind a 2013–2022 archive result, REBUILT FROM THE PUBLIC RECORD. The result tells you what happened; this tells you how.
+
The score sequence behind a 2013–2022 archive result: RECONSTRUCTED POINT SEQUENCES, NO CAPTURE TIMESTAMPS. The result tells you what happened; this tells you how.
NOBODY WATCHED THESE MATCHES, and every field that would imply otherwise is null. timestamp is null on every row — not by convention, but because the production table has no timestamp column at all, so no repair, backfill or re-archive can put a wall clock on a 2016 row, and the promotion script refuses to run if such a column ever appears. win_probability_p1 and danger are null on every row: the model was not running in 2016 and nothing has been back-computed and served as if it had been (meta.model_rows and meta.observed_rows are both 0, always). profiles is always []. The rule we publish for the 2023+ tape — a non-null timestamp means we watched that point live — holds across both halves of the product.
@@ -256,7 +256,7 @@
GET /history/archive/matches/{archiveId}/t
COVERAGE OF THE ERA, thin spots beside the strong ones: 19.3% of archive matches played 2013–2022 overall, 44.9% of tour-level play. Main-draw tour buckets run 91.6–98.7% (ATP Masters 98.7%, ATP slam 98.0%, WTA Premier Mandatory 97.9%, WTA slam 97.4%, ATP 250–500 95.4%, WTA Premier 94.1%, WTA International 91.6%); ATP Challenger main draws 55.3% and Challenger qualifying 33.6%; slam QUALIFYING is thin at 16.0% (ATP) and 18.1% (WTA); ITF and futures are effectively absent — 25 of 116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25 — and this product does not cover them. If your work is ITF, this is the wrong dataset and we would rather you knew now. 31.0% of the corpus is qualifying-draw play, which is unusual at this scale and is the part you are least likely to hold already.
-HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the vendor DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, seq is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.
+HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the recorded match DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, seq is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.
archiveId is an archive_matches.id (the id on a /history/archive/matches row), NOT a match id — the two are separate, overlapping integer spaces, so passing one to the other's routes resolves a different, real record without erroring. The response names it meta.archive_match_id for exactly that reason. Rows are WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, server: 1 means the winner was serving), unlike the p1/p2-shaped 2023+ tape — a live match has no winner while it is being played.
@@ -265,7 +265,7 @@ GET /history/archive/matches/{archiveId}/t
Tier: core ULTRA, or any active History plan (Starter included, which opens it on a FREE core key). The archive RESULT itself (GET /history/archive/matches/{archiveId}) stays on BASIC — core BASIC and core PRO read the result and are refused the tape, and the refusal is 403 upgrade_required carrying capability: archive_tape. The per-year BULK files (?kind=archive_tape) are a separate gate: core ULTRA, or a History Pro/Business subscription, or an active one-off package window.
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/history/archive/matches/{archiveId}/tape \
-H "Authorization: Bearer twjp_..."
diff --git a/docs/live-scores.html b/docs/live-scores.html
index fe0f374..b6f485b 100644
--- a/docs/live-scores.html
+++ b/docs/live-scores.html
@@ -202,7 +202,7 @@
-Live Tennis API · docs · version 1.13.74
+Live Tennis API · docs · version 1.13.75
Live tennis scores API
How do you read live tennis scores from the API? Poll GET /matches?status=live for the slate, then GET /matches/{matchId}/score for the lowest-latency read of one match. Both are on the FREE plan. Scores carry sets, games, points and the server; /events and /points give the same match as a stream of changes rather than a snapshot, so a client can catch up without re-reading state.
11 endpoints on this page. Base URL
@@ -248,9 +248,9 @@
Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953 \
-H "Authorization: Bearer twjp_..."
@@ -266,7 +266,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/score \
-H "Authorization: Bearer twjp_..."
@@ -288,7 +288,7 @@ Example
curl https://api.livetennisapi.com/api/public/v1/matc
GET /events
Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)
Plan required: PRO · operationId: listSlateEvents
-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.
+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 API 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
Responses
Response fields
@@ -360,9 +360,9 @@ GET /matches/{matchId}/statistics
DERIVED (the top level of players.pN) are rebuilt from the point-by-point record: service and return games played and won, hold and break percentage, break points faced, saved and converted, service and return points.
-MEASURED (players.pN.measured) are counted upstream, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.
+MEASURED (players.pN.measured) are match counts recorded as the match is played, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.
-Measured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been delivered upstream since 2026-07-12 (measured 2026-08-17).
+Measured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been available since 2026-07-12 (measured 2026-08-17).
freshness.derived and freshness.measured each carry their own coverage (live | final | stale | none | diverged; final = the closing figures of a completed match — a finished match cannot be "stale", so its age_seconds is null), as_of, age_seconds and describes — the match state the numbers describe. On diverged the measured VALUES are withheld and freshness.measured_divergence says why; the top-level coverage only summarises the response. none on both returns 200 with null players, not 404 — the match exists and holding nothing for it is the honest answer.
@@ -377,7 +377,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/statistics \
-H "Authorization: Bearer twjp_..."
@@ -386,9 +386,9 @@ GET /matches/{matchId}/points
Plan required: ULTRA · operationId: getMatchPoints
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.
-seq IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and a live match is fed by more than one upstream at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher seq. Each row is self-consistent — its set, game, number, score, sets and games all describe the point that was played — but reading the tape in seq order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the reconstruction basis. So seq is the right key for paging, dedup and resume (unique, stable, strictly increasing — all after_seq needs) and the wrong key for chronology: sort by (set, game, number) to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. number is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so after_seq never needs a refetch, and the page-level quality field reads revised when the page contains such a re-statement. On the reconstruction basis of a completed match, seq is contiguous 1..N in true match order and the two agree.
+seq IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and points of a live match arrive by more than one path at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher seq. Each row is self-consistent — its set, game, number, score, sets and games all describe the point that was played — but reading the tape in seq order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the reconstruction basis. So seq is the right key for paging, dedup and resume (unique, stable, strictly increasing — all after_seq needs) and the wrong key for chronology: sort by (set, game, number) to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. number is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so after_seq never needs a refetch, and the page-level quality field reads revised when the page contains such a re-statement. On the reconstruction basis of a completed match, seq is contiguous 1..N in true match order and the two agree.
-READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: pbp_coverage: "point" means a per-point stream has DELIVERED for this match — at least one played point past the seq 1 opener; "game" means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to point on the first played point. To admit a match as advancing, gate on sequence > 1 (a seeded match is 1) together with stale: false. 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).
+READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: pbp_coverage: "point" means a per-point stream has DELIVERED for this match — at least one played point past the seq 1 opener; "game" means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to point on the first played point. To admit a match as advancing, gate on sequence > 1 (a seeded match is 1) together with stale: false. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. quality: "revised" means the point record was rewritten over an already-served prefix at least once during this match; served rows are never edited (append-only).
Each row is the state AFTER a played point: score/sets/games (tiebreaks carry the running count in score with games frozen at the pre-breaker score), its position (set/game/number), server (of the next point), the derived winner (null when not attributable to a single point — never guessed), and ts — CAPTURE time, when our pipeline committed the state, because no feed asserts a per-point clock and we fabricate none.
@@ -407,7 +407,7 @@
Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/points \
-H "Authorization: Bearer twjp_..."
diff --git a/docs/llms.txt b/docs/llms.txt
index bc48164..93942c3 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -154,11 +154,11 @@ begins. GET /history/packages lists exactly which bulk periods exist (monthly fo
tape, yearly for ?kind=archive and ?kind=archive_tape) and is always the
authoritative answer.
Is there point-by-point data before 2023? For 2013–2022 yes, RECONSTRUCTED, not
-recorded: GET /history/archive/matches/{archiveId}/tape rebuilds the score sequence
-behind a 2013–2022 archive result from the public record — 97,901 matches and
-14,340,663 rows. The floor is hard: 977,903 archive results from 1968–2012 have NO
-tape and never will, because no public point-by-point record of those years exists
-to rebuild and we do not manufacture one. Nobody watched those matches, so
+recorded: GET /history/archive/matches/{archiveId}/tape serves the score sequence
+behind a 2013–2022 archive result as reconstructed point sequences, no capture
+timestamps. 97,901 matches and 14,340,663 rows. The floor is hard: 977,903 archive
+results from 1968–2012 have NO tape and never will, because we hold no point
+sequence for those years and we do not manufacture one. Nobody watched those matches, so
timestamp, win_probability_p1 and danger are null on EVERY row and cannot be filled
in later — the production table has no timestamp column at all, and the promotion
script refuses to run if one ever appears. Do not time
diff --git a/docs/openapi.json b/docs/openapi.json
index ed92cbc..ac80527 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Live Tennis API",
- "version": "1.13.74",
+ "version": "1.13.75",
"contact": {
"name": "Live Tennis API",
"url": "https://livetennisapi.com"
@@ -12,7 +12,7 @@
"identifier": "MIT"
},
"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 market prices, no model fields, no\nWebSocket. Historical results are not part of the tier, but a FREE key\nmay spend 20 calls per calendar month on the history endpoints as a\ntaste of the product — served exactly like an entitled call. Past that\nthey answer `403 upgrade_required` carrying\n`free_history_taste: \"used\"`.\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`), the\nrank-ordered rankings listing (`/rankings?system=`) and one player's\nwin/loss and set-based record (`/players/{playerId}/stats`).\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`) and their\nper-player sums (`serve_return` on `/players/{playerId}/stats`), 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:\"`. 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. On the live basis `seq` is ARRIVAL order, not match\norder: use it to page, dedup and resume, and sort by\n`(set, game, number)` to replay in playing order — a tuple that may\nrepeat or carry a null `number`, so it orders points without identifying\nthem (see `GET /matches/{matchId}/points`). 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\nCLOSE CODES. Every refusal sends its `error` frame **and then closes with a\ncode that says what to do next**, so a reconnect loop or a supervisor keyed\non the close code alone behaves correctly without parsing the frame. The\nclose *reason* repeats the frame's `error` string, so `(code, reason)` is a\ncomplete diagnosis even if the frame was missed.\n`1013` Try Again Later — transient, the request was fine: `connection_limit`\n(reason `connection_limit:per_key` or `connection_limit:server`) and\n`service_unavailable`. Back off and retry; for `per_key` release a\nconnection first, or move to the push feed, which has no shared ceiling.\n`1008` Policy Violation — the request as sent will never be accepted:\n`unauthorized`, `upgrade_required`, `email_unverified`, `client_blocked`,\n`bad_json`, `no_topics`, and any mid-stream loss of access. Fix the request\nor the credentials; do not retry unchanged.\n`1012` Service Restart — reconnect with backoff and re-subscribe.\n`1000` Normal Closure — you closed it, or the stream ended normally.\nChanged 2026-09-18: refusals raised during the *handshake* previously closed\n`1000` with an empty reason, indistinguishable from an orderly shutdown, so\na client awaiting its `subscribed` ack saw only a normal close. The error\nframe was, and still is, delivered before the close; only the close code and\nreason changed.\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"
+ "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 market prices, no model fields, no\nWebSocket. Historical results are not part of the tier, but a FREE key\nmay spend 20 calls per calendar month on the history endpoints as a\ntaste of the product — served exactly like an entitled call. Past that\nthey answer `403 upgrade_required` carrying\n`free_history_taste: \"used\"`.\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`), the\nrank-ordered rankings listing (`/rankings?system=`) and one player's\nwin/loss and set-based record (`/players/{playerId}/stats`).\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`) and their\nper-player sums (`serve_return` on `/players/{playerId}/stats`), 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: reconstructed point sequences,\nno capture timestamps. 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 we hold no point sequence for those years and\nwe 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:\"`. 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. On the live basis `seq` is ARRIVAL order, not match\norder: use it to page, dedup and resume, and sort by\n`(set, game, number)` to replay in playing order — a tuple that may\nrepeat or carry a null `number`, so it orders points without identifying\nthem (see `GET /matches/{matchId}/points`). 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\nCLOSE CODES. Every refusal sends its `error` frame **and then closes with a\ncode that says what to do next**, so a reconnect loop or a supervisor keyed\non the close code alone behaves correctly without parsing the frame. The\nclose *reason* repeats the frame's `error` string, so `(code, reason)` is a\ncomplete diagnosis even if the frame was missed.\n`1013` Try Again Later — transient, the request was fine: `connection_limit`\n(reason `connection_limit:per_key` or `connection_limit:server`) and\n`service_unavailable`. Back off and retry; for `per_key` release a\nconnection first, or move to the push feed, which has no shared ceiling.\n`1008` Policy Violation — the request as sent will never be accepted:\n`unauthorized`, `upgrade_required`, `email_unverified`, `client_blocked`,\n`bad_json`, `no_topics`, and any mid-stream loss of access. Fix the request\nor the credentials; do not retry unchanged.\n`1012` Service Restart — reconnect with backoff and re-subscribe.\n`1000` Normal Closure — you closed it, or the stream ended normally.\nChanged 2026-09-18: refusals raised during the *handshake* previously closed\n`1000` with an empty reason, indistinguishable from an orderly shutdown, so\na client awaiting its `subscribed` ack saw only a normal close. The error\nframe was, and still is, delivered before the close; only the close code and\nreason changed.\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": [
{
@@ -307,7 +307,7 @@
"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.",
+ "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 API 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",
@@ -742,7 +742,7 @@
"/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.",
+ "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 match counts recorded as the match is played, 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 available 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": [
{
@@ -781,7 +781,7 @@
"/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\n`seq` IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and a live match is fed by more than one upstream at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher `seq`. Each row is self-consistent — its `set`, `game`, `number`, `score`, `sets` and `games` all describe the point that was played — but reading the tape in `seq` order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the `reconstruction` basis. So `seq` is the right key for paging, dedup and resume (unique, stable, strictly increasing — all `after_seq` needs) and the wrong key for chronology: sort by `(set, game, number)` to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. `number` is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so `after_seq` never needs a refetch, and the page-level `quality` field reads `revised` when the page contains such a re-statement. On the `reconstruction` basis of a completed match, `seq` is contiguous 1..N in true match order and the two agree.\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 a per-point stream has DELIVERED for this match — at least one played point past the `seq` 1 opener; `\"game\"` means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to `point` on the first played point. To admit a match as advancing, gate on `sequence > 1` (a seeded match is 1) together with `stale: false`. 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: the stored live stream is served on a completed match too, whenever it is itself measured complete (the match-closing point included) or carries `serve`/`outcome` tags and is legal end to end (every transition one attributable point, judged in playing order). A projection never carries a clock or a tag, so a complete tagged stream is strictly more information than any reconstruction of the same match. Only when the stream falls short — no rows at all, incomplete and untagged, or a transition nobody can attribute — and a measured-complete recorded point sequence of the finished match exists does this endpoint serve 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) or `reconstruction` (the projected complete sequence; `quality` is `clean`, every transition measured legal), and on `reconstruction` `basis_reason` says why the stream was not served: `stream_absent` (no stored stream rows), `stream_incomplete` (the stream is legal but does not measure complete — it joined mid-match or stopped short — and carries no tags) or `stream_illegal` (at least one transition is not attributable to one point: a gap or a torn row). When the reconstruction serves it serves 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: if a completed match reads `reconstruction`, re-read from `after_seq=0` rather than resuming a live cursor into it. Precedence fixed 2026-09-21: until then a measured-complete recorded sequence displaced the stream unconditionally, so a completed match could lose its tags the moment a reconstruction landed.\n\nTHE MATCH-CLOSING POINT (added 2026-09-20). Every row is the state AFTER a point, so the point that wins a game is carried by the next game's `number: 0` opener — and the point that wins the match had no next row to be carried by: the live stream never held it, and a serve statistic built off the stream was missing every match's last point. On a COMPLETED match served on the `live` basis the page now closes with ONE terminal row: `seq` = last + 1, `number` 0, `sets`/`games` the final score, `score` `{\"p1\":\"0\",\"p2\":\"0\"}`, `tiebreak` false, `server` null (nobody serves next), `winner` the match winner, `ts` the instant the final score was observed. It is built at read time from the stream's last row and the observed final score, and only when the two are one point apart — the winner held game point and the final is the decided score; nothing is fabricated otherwise. `serve`, `outcome` and `tagged_at` on that row are null: no source's tag for a match's last point is stored yet. When the match ends in a tiebreak (added 2026-09-21) the stream's last row is the decisive tiebreak score itself (7-3, 8-6) and the closing row is the set roll-up after it: the next game number, `number` 0, `tiebreak` false, `sets` incremented for the tiebreak winner, the set banked 7-6 in `games`, `score` 0-0, `server` null, the same `winner` — the same row the stream stores after every other set-ending tiebreak. The response field `ends_at_final` says whether the sequence served ends on the match-closing point: `false` on a completed match whose stream stops short of it — a retirement or walkover (no closing point was played), a capture that stopped two or more points short, or a closer that cannot be stated as one point (from deuce, or from a 10-point match tiebreak). Always `false` on a live match. On the `reconstruction` basis it is judged from the projected sequence's last frame (a complete recorded sequence of a retired match ends at the retirement, so it reads `false` there). WebSocket and push frames are unchanged.\n\nREVISIONS: `changed_since` (added 2026-09-20). `after_seq` is a cursor by `seq`, so it can never return a row you already hold — and a `serve`/`outcome` tag that lands late lands on exactly such a row. Every row now carries `tagged_at`, the UTC instant its tags landed (null while none has). Pass `changed_since=` (e.g. `2026-09-20T00:35:18Z`; `Z` or an offset, a naive value is read as UTC, a date alone is refused) to get only the rows whose `ts` OR `tagged_at` is later than that instant, in `seq` order, paged like any other read and composable with `after_seq`. The post-match recipe: read the match, keep the instant, re-read with `changed_since=` and replace the rows you hold by `seq` — no socket, no full re-fetch. Anything that is not an ISO-8601 timestamp is a 400 `bad_changed_since`. On the `reconstruction` basis no row carries a clock or a tag, so a `changed_since` read of it is an empty page: that sequence is final at first read.",
+ "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\n`seq` IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and points of a live match arrive by more than one path at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher `seq`. Each row is self-consistent — its `set`, `game`, `number`, `score`, `sets` and `games` all describe the point that was played — but reading the tape in `seq` order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the `reconstruction` basis. So `seq` is the right key for paging, dedup and resume (unique, stable, strictly increasing — all `after_seq` needs) and the wrong key for chronology: sort by `(set, game, number)` to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. `number` is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so `after_seq` never needs a refetch, and the page-level `quality` field reads `revised` when the page contains such a re-statement. On the `reconstruction` basis of a completed match, `seq` is contiguous 1..N in true match order and the two agree.\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 a per-point stream has DELIVERED for this match — at least one played point past the `seq` 1 opener; `\"game\"` means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to `point` on the first played point. To admit a match as advancing, gate on `sequence > 1` (a seeded match is 1) together with `stale: false`. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. `quality: \"revised\"` means the point record was rewritten over 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: the stored live stream is served on a completed match too, whenever it is itself measured complete (the match-closing point included) or carries `serve`/`outcome` tags and is legal end to end (every transition one attributable point, judged in playing order). A projection never carries a clock or a tag, so a complete tagged stream is strictly more information than any reconstruction of the same match. Only when the stream falls short — no rows at all, incomplete and untagged, or a transition nobody can attribute — and a measured-complete recorded point sequence of the finished match exists does this endpoint serve 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) or `reconstruction` (the projected complete sequence; `quality` is `clean`, every transition measured legal), and on `reconstruction` `basis_reason` says why the stream was not served: `stream_absent` (no stored stream rows), `stream_incomplete` (the stream is legal but does not measure complete — it joined mid-match or stopped short — and carries no tags) or `stream_illegal` (at least one transition is not attributable to one point: a gap or a torn row). When the reconstruction serves it serves 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: if a completed match reads `reconstruction`, re-read from `after_seq=0` rather than resuming a live cursor into it. Precedence fixed 2026-09-21: until then a measured-complete recorded sequence displaced the stream unconditionally, so a completed match could lose its tags the moment a reconstruction landed.\n\nTHE MATCH-CLOSING POINT (added 2026-09-20). Every row is the state AFTER a point, so the point that wins a game is carried by the next game's `number: 0` opener — and the point that wins the match had no next row to be carried by: the live stream never held it, and a serve statistic built off the stream was missing every match's last point. On a COMPLETED match served on the `live` basis the page now closes with ONE terminal row: `seq` = last + 1, `number` 0, `sets`/`games` the final score, `score` `{\"p1\":\"0\",\"p2\":\"0\"}`, `tiebreak` false, `server` null (nobody serves next), `winner` the match winner, `ts` the instant the final score was observed. It is built at read time from the stream's last row and the observed final score, and only when the two are one point apart — the winner held game point and the final is the decided score; nothing is fabricated otherwise. `serve`, `outcome` and `tagged_at` on that row are null: no source's tag for a match's last point is stored yet. When the match ends in a tiebreak (added 2026-09-21) the stream's last row is the decisive tiebreak score itself (7-3, 8-6) and the closing row is the set roll-up after it: the next game number, `number` 0, `tiebreak` false, `sets` incremented for the tiebreak winner, the set banked 7-6 in `games`, `score` 0-0, `server` null, the same `winner` — the same row the stream stores after every other set-ending tiebreak. The response field `ends_at_final` says whether the sequence served ends on the match-closing point: `false` on a completed match whose stream stops short of it — a retirement or walkover (no closing point was played), a capture that stopped two or more points short, or a closer that cannot be stated as one point (from deuce, or from a 10-point match tiebreak). Always `false` on a live match. On the `reconstruction` basis it is judged from the projected sequence's last frame (a complete recorded sequence of a retired match ends at the retirement, so it reads `false` there). WebSocket and push frames are unchanged.\n\nREVISIONS: `changed_since` (added 2026-09-20). `after_seq` is a cursor by `seq`, so it can never return a row you already hold — and a `serve`/`outcome` tag that lands late lands on exactly such a row. Every row now carries `tagged_at`, the UTC instant its tags landed (null while none has). Pass `changed_since=` (e.g. `2026-09-20T00:35:18Z`; `Z` or an offset, a naive value is read as UTC, a date alone is refused) to get only the rows whose `ts` OR `tagged_at` is later than that instant, in `seq` order, paged like any other read and composable with `after_seq`. The post-match recipe: read the match, keep the instant, re-read with `changed_since=` and replace the rows you hold by `seq` — no socket, no full re-fetch. Anything that is not an ISO-8601 timestamp is a 400 `bad_changed_since`. On the `reconstruction` basis no row carries a clock or a tag, so a `changed_since` read of it is an empty page: that sequence is final at first read.",
"operationId": "getMatchPoints",
"parameters": [
{
@@ -2039,7 +2039,7 @@
"/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.",
+ "description": "The score sequence behind a 2013–2022 archive result: RECONSTRUCTED POINT SEQUENCES, NO CAPTURE TIMESTAMPS. 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 recorded match 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": [
{
@@ -2530,7 +2530,7 @@
"/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.",
+ "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 computed 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": [
{
@@ -2924,7 +2924,7 @@
"/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. An exact name selects its person even where a longer name contains it (same rule as `/h2h`), while one name charted under both genders still refuses, which is what `gender` is for. Every field is a raw SUM over the player's Total rows and `matches_charted` states the sample. COVERAGE IS CURATED — 11,803 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.",
+ "description": "The deepest serve/return profile we hold: 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. An exact name selects its person even where a longer name contains it (same rule as `/h2h`), while one name charted under both genders still refuses, which is what `gender` is for. Every field is a raw SUM over the player's Total rows and `matches_charted` states the sample. COVERAGE IS CURATED — 11,803 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.",
"operationId": "getChartingPlayer",
"parameters": [
{
@@ -2998,7 +2998,7 @@
"/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).",
+ "description": "Every charting 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": [
{
@@ -3898,7 +3898,7 @@
"null"
],
"format": "date-time",
- "description": "Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns `sequence`), then refreshed — at most every 8 s — while the owning source keeps re-asserting the unchanged state, so on a live read it is usually the last-assertion instant, not the accept instant. Never an upstream observation time, never a source's own clock. NOT an ordering key — use `sequence`; for the accept instant use `accepted_at`."
+ "description": "Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns `sequence`), then refreshed — at most every 8 s — while the owning source keeps re-asserting the unchanged state, so on a live read it is usually the last-assertion instant, not the accept instant. Never an observation time from outside our process. NOT an ordering key — use `sequence`; for the accept instant use `accepted_at`."
},
"accepted_at": {
"type": [
@@ -3945,7 +3945,7 @@
"integer",
"null"
],
- "description": "Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchanged state is not counted, which is the difference from sources_count: a feed stuck re-sending its last state keeps sources_count at 1 for as long as the echo lasts and keeps this at 0. The window is wider than sources_count's on purpose, because a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below sources_count and never above it. 0 on a live match past five minutes means nobody moved the score in that time; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). A count, never a name. Present wherever sources_count is."
+ "description": "Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchanged state is not counted, which is the difference from sources_count: a source stuck re-sending its last state keeps sources_count at 1 for as long as the echo lasts and keeps this at 0. The window is wider than sources_count's on purpose, because a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below sources_count and never above it. 0 on a live match past five minutes means nobody moved the score in that time; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). A count, never a name. Present wherever sources_count is."
},
"corroborated": {
"type": [
@@ -5040,7 +5040,7 @@
},
"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.",
+ "description": "How much biographical detail is known for this player, so a consumer can distinguish \"never published\" from \"not yet filled in\" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour.",
"properties": {
"known": {
"type": [
@@ -5119,14 +5119,14 @@
"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."
+ "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 no tour is stated for the match 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.)"
+ "description": "Stable tournament identity — one id per tournament × event type, stable across seasons. Joins `GET /tournaments/{tournamentId}`. Null when the tournament is not in our tournament list at all (UTR events), and on the small residue of matches whose tournament name could not be matched to exactly one edition in that list — 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.)"
},
"tier": {
"type": [
@@ -5186,7 +5186,7 @@
"resolver",
null
],
- "description": "How `tier` was established (added 2026-09-22): `calendar` = the official per-season tour calendar named the level (ATP tour and Challenger calendars, the WTA tournament list); `name` = an unambiguous name rule (the ITF category is in the event's official name — `M25 …`, `W35 …` — team and UTR events are named as such, and the Slam junior draws and exhibitions are their own event type); `wikipedia` = the season's schedule page, used only where the official calendar could not state the level for that season (Eastbourne 2023–2025); `resolver` = resolved after the seed dataset, by the same rules, from the event's name, tour and dates in that season. Null exactly when `tier` is null."
+ "description": "How `tier` was established (added 2026-09-22): `calendar` = the official per-season tour calendar named the level (ATP tour and Challenger calendars, the WTA tournament list); `name` = an unambiguous name rule (the ITF category is in the event's official name — `M25 …`, `W35 …` — team and UTR events are named as such, and the Slam junior draws and exhibitions are their own event type); `wikipedia` = a published season schedule, used only where the official calendar could not state the level for that season (Eastbourne 2023–2025); `resolver` = resolved after the seed dataset, by the same rules, from the event's name, tour and dates in that season. Null exactly when `tier` is null."
},
"surface": {
"type": [
@@ -5199,7 +5199,7 @@
"grass",
null
],
- "description": "Court surface. Null where no source states one, never inferred from a tournament's usual surface in other seasons. TEAM COMPETITIONS (Davis Cup, Billie Jean King Cup, Laver Cup) have no surface at tournament level — each tie is played on the surface its host nation chose — so the match carries its TIE's surface, taken from the ITF's published tie record (venue, indoor/outdoor, surface) and the same for every rubber of that tie; `tournament.surface` stays null for them. A team-event match is null only when the tie's venue is not yet published or the match cannot be matched to a single tie (competition, date window and the nations on court must agree). The other structural null is UTR events, which are not in the vendor tournament catalogue at all and so carry `tournament_id: null` too. For every other event a surface that IS known is published on both the match and its `tournament`, and the two agree."
+ "description": "Court surface. Null where no source states one, never inferred from a tournament's usual surface in other seasons. TEAM COMPETITIONS (Davis Cup, Billie Jean King Cup, Laver Cup) have no surface at tournament level — each tie is played on the surface its host nation chose — so the match carries its TIE's surface, taken from the ITF's published tie record (venue, indoor/outdoor, surface) and the same for every rubber of that tie; `tournament.surface` stays null for them. A team-event match is null only when the tie's venue is not yet published or the match cannot be matched to a single tie (competition, date window and the nations on court must agree). The other structural null is UTR events, which are not in our tournament list at all and so carry `tournament_id: null` too. For every other event a surface that IS known is published on both the match and its `tournament`, and the two agree."
},
"indoor": {
"type": "boolean"
@@ -5220,7 +5220,7 @@
"string",
"null"
],
- "description": "The feed's own free-text round label, verbatim. It is the round WITHIN the draw this match belongs to, and the draw is named by `is_qualifying` — NOT by this string. A qualifying draw has its own semi-final and its own final, so `\"Buenos Aires 3 - Semi-finals\"` with `is_qualifying: true` is the semi-final of the QUALIFYING draw, not of the tournament. Read the two fields together or not at all. The label also embeds the tournament name for most feeds — parse the suffix after the last `\" - \"`, or better, use `round_code`."
+ "description": "The free-text round label, verbatim, as published for the match. It is the round WITHIN the draw this match belongs to, and the draw is named by `is_qualifying` — NOT by this string. A qualifying draw has its own semi-final and its own final, so `\"Buenos Aires 3 - Semi-finals\"` with `is_qualifying: true` is the semi-final of the QUALIFYING draw, not of the tournament. Read the two fields together or not at all. The label also embeds the tournament name on most matches — parse the suffix after the last `\" - \"`, or better, use `round_code`."
},
"round_code": {
"type": [
@@ -5245,14 +5245,14 @@
"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). It matches `/history/archive/matches?round=` exactly. Null when the label is unrecognised — never guessed.\nIT IS DRAW-RELATIVE AND CANNOT, BY ITSELF, TELL YOU WHETHER A MATCH IS QUALIFYING. A qualifying semi-final carries `SF`, the same as a main-draw semi-final; only `Q`/`Q1`..`Q4` appear when the feed itself names the round as qualifying, which most feeds do not. To branch on main draw vs qualifying, use `round_code` AND `is_qualifying` together."
+ "description": "The round in the archive's controlled vocabulary, normalized from the free-text label above (`Q` = qualifying round the label does not number). It matches `/history/archive/matches?round=` exactly. Null when the label is unrecognised — never guessed.\nIT IS DRAW-RELATIVE AND CANNOT, BY ITSELF, TELL YOU WHETHER A MATCH IS QUALIFYING. A qualifying semi-final carries `SF`, the same as a main-draw semi-final; only `Q`/`Q1`..`Q4` appear when the label itself names the round as qualifying, which most labels do not. To branch on main draw vs qualifying, use `round_code` AND `is_qualifying` together."
},
"is_qualifying": {
"type": [
"boolean",
"null"
],
- "description": "Whether this match belongs to the QUALIFYING draw, as the source states it — never GUESSED from main-draw round vocabulary or from the tournament name. (A round the feed itself spells `Qualification Round 1` is the source stating it, and reads `true`: a `false` beside such a round is a payload contradicting itself, not a main-draw assertion. `Semi-finals` and `Final` are main-draw vocabulary and are never read that way.) This is the only field that separates a qualifying draw from the main draw, and it is load-bearing because the two are routinely carried under ONE tournament name with ONE round vocabulary: a tournament will publish a dozen `Semi-finals` and several `Final` that are all qualifying, a week before the main draw begins. Neither `tournament`, `tournament_id` nor `round_code` can tell them apart.\nTHREE-VALUED, and `null` is NOT `false`. `true`/`false` are the source's own assertion; `null` means no source has ever stated it — every row written before the field existed, and rows whose only feed carries no equivalent field. Treat `null` as unknown and filter accordingly, exactly as with every other nullable field on this object."
+ "description": "Whether this match belongs to the QUALIFYING draw, as the source states it — never GUESSED from main-draw round vocabulary or from the tournament name. (A round label that itself reads `Qualification Round 1` states it, and reads `true`: a `false` beside such a round is a payload contradicting itself, not a main-draw assertion. `Semi-finals` and `Final` are main-draw vocabulary and are never read that way.) This is the only field that separates a qualifying draw from the main draw, and it is load-bearing because the two are routinely carried under ONE tournament name with ONE round vocabulary: a tournament will publish a dozen `Semi-finals` and several `Final` that are all qualifying, a week before the main draw begins. Neither `tournament`, `tournament_id` nor `round_code` can tell them apart.\nTHREE-VALUED, and `null` is NOT `false`. `true`/`false` are the source's own assertion; `null` means no source has ever stated it — every row written before the field existed, and rows whose only feed carries no equivalent field. Treat `null` as unknown and filter accordingly, exactly as with every other nullable field on this object."
},
"status": {
"type": "string",
@@ -5262,7 +5262,7 @@
"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."
+ "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 stated reason 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 momentary status flap."
},
"event_status": {
"type": [
@@ -5279,7 +5279,7 @@
"Unresolved",
null
],
- "description": "The feed's own designator for the match state, passed through verbatim. `Finished` is by far the most common value — 144,266 of the 150,678 rows carrying one at 2026-09-18 — and until this version it was missing from this enum, so a strictly-validating generated client rejected the majority of completed matches. It is listed now, with `Unresolved` (every source lost the match before a result; see `outcome`). The rest name a match that 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 does NOT mean \"completed normally\": it means the feed never stated anything for this match, which covers matches that ran their course and matches nothing was ever said about alike. `event_status: Finished` while `status` is still `live` is not a contradiction — one source has called the match over and we have not yet confirmed the final, so the score may stand still until we do (`stale` goes true, `age_seconds` climbs). Measured over the seven days to 2026-09-18, across 649 matches, that gap closed in 111 s at the median and 911 s at the ninetieth percentile; 85 ran past ten minutes. 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."
+ "description": "The stated designator for the match state, published verbatim. `Finished` is by far the most common value — 144,266 of the 150,678 rows carrying one at 2026-09-18 — and until this version it was missing from this enum, so a strictly-validating generated client rejected the majority of completed matches. It is listed now, with `Unresolved` (every source lost the match before a result; see `outcome`). The rest name a match that 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 does NOT mean \"completed normally\": it means nothing was ever stated for this match, which covers matches that ran their course and matches nothing was ever said about alike. `event_status: Finished` while `status` is still `live` is not a contradiction — one source has called the match over and we have not yet confirmed the final, so the score may stand still until we do (`stale` goes true, `age_seconds` climbs). Measured over the seven days to 2026-09-18, across 649 matches, that gap closed in 111 s at the median and 911 s at the ninetieth percentile; 85 ran past ten minutes. 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": [
@@ -5287,11 +5287,11 @@
"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."
+ "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 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."
+ "description": "Doubles match — kept for compatibility, and LOSSY. Evidence order: a doubles-team participant proves true regardless of the event type; otherwise the stated 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": [
@@ -5303,7 +5303,7 @@
"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."
+ "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 stated event type decides. Null means neither says anything — no event type is stated, 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": [
@@ -5954,7 +5954,7 @@
"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."
+ "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 in two layers: the match state within ~20 s (`trainer_called`, `toilet_break_*`, `stoppage_*`, player null) and a later event layer 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: the live match stage is watched for its medical-timeout and interruption stages — present in the 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": [
@@ -6375,7 +6375,7 @@
"properties": {
"sequence": {
"type": "integer",
- "description": "The refused state's `sequence` — the value you hold from the feed, and the frame's `superseded_sequence`."
+ "description": "The refused state's `sequence` — the value you hold from the push feed, and the frame's `superseded_sequence`."
},
"kind": {
"type": "string",
@@ -6656,7 +6656,7 @@
},
"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. `origin` (since 2026-09-20) says explicitly which kind each row is, read from the row's stored provenance — never inferred from its clock; a null `timestamp` remains true of every reconstructed row. The model fields alone are not a marker, 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. `origin`, `serve` and `outcome` are present on `GET /history/matches/{matchId}` rows; the pre-2023 ArchiveTape (every row reconstructed) does not carry them. `winner` (since 2026-09-22) is present on EVERY row of every sequence, on both — who won the point that produced the row, judged from the score step and from nothing else; `server`, `serve` and `winner` together are the three per-point facts a model needs, and the third was only implicit in the score step before. Reconstructed rows are never repaired or reordered: a game whose vendor point record is internally inconsistent is withheld whole, not fitted or patched into a legal sequence — see `meta.points.games_withheld` (added 2026-09-20).",
+ "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. `origin` (since 2026-09-20) says explicitly which kind each row is, read from the row's stored provenance — never inferred from its clock; a null `timestamp` remains true of every reconstructed row. The model fields alone are not a marker, 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. `origin`, `serve` and `outcome` are present on `GET /history/matches/{matchId}` rows; the pre-2023 ArchiveTape (every row reconstructed) does not carry them. `winner` (since 2026-09-22) is present on EVERY row of every sequence, on both — who won the point that produced the row, judged from the score step and from nothing else; `server`, `serve` and `winner` together are the three per-point facts a model needs, and the third was only implicit in the score step before. Reconstructed rows are never repaired or reordered: a game whose recorded points are internally inconsistent is withheld whole, not fitted or patched into a legal sequence — see `meta.points.games_withheld` (added 2026-09-20).",
"properties": {
"origin": {
"type": "string",
@@ -6691,7 +6691,7 @@
"unforced_error",
null
],
- "description": "How the point ended, as an outside source states it — same join rule and same nulls as `serve`. `meta.enrichment` says what landed on this tape. Added 2026-09-20."
+ "description": "How the point ended, as stated for the point — same join rule and same nulls as `serve`. `meta.enrichment` says what landed on this tape. Added 2026-09-20."
},
"sets": {
"type": "array",
@@ -6725,7 +6725,7 @@
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. TIEBREAK ROWS ARE NEVER SETTLED, on either sequence: the server genuinely changes during a breaker, so there is no constant to take a majority of, and the field is the source's own assertion carried through. Two consequences, both reported by a Basic customer on 2026-09-25 and confirmed in the code. The rotation cannot be reconstructed from the rows served wherever the tape is sampled — a breaker whose rows skip points gives you the server of a state, not of a numbered point, and `meta.points.transitions_legal` against `transitions_total` tells you whether that happened. And on the set roll-up row that carries `tiebreak_final`, `server` is the value of that committed set-end state: it is NOT a preserved copy of the final breaker point's server, and we make no claim that it is. Withhold server-dependent tiebreak analysis unless the encoding reconciles on the match in front of you."
+ "description": "Who is serving — null where it was never stated. 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. TIEBREAK ROWS ARE NEVER SETTLED, on either sequence: the server genuinely changes during a breaker, so there is no constant to take a majority of, and the field is the source's own assertion carried through. Two consequences, both reported by a Basic customer on 2026-09-25 and confirmed in the code. The rotation cannot be reconstructed from the rows served wherever the tape is sampled — a breaker whose rows skip points gives you the server of a state, not of a numbered point, and `meta.points.transitions_legal` against `transitions_total` tells you whether that happened. And on the set roll-up row that carries `tiebreak_final`, `server` is the value of that committed set-end state: it is NOT a preserved copy of the final breaker point's server, and we make no claim that it is. Withhold server-dependent tiebreak analysis unless the encoding reconciles on the match in front of you."
},
"is_tiebreak": {
"type": "boolean"
@@ -6778,7 +6778,7 @@
"null"
],
"format": "date-time",
- "description": "On tape rows since 2026-09-24. The instant this state reached our edge, before the arbiter ran, stamped once. `accepted_at` minus `received_at` is our own arbitration step; neither figure is a claim about how fast anything upstream is, and neither is the court's clock. `timestamp` is the only one of the three with no start date: this clock begins 2026-09-24 and `accepted_at` begins 2026-08-11 on an archived tape (this description said the other two had no start date until 2026-10-01, which was wrong about `accepted_at` — see `HistoryTapeRow.accepted_at`). This is also the one clock the 2026-10-01 archive backfill could not move: the live column landed 2026-09-24, a day AFTER the archive column, so every live row carrying an arrival clock was archived with it and the recoverable count is zero. It is stamped only as a state arrives, so it can never be filled in afterwards: the earliest one we hold is `2026-09-24T02:00:26Z`, and on every observed row accepted before that instant it is null while `timestamp` is populated. Measured on match 189679 (12 September) after a Basic customer reported it on 2026-09-25: 171 observed rows, `accepted_at` on all 171, `received_at` on none — a match served from its live rows, so it says nothing about the archived tape. Null on every reconstructed row."
+ "description": "On tape rows since 2026-09-24. The instant this state reached our edge, before the arbiter ran, stamped once. `accepted_at` minus `received_at` is our own arbitration step; neither figure is a claim about how fast anything before our edge is, and neither is the court's clock. `timestamp` is the only one of the three with no start date: this clock begins 2026-09-24 and `accepted_at` begins 2026-08-11 on an archived tape (this description said the other two had no start date until 2026-10-01, which was wrong about `accepted_at` — see `HistoryTapeRow.accepted_at`). This is also the one clock the 2026-10-01 archive backfill could not move: the live column landed 2026-09-24, a day AFTER the archive column, so every live row carrying an arrival clock was archived with it and the recoverable count is zero. It is stamped only as a state arrives, so it can never be filled in afterwards: the earliest one we hold is `2026-09-24T02:00:26Z`, and on every observed row accepted before that instant it is null while `timestamp` is populated. Measured on match 189679 (12 September) after a Basic customer reported it on 2026-09-25: 171 observed rows, `accepted_at` on all 171, `received_at` on none — a match served from its live rows, so it says nothing about the archived tape. Null on every reconstructed row."
},
"sequence": {
"type": [
@@ -7011,7 +7011,7 @@
},
"resent_rows": {
"type": "integer",
- "description": "Rows identical to the row before them — the same state re-sent by a source on its timer, which the raw tape carries by design. No point separates them, so they are excluded from the transition counts (`transitions_total` = `rows` − 1 − `resent_rows`) rather than counted as non-point steps. 0 on a `clean` or `recon` basis: the collapse removes them, and in a reconstruction one row IS one point so a repeat is a vendor tear and is never dropped. Added 2026-09-20."
+ "description": "Rows identical to the row before them — the same state re-sent by a source on its timer, which the raw tape carries by design. No point separates them, so they are excluded from the transition counts (`transitions_total` = `rows` − 1 − `resent_rows`) rather than counted as non-point steps. 0 on a `clean` or `recon` basis: the collapse removes them, and in a reconstruction one row IS one point so a repeat is a tear in the recorded sequence and is never dropped. Added 2026-09-20."
},
"server_rows": {
"type": "integer",
@@ -7053,7 +7053,7 @@
"integer",
"null"
],
- "description": "Present only when reconstructed rows are in the response. Games whose vendor point record was internally inconsistent (the recorded in-game scores do not form a legal single-point progression from 0-0 — a state repeating or moving backwards) have their in-game points withheld rather than published; the game's opening 0-0 row is still served, so the games spine stays intact. Nothing is repaired, reordered or inferred: a game is served exactly as recorded, or not at all. `> 0` means `complete` is `false`. `null` = not measured — the reconstruction predates this check, or was built from the multi-vendor union rather than a single vendor's point record. Added 2026-09-20."
+ "description": "Present only when reconstructed rows are in the response. Games whose recorded points were internally inconsistent (the recorded in-game scores do not form a legal single-point progression from 0-0 — a state repeating or moving backwards) have their in-game points withheld rather than published; the game's opening 0-0 row is still served, so the games spine stays intact. Nothing is repaired, reordered or inferred: a game is served exactly as recorded, or not at all. `> 0` means `complete` is `false`. `null` = not measured — the reconstruction predates this check, or was built by merging more than one point record rather than from a single one. Added 2026-09-20."
}
}
},
@@ -7158,7 +7158,7 @@
"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."
+ "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 recorded sequence 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",
@@ -7408,7 +7408,7 @@
},
"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.",
+ "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 match counts recorded as the match is played, 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"
@@ -7496,7 +7496,7 @@
},
"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.",
+ "description": "Measured counting statistics for one player. These are COUNTED during the match, 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. This group has NOT BEEN AVAILABLE 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 it returns.\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 as a rounded figure.",
"properties": {
"aces": {
"type": [
@@ -7799,7 +7799,7 @@
"integer",
"null"
],
- "description": "Forced errors on the overhead — the upstream's overhead-stroke count."
+ "description": "Forced errors on the overhead — the overhead-stroke count."
},
"overhead_winners": {
"type": [
@@ -7920,7 +7920,7 @@
"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.",
+ "description": "The match state these statistics describe, as recorded with them. `age_seconds` says when we fetched; this says WHAT we fetched. Null when unavailable.",
"properties": {
"games_p1": {
"type": "array",
@@ -8477,7 +8477,7 @@
2,
null
],
- "description": "Server of the point played FROM this state; null when the feed omits it."
+ "description": "Server of the point played FROM this state; null when it is not stated."
},
"winner": {
"type": [
@@ -8546,7 +8546,7 @@
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. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly; nothing is corrected to a second serve (since 2026-09-21). Lands a few seconds after the point's own frame (see the `point_update` frame). Stated on the ATP main tour, ATP Challenger, WTA 1000/500/250/125, UTR and the top Davis Cup tiers; not stated on the ITF World Tennis Tour, nor in any doubles draw. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES, not what we store, and on completed matches the two differ (corrected 2026-09-23; the figures published before that date were counted in our own tables and overstated what a reader could fetch). A completed match whose stored per-point stream does not pass our quality bar is served as a measured-complete RECONSTRUCTION instead, and a reconstruction carries no clock and no tags at all — so per-match tag coverage on completed matches is lower, often much lower, than the share of matches a source tagged. `basis` on this response names which one you were given. Team ties, re-measured 2026-09-23 over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group publishes `serve` on 2 of 22 and World Group I on 1 of 42, while World Group II publishes it on 0 of 38 — against 22 of 22 and 41 of 42 respectively in storage, which is the gap this paragraph is about. Qualifying draws are covered like their own main draw, not excluded, and land in the same place their main draw does: WTA qualifying 18 of 48 against 50 of 88 in the WTA main draw, ATP Challenger qualifying (`tier: challenger_*`) 137 of 197 against 221 of 285, and WTA 125 qualifying (`tier: wta_125`, also carried under `tour: challenger`) 47 of 63 against 117 of 147. Read `enrichment` per match — it is the only per-match answer, none of this is a promise about a match not yet played. Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's serve/outcome coverage exceeds what was captured live — the tags arrive as `point_update` frames and `tagged_at` revisions like any other."
+ "description": "Added 2026-09-12. The serve the point was played on (1 first, 2 second) as stated for the point, joined onto the row by exact state. Null = not stated for this point; never inferred. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly; nothing is corrected to a second serve (since 2026-09-21). Lands a few seconds after the point's own frame (see the `point_update` frame). Stated on the ATP main tour, ATP Challenger, WTA 1000/500/250/125, UTR and the top Davis Cup tiers; not stated on the ITF World Tennis Tour, nor in any doubles draw. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES, not what we store, and on completed matches the two differ (corrected 2026-09-23; the figures published before that date were counted in our own tables and overstated what a reader could fetch). A completed match whose stored per-point stream does not pass our quality bar is served as a measured-complete RECONSTRUCTION instead, and a reconstruction carries no clock and no tags at all — so per-match tag coverage on completed matches is lower, often much lower, than the share of matches a source tagged. `basis` on this response names which one you were given. Team ties, re-measured 2026-09-23 over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group publishes `serve` on 2 of 22 and World Group I on 1 of 42, while World Group II publishes it on 0 of 38 — against 22 of 22 and 41 of 42 respectively in storage, which is the gap this paragraph is about. Qualifying draws are covered like their own main draw, not excluded, and land in the same place their main draw does: WTA qualifying 18 of 48 against 50 of 88 in the WTA main draw, ATP Challenger qualifying (`tier: challenger_*`) 137 of 197 against 221 of 285, and WTA 125 qualifying (`tier: wta_125`, also carried under `tour: challenger`) 47 of 63 against 117 of 147. Read `enrichment` per match — it is the only per-match answer, none of this is a promise about a match not yet played. Since 2026-09-22, on the tours with a complete per-point record (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against that complete sequence by the same exact-state rule, so a completed match's serve/outcome coverage exceeds what was captured live — the tags arrive as `point_update` frames and `tagged_at` revisions like any other."
},
"outcome": {
"type": [
@@ -8561,7 +8561,7 @@
"unforced_error",
null
],
- "description": "Added 2026-09-12. How the point ended, as an outside source states it. ATP 250 / 500 / 1000: serve stated, outcome full (ace, double fault, winner, forced error, unforced error) from the tour's own per-point console. Grand Slams and Davis Cup are not on that console, so `outcome` reads null there — but `serve` comes from a different source and IS stated on them, so those matches are not blanket-null; read `enrichment` per match. First measured on tour events from the week of 2026-09-22 — before that, no tour event had been played since the source went live on 2026-09-12. ATP Challenger — `tour: challenger` with a `challenger_*` tier — states all five. `tour: challenger` also carries the WTA 125 events (`tier: wta_125`), and those state ace and double fault only, so read `tier` rather than `tour` to tell the two apart. 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. ITF World Tennis Tour DOUBLES draws state ace and double fault on a growing minority of matches — the tags first reached a doubles row on 2026-09-23 and the endpoint published them on 104 of 596 completed doubles matches (17%) measured 2026-09-29, so read `enrichment` per match here and do not plan a corpus on the draw. Every other doubles draw (ATP, WTA and Challenger): none, measured 0 of 371 in the same window. Qualifying draws mostly follow their own tour, and the one real gap is on the WTA main tour, not on Challenger. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES rather than what we store (corrected 2026-09-23 — see the note on `serve` above: a completed match served on the `reconstruction` basis carries no tags, so the published share is below the share we hold). Re-measured 2026-09-23 over completed matches carrying a per-point stream since 2026-09-12: ATP qualifying publishes outcome on 16 of 16 and ATP Challenger qualifying on 134 of 197, both the full five; ITF qualifying publishes aces and double faults on 181 of 215 men's and 142 of 176 women's, and WTA 125 qualifying on 30 of 63 — while WTA main-tour qualifying publishes it on 5 of 48, against 47 of 88 in the same events' main draws. Read `enrichment` per match rather than inferring a draw's coverage from its tour. Null = not stated. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly (since 2026-09-21). Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's coverage exceeds what was captured live — collect the late tags with `?changed_since=` or the `point_update` frame."
+ "description": "Added 2026-09-12. How the point ended, as stated for the point. ATP 250 / 500 / 1000: serve stated, outcome full (ace, double fault, winner, forced error, unforced error). Grand Slams and Davis Cup have no per-point outcome, so `outcome` reads null there — but `serve` IS stated on them, so those matches are not blanket-null; read `enrichment` per match. First measured on tour events from the week of 2026-09-22 — before that, no tour event had been played since outcomes went live on 2026-09-12. ATP Challenger — `tour: challenger` with a `challenger_*` tier — states all five. `tour: challenger` also carries the WTA 125 events (`tier: wta_125`), and those state ace and double fault only, so read `tier` rather than `tour` to tell the two apart. The ITF World Tennis Tour: ace and double fault. WTA 1000/500/250/125: ace and double fault where the match has a per-point record — read the page's `enrichment` per match. Grand Slams and UTR: none. ITF World Tennis Tour DOUBLES draws state ace and double fault on a growing minority of matches — the tags first reached a doubles row on 2026-09-23 and the endpoint published them on 104 of 596 completed doubles matches (17%) measured 2026-09-29, so read `enrichment` per match here and do not plan a corpus on the draw. Every other doubles draw (ATP, WTA and Challenger): none, measured 0 of 371 in the same window. Qualifying draws mostly follow their own tour, and the one real gap is on the WTA main tour, not on Challenger. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES rather than what we store (corrected 2026-09-23 — see the note on `serve` above: a completed match served on the `reconstruction` basis carries no tags, so the published share is below the share we hold). Re-measured 2026-09-23 over completed matches carrying a per-point stream since 2026-09-12: ATP qualifying publishes outcome on 16 of 16 and ATP Challenger qualifying on 134 of 197, both the full five; ITF qualifying publishes aces and double faults on 181 of 215 men's and 142 of 176 women's, and WTA 125 qualifying on 30 of 63 — while WTA main-tour qualifying publishes it on 5 of 48, against 47 of 88 in the same events' main draws. Read `enrichment` per match rather than inferring a draw's coverage from its tour. Null = not stated. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly (since 2026-09-21). Since 2026-09-22, on the tours with a complete per-point record (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against that complete sequence by the same exact-state rule, so a completed match's coverage exceeds what was captured live — collect the late tags with `?changed_since=` or the `point_update` frame."
},
"tagged_at": {
"type": [
@@ -8649,11 +8649,11 @@
"clean",
"revised"
],
- "description": "`revised` = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited."
+ "description": "`revised` = the point record was rewritten over 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.",
+ "description": "Added 2026-09-12. What has been 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",
@@ -8793,7 +8793,7 @@
"grass",
null
],
- "description": "Court surface, null where the catalogue holds none — 61 of 10,287 tournaments on 2026-09-20. 35 of those are team competitions (Davis Cup, Billie Jean King Cup, Laver Cup), which have no tournament-level surface to hold: each tie is played on the host nation's choice, and that surface is published on each MATCH of the tie instead. The upstream field for those rows carries the DRAW TIER rather than a surface (\"- Preliminary\", \"- Play Offs\", \"- Promotion\"), and a value that is not one of hard/clay/grass is rejected rather than published. 3 are the exhibition catch-all and 23 are ordinary events the upstream catalogue simply never stated a surface for."
+ "description": "Court surface, null where the catalogue holds none — 61 of 10,287 tournaments on 2026-09-20. 35 of those are team competitions (Davis Cup, Billie Jean King Cup, Laver Cup), which have no tournament-level surface to hold: each tie is played on the host nation's choice, and that surface is published on each MATCH of the tie instead. A surface value that is not one of hard/clay/grass is rejected rather than published. 3 are the exhibition catch-all and 23 are ordinary events with no stated surface."
},
"indoor": {
"type": "boolean"
@@ -8892,7 +8892,7 @@
"resolver",
null
],
- "description": "How `tier` was established (added 2026-09-22): `calendar` = the official per-season tour calendar named the level (ATP tour and Challenger calendars, the WTA tournament list); `name` = an unambiguous name rule (the ITF category is in the event's official name — `M25 …`, `W35 …` — team and UTR events are named as such, and the Slam junior draws and exhibitions are their own event type); `wikipedia` = the season's schedule page, used only where the official calendar could not state the level for that season (Eastbourne 2023–2025); `resolver` = resolved after the seed dataset, by the same rules, from the event's name, tour and dates in that season. Null exactly when `tier` is null."
+ "description": "How `tier` was established (added 2026-09-22): `calendar` = the official per-season tour calendar named the level (ATP tour and Challenger calendars, the WTA tournament list); `name` = an unambiguous name rule (the ITF category is in the event's official name — `M25 …`, `W35 …` — team and UTR events are named as such, and the Slam junior draws and exhibitions are their own event type); `wikipedia` = a published season schedule, used only where the official calendar could not state the level for that season (Eastbourne 2023–2025); `resolver` = resolved after the seed dataset, by the same rules, from the event's name, tour and dates in that season. Null exactly when `tier` is null."
}
}
},
@@ -9383,7 +9383,7 @@
},
"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."
+ "description": "Always `reconstruction from public record`, the fixed label for reconstructed point sequences with no capture timestamps. Stated in the payload so the label travels with the data rather than living only in a customer's memory of these docs."
},
"sequence": {
"type": "string",
@@ -9533,7 +9533,7 @@
"integer",
"null"
],
- "description": "OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection."
+ "description": "OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table."
},
"date": {
"type": [
diff --git a/docs/players-and-tournaments.html b/docs/players-and-tournaments.html
index 78bd2ca..fafc4bb 100644
--- a/docs/players-and-tournaments.html
+++ b/docs/players-and-tournaments.html
@@ -202,9 +202,9 @@
-Live Tennis API · docs · version 1.13.74
+Live Tennis API · docs · version 1.13.75
Tennis players, tournaments and rankings API
-How do you look up a player, a tournament or a ranking? GET /players searches by name and GET /players/{playerId} returns one player's bio, current ranking and cached statistics — both FREE. GET /players/{playerId}/stats (PRO) is the player's win/loss record over our completed matches, split by surface, level and season, with ULTRA adding summed serve and return statistics. GET /tournaments is the catalogue that Match.tournament_id joins against, so a match can be resolved to its event without a second vendor. GET /rankings serves rank-ordered listings on PRO. The per-player as-of records, including the Elo tape, are ULTRA.
+How do you look up a player, a tournament or a ranking? GET /players searches by name and GET /players/{playerId} returns one player's bio, current ranking and cached statistics — both FREE. GET /players/{playerId}/stats (PRO) is the player's win/loss record over our completed matches, split by surface, level and season, with ULTRA adding summed serve and return statistics. GET /tournaments is the catalogue that Match.tournament_id joins against, so a match can be resolved to its event without a second lookup. GET /rankings serves rank-ordered listings on PRO. The per-player as-of records, including the Elo tape, are ULTRA.
8 endpoints on this page. Base URL
https://api.livetennisapi.com/api/public/v1; authenticate with the X-API-Key header. Plans involved:
FREE, PRO. A free key needs no card.
@@ -259,7 +259,7 @@ GET /players/{playerId}
It is NOT the archive corpus person id. The results archive (1968–2022) keeps its own person registry, and archive match rows publish those ids as winner.player_id / loser.player_id. The two spaces are disjoint: no corpus id resolves here, and since 2026-09-20 a 404 for one says so and points at /history/archive/players?id={playerId}, which is where that id is read. A 404 with no see field is simply an id we do not hold.
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/players/1104 \
-H "Authorization: Bearer twjp_..."
@@ -296,7 +296,7 @@ GET /tournaments/{tournamentId}
Plan required: FREE · operationId: getTournament
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
-H "Authorization: Bearer twjp_..."
@@ -309,7 +309,7 @@ GET /rankings
SYSTEM=UTR — observed ratings, honestly bounded. UTR records are observed from UTR's public search: a rating UTR withholds appears as ABSENT, never as 0, and rating is the only populated value — rank and points are always null. Per-player as-of ONLY — there is deliberately no UTR listing, because a table of only the players we happen to track would be a fake leaderboard. Per-player history accumulates from 2026-07-29; scattered earlier single-snapshot observations reach back to 2026-06-01. Coverage is a deliberate bias, not a roster mirror: the 24-hour sweep targets players with no official rank and no Elo rating (so it skews ITF), and among players active in the last 60 days it holds 931 of 5,606 ITF players (16.6%), 197 of 1,903 Challenger (10.4%), 43 of 573 WTA (7.5%) and 15 of 525 ATP (2.9%) — measured 2026-08-17.
-SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so meta.coverage.newest_available states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. rating is the Elo. rank is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. points is always null. matches is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on GET /players/{id}; this is the point-in-time series, the leaderboard and the bulk export. That free rating is sourced differently and sits on a DIFFERENT scale — the two differ by roughly 150 Elo of per-player standard deviation — so never present a rating from one scale against a rating from the other. The Elo listing REQUIRES tour — the ATP and WTA walks are disjoint, so a combined leaderboard would not be comparable — and takes exactly one surface (default overall). elo is never implicit — omitting system returns the official systems only.
+SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so meta.coverage.newest_available states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. rating is the Elo. rank is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. points is always null. matches is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on GET /players/{id}; this is the point-in-time series, the leaderboard and the bulk export. That free rating is computed 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.
Parameters
Responses
Response fields
diff --git a/docs/point-by-point-history.html b/docs/point-by-point-history.html
index b3dd364..48ec0ec 100644
--- a/docs/point-by-point-history.html
+++ b/docs/point-by-point-history.html
@@ -202,7 +202,7 @@
-Live Tennis API · docs · version 1.13.74
+Live Tennis API · docs · version 1.13.75
Point-by-point tennis data API
Which endpoints return point-by-point tennis data, and how complete is it? GET /history/matches/{matchId} returns the tape for one completed match: the ordered score states we hold for it. The model's win probability sits on the rows that were observed live, and coverage is declared per match. GET /history/matches lists completed matches with their tape coverage, and GET /history/coverage is the measured completeness rollup per tour and draw bucket — a number we publish rather than estimate, with its own as_of. GET /history/packages serves the same data as pre-built monthly bulk files.
8 endpoints on this page. Base URL
@@ -278,7 +278,7 @@
Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953 \
-H "Authorization: Bearer twjp_..."
diff --git a/docs/push-feed-and-webhooks.html b/docs/push-feed-and-webhooks.html
index c8075c2..dec24bb 100644
--- a/docs/push-feed-and-webhooks.html
+++ b/docs/push-feed-and-webhooks.html
@@ -202,7 +202,7 @@
-Live Tennis API · docs · version 1.13.74
+Live Tennis API · docs · version 1.13.75
Tennis WebSocket feed and webhooks
How do you receive tennis data as it happens instead of polling? Two ways, both ULTRA. GET /ws-token mints a short-lived token for the high-fan-out push feed, which streams score and point changes as they are written. POST /webhooks registers an outbound HTTP callback for the same events; GET /webhooks lists your registrations and never returns the signing secret. Webhooks require a direct key, not a marketplace one.
4 endpoints on this page. Base URL
diff --git a/docs/reference.html b/docs/reference.html
index 7e0244c..d4aabe0 100644
--- a/docs/reference.html
+++ b/docs/reference.html
@@ -207,7 +207,7 @@
Archive results played **2013–2022** additionally carry a RECONSTRUCTED
point-by-point tape at /history/archive/matches/{archiveId}/tape — the
-score sequence behind the published result, rebuilt from the public record
-after the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022
+score sequence behind the published result: reconstructed point sequences,
+no capture timestamps. 97,901 matches / 14,340,663 rows, seasons **2013–2022
ONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT
-ONE of them has a tape, because there is no public point-by-point record of
-those years to rebuild and we do not manufacture one. Write the range as
+ONE of them has a tape, because we hold no point sequence for those years and
+we do not manufacture one. Write the range as
2013–2022, never as "pre-2023" — the second phrasing reads as 1968 onward
and is wrong by 45 seasons.
Nobody watched those matches, and the data says so: timestamp,
win_probability_p1 and danger are null on EVERY row and cannot be
@@ -616,9 +616,9 @@
Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953 \
-H "Authorization: Bearer twjp_..."
@@ -635,7 +635,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/score \
-H "Authorization: Bearer twjp_..."
@@ -659,7 +659,7 @@ Example
curl https://api.livetennisapi.com/api/public/v1/matc
GET /events
Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)
Plan required: PRO · operationId: listSlateEvents
-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.
+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 API 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
Responses
Response fields
@@ -757,9 +757,9 @@ GET /matches/{matchId}/statistics
DERIVED (the top level of players.pN) are rebuilt from the point-by-point record: service and return games played and won, hold and break percentage, break points faced, saved and converted, service and return points.
-MEASURED (players.pN.measured) are counted upstream, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.
+MEASURED (players.pN.measured) are match counts recorded as the match is played, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.
-Measured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been delivered upstream since 2026-07-12 (measured 2026-08-17).
+Measured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been available since 2026-07-12 (measured 2026-08-17).
freshness.derived and freshness.measured each carry their own coverage (live | final | stale | none | diverged; final = the closing figures of a completed match — a finished match cannot be "stale", so its age_seconds is null), as_of, age_seconds and describes — the match state the numbers describe. On diverged the measured VALUES are withheld and freshness.measured_divergence says why; the top-level coverage only summarises the response. none on both returns 200 with null players, not 404 — the match exists and holding nothing for it is the honest answer.
@@ -774,7 +774,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/statistics \
-H "Authorization: Bearer twjp_..."
@@ -784,9 +784,9 @@ GET /matches/{matchId}/points
Plan required: ULTRA · operationId: getMatchPoints
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.
-seq IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and a live match is fed by more than one upstream at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher seq. Each row is self-consistent — its set, game, number, score, sets and games all describe the point that was played — but reading the tape in seq order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the reconstruction basis. So seq is the right key for paging, dedup and resume (unique, stable, strictly increasing — all after_seq needs) and the wrong key for chronology: sort by (set, game, number) to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. number is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so after_seq never needs a refetch, and the page-level quality field reads revised when the page contains such a re-statement. On the reconstruction basis of a completed match, seq is contiguous 1..N in true match order and the two agree.
+seq IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and points of a live match arrive by more than one path at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher seq. Each row is self-consistent — its set, game, number, score, sets and games all describe the point that was played — but reading the tape in seq order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the reconstruction basis. So seq is the right key for paging, dedup and resume (unique, stable, strictly increasing — all after_seq needs) and the wrong key for chronology: sort by (set, game, number) to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. number is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so after_seq never needs a refetch, and the page-level quality field reads revised when the page contains such a re-statement. On the reconstruction basis of a completed match, seq is contiguous 1..N in true match order and the two agree.
-READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: pbp_coverage: "point" means a per-point stream has DELIVERED for this match — at least one played point past the seq 1 opener; "game" means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to point on the first played point. To admit a match as advancing, gate on sequence > 1 (a seeded match is 1) together with stale: false. 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).
+READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: pbp_coverage: "point" means a per-point stream has DELIVERED for this match — at least one played point past the seq 1 opener; "game" means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to point on the first played point. To admit a match as advancing, gate on sequence > 1 (a seeded match is 1) together with stale: false. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. quality: "revised" means the point record was rewritten over an already-served prefix at least once during this match; served rows are never edited (append-only).
Each row is the state AFTER a played point: score/sets/games (tiebreaks carry the running count in score with games frozen at the pre-breaker score), its position (set/game/number), server (of the next point), the derived winner (null when not attributable to a single point — never guessed), and ts — CAPTURE time, when our pipeline committed the state, because no feed asserts a per-point clock and we fabricate none.
@@ -805,7 +805,7 @@
Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/points \
-H "Authorization: Bearer twjp_..."
@@ -827,7 +827,7 @@ GET /players/{playerId}
It is NOT the archive corpus person id. The results archive (1968–2022) keeps its own person registry, and archive match rows publish those ids as winner.player_id / loser.player_id. The two spaces are disjoint: no corpus id resolves here, and since 2026-09-20 a 404 for one says so and points at /history/archive/players?id={playerId}, which is where that id is read. A 404 with no see field is simply an id we do not hold.
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/players/1104 \
-H "Authorization: Bearer twjp_..."
@@ -867,7 +867,7 @@ GET /tournaments/{tournamentId}
Plan required: FREE · operationId: getTournament
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
-H "Authorization: Bearer twjp_..."
@@ -983,7 +983,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953 \
-H "Authorization: Bearer twjp_..."
@@ -1035,7 +1035,7 @@ Example
curl https://api.livetennisapi.com/api/public/v1/hist
GET /history/archive/matches/{archiveId}/tape
Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)
Plan required: ULTRA · operationId: getArchiveTape
-The score sequence behind a 2013–2022 archive result, REBUILT FROM THE PUBLIC RECORD. The result tells you what happened; this tells you how.
+
The score sequence behind a 2013–2022 archive result: RECONSTRUCTED POINT SEQUENCES, NO CAPTURE TIMESTAMPS. The result tells you what happened; this tells you how.
NOBODY WATCHED THESE MATCHES, and every field that would imply otherwise is null. timestamp is null on every row — not by convention, but because the production table has no timestamp column at all, so no repair, backfill or re-archive can put a wall clock on a 2016 row, and the promotion script refuses to run if such a column ever appears. win_probability_p1 and danger are null on every row: the model was not running in 2016 and nothing has been back-computed and served as if it had been (meta.model_rows and meta.observed_rows are both 0, always). profiles is always []. The rule we publish for the 2023+ tape — a non-null timestamp means we watched that point live — holds across both halves of the product.
@@ -1043,7 +1043,7 @@
GET /history/archive/matches/{archiveId}/t
COVERAGE OF THE ERA, thin spots beside the strong ones: 19.3% of archive matches played 2013–2022 overall, 44.9% of tour-level play. Main-draw tour buckets run 91.6–98.7% (ATP Masters 98.7%, ATP slam 98.0%, WTA Premier Mandatory 97.9%, WTA slam 97.4%, ATP 250–500 95.4%, WTA Premier 94.1%, WTA International 91.6%); ATP Challenger main draws 55.3% and Challenger qualifying 33.6%; slam QUALIFYING is thin at 16.0% (ATP) and 18.1% (WTA); ITF and futures are effectively absent — 25 of 116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25 — and this product does not cover them. If your work is ITF, this is the wrong dataset and we would rather you knew now. 31.0% of the corpus is qualifying-draw play, which is unusual at this scale and is the part you are least likely to hold already.
-HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the vendor DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, seq is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.
+HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the recorded match DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, seq is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.
archiveId is an archive_matches.id (the id on a /history/archive/matches row), NOT a match id — the two are separate, overlapping integer spaces, so passing one to the other's routes resolves a different, real record without erroring. The response names it meta.archive_match_id for exactly that reason. Rows are WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, server: 1 means the winner was serving), unlike the p1/p2-shaped 2023+ tape — a live match has no winner while it is being played.
@@ -1052,7 +1052,7 @@ GET /history/archive/matches/{archiveId}/t
Tier: core ULTRA, or any active History plan (Starter included, which opens it on a FREE core key). The archive RESULT itself (GET /history/archive/matches/{archiveId}) stays on BASIC — core BASIC and core PRO read the result and are refused the tape, and the refusal is 403 upgrade_required carrying capability: archive_tape. The per-year BULK files (?kind=archive_tape) are a separate gate: core ULTRA, or a History Pro/Business subscription, or an active one-off package window.
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/history/archive/matches/{archiveId}/tape \
-H "Authorization: Bearer twjp_..."
@@ -1145,7 +1145,7 @@ GET /rankings
SYSTEM=UTR — observed ratings, honestly bounded. UTR records are observed from UTR's public search: a rating UTR withholds appears as ABSENT, never as 0, and rating is the only populated value — rank and points are always null. Per-player as-of ONLY — there is deliberately no UTR listing, because a table of only the players we happen to track would be a fake leaderboard. Per-player history accumulates from 2026-07-29; scattered earlier single-snapshot observations reach back to 2026-06-01. Coverage is a deliberate bias, not a roster mirror: the 24-hour sweep targets players with no official rank and no Elo rating (so it skews ITF), and among players active in the last 60 days it holds 931 of 5,606 ITF players (16.6%), 197 of 1,903 Challenger (10.4%), 43 of 573 WTA (7.5%) and 15 of 525 ATP (2.9%) — measured 2026-08-17.
-SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so meta.coverage.newest_available states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. rating is the Elo. rank is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. points is always null. matches is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on GET /players/{id}; this is the point-in-time series, the leaderboard and the bulk export. That free rating is sourced differently and sits on a DIFFERENT scale — the two differ by roughly 150 Elo of per-player standard deviation — so never present a rating from one scale against a rating from the other. The Elo listing REQUIRES tour — the ATP and WTA walks are disjoint, so a combined leaderboard would not be comparable — and takes exactly one surface (default overall). elo is never implicit — omitting system returns the official systems only.
+SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so meta.coverage.newest_available states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. rating is the Elo. rank is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. points is always null. matches is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on GET /players/{id}; this is the point-in-time series, the leaderboard and the bulk export. That free rating is computed 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.
Parameters
Responses
Response fields
@@ -1172,7 +1172,7 @@ GET /rally/matches/{rallyMatchId}
One charted match with its points, in play order. Paged with limit/offset; meta.total is the match's full point count.
Parameters
Responses
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/rally/matches/{rallyMatchId} \
-H "Authorization: Bearer twjp_..."
@@ -1189,7 +1189,7 @@ Parameters
-Response fields
+Response fields
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953/rally \
-H "Authorization: Bearer twjp_..."
@@ -1197,7 +1197,7 @@ Example
curl https://api.livetennisapi.com/api/public/v1/hist
GET /charting/players
Career shot-level charting aggregate for one player (ULTRA)
Plan required: ULTRA · operationId: getChartingPlayer
-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. An exact name selects its person even where a longer name contains it (same rule as /h2h), while one name charted under both genders still refuses, which is what gender is for. Every field is a raw SUM over the player's Total rows and matches_charted states the sample. COVERAGE IS CURATED — 11,803 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.
+The deepest serve/return profile we hold: 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. An exact name selects its person even where a longer name contains it (same rule as /h2h), while one name charted under both genders still refuses, which is what gender is for. Every field is a raw SUM over the player's Total rows and matches_charted states the sample. COVERAGE IS CURATED — 11,803 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.
Parameters
Responses
Response fields
@@ -1208,7 +1208,7 @@ Example
curl https://api.livetennisapi.com/api/public/v1/char
GET /charting/matches/{chartingMatchId}
One charted match, every stat family for both players (ULTRA)
Plan required: ULTRA · operationId: getChartingMatch
-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).
+Every charting 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).
Parameters
Responses
Response fields
@@ -1375,11 +1375,11 @@ How far back does history go?
Is there point-by-point data before 2023?
For 2013–2022, yes — reconstructed, not recorded.
GET /history/archive/matches/{archiveId}/tape serves the score sequence
-behind a 2013–2022 archive result, rebuilt from the public record after the
-fact: 97,901 matches and 14,340,663 rows. The floor is hard. The
+behind a 2013–2022 archive result: reconstructed point sequences, no capture
+timestamps. 97,901 matches and 14,340,663 rows. The floor is hard. The
archive holds a further 977,903 results from 1968–2012 and not one of
-them has a tape — there is no public point-by-point record of those years
-to rebuild, and we do not manufacture one.
+them has a tape. We hold no point sequence for those years, and we do not
+manufacture one.
Nobody watched those matches, and the data says so.
timestamp, win_probability_p1 and danger are
null on every row and cannot be filled in later — the production table has no
@@ -1508,14 +1508,14 @@
Score
win_probability_meta.generated_at | string or null | |
win_probability_meta.market_anchored | boolean | The market-prior anchor applies to this match: a two-sided pre-match price and a pre-play first score gave the model a shift to its pre-match prior. Per match, not per row. |
win_probability_meta.anchor_effective | boolean or null | Since 2026-09-15. This row's win_probability_p1 differs from win_probability_p1_model, i.e. the anchor moved this value. Null when either probability is null. |
-timestamp | string or null | Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns sequence), then refreshed — at most every 8 s — while the owning source keeps re-asserting the unchanged state, so on a live read it is usually the last-assertion instant, not the accept instant. Never an upstream observation time, never a source's own clock. NOT an ordering key — use sequence; for the accept instant use accepted_at. |
+timestamp | string or null | Our clock, not the court's: stamped in our process when the state was accepted (the same step that assigns sequence), then refreshed — at most every 8 s — while the owning source keeps re-asserting the unchanged state, so on a live read it is usually the last-assertion instant, not the accept instant. Never an observation time from outside our process. NOT an ordering key — use sequence; for the accept instant use accepted_at. |
accepted_at | string or null | Since 2026-09-15. The instant we accepted this state (UTC): stamped once at insert, never refreshed by a re-assertion — the clock to difference a latency study against. Present on live score reads and push frames, and since 2026-09-24 on history-tape rows too (this description said "absent on history-tape rows" until 2026-09-25, which had been wrong since the tape started carrying it — see HistoryTapeRow.accepted_at). Null on the archive fallback of a retention-trimmed completed match. |
sequence | integer or null | Per-match accept counter for this state: 1, 2, 3, … in the order we accepted states for THIS match, gapless within a match — higher is later, equal is the same state re-served, and a gap between two reads is exactly that many missed updates. On /matches/{matchId}/score it moves BACKWARDS in two cases only: we withdrew a state we cannot stand behind and re-served the last good one, or the read deferred to a strictly higher-trust source's fresh state (about 1% of live reads, measured 2026-09-15). On the push feed and the native WebSocket it only ever rises. Null on states that predate the counter — never fabricated. Present on live score reads AND, since 2026-09-26, on history-tape rows too — see HistoryTapeRow.sequence. This description said "absent on history-tape rows, which are already served in order" until then, and the reasoning was the gap: being served in order does not let you resolve the replaced_by_sequence that GET /matches/{matchId}/withdrawals hands you, which is a pointer AT a counter and not a position in a list. Since 2026-09-24 a set completion that does not end the match is assigned a sequence only once confirmed — the same source keeps presenting the completed set for 10 s or more, a source at least as trusted presents it, or the same source shows play continuing inside the new set. A live scoring display can render the set-ending frame ON set point, before the point is decided, and step back when it is not won; the unconfirmed frame is now never stored, so no sequence ever carries it and nothing is published as provisional. Until a frame is confirmed every surface keeps serving the set-point state; one nobody confirms within 120 s is discarded. A set completion that ENDS the match is exempt — it follows the completion rules instead. Measured over the 30 days to 2026-09-24 this adds a median 10 s between the point and the published set-end, and on a feed that does not re-present states the set-end lands with the first point of the next set (90th percentile 133 s). It does not cover a set genuinely won and then corrected on court, which can still be published and rewound. |
age_seconds | integer or null | Seconds since the score CONTENT last changed (not since we last polled) — it keeps rising between points. Null = change-clock unknown for this row, never "fresh". Always null on the archived final that /matches/{matchId}/score serves for a settled match whose live rows were retired (since 2026-09-20): no clock is claimed for a state nobody watched. |
stale | boolean | Our own staleness verdict on age_seconds for a LIVE match (threshold currently 120s). Always false on a completed match: its last state is final, not stale. |
observed_age_seconds | integer or null | Seconds since ANY of our sources last CONFIRMED this state — the insert itself, the owning source re-asserting it, or an independent source agreeing with it. The "is anyone still watching" clock, next to age_seconds, the "has anything happened" clock: a long age_seconds with a small observed_age_seconds is a changeover or set break, not a stall. Null = unknown: no observation clock stored on the row, or the archive fallback on a retention-trimmed completed match (where age_seconds and sources_count are null too). Absent on history-tape rows. |
sources_count | integer or null | How many DISTINCT sources inserted or independently confirmed this match's state in the last 60 seconds (our public source window). A count, never a name; it counts sources we accepted a state from or that agreed with the stored state, so it can under-count and never over-counts. 1 is ordinary; 0 on a live match past a full minute means nobody we accepted a state from, or who agreed with the stored state, has reported it in the last 60 seconds; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). Present wherever observed_age_seconds is. |
-changing_sources_count | integer or null | Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchanged state is not counted, which is the difference from sources_count: a feed stuck re-sending its last state keeps sources_count at 1 for as long as the echo lasts and keeps this at 0. The window is wider than sources_count's on purpose, because a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below sources_count and never above it. 0 on a live match past five minutes means nobody moved the score in that time; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). A count, never a name. Present wherever sources_count is. |
+changing_sources_count | integer or null | Since 2026-09-24. How many DISTINCT sources CHANGED this match's score in the last 300 seconds — sources that inserted a new state, counted on the content clock alone. A source re-asserting an unchanged state is not counted, which is the difference from sources_count: a source stuck re-sending its last state keeps sources_count at 1 for as long as the echo lasts and keeps this at 0. The window is wider than sources_count's on purpose, because a point lands every 30-60 s and a changeover is about 90 s. A cross-confirmation is agreement, not a change, so this can read below sources_count and never above it. 0 on a live match past five minutes means nobody moved the score in that time; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). A count, never a name. Present wherever sources_count is. |
corroborated | boolean or null | Since 2026-09-24. Has an INDEPENDENT source vouched for THIS EXACT state? True when a source other than the one that wrote this state has since presented us the same state; false when none has, including a brand-new state nobody has had a chance to agree with yet. This is the field to gate a SET-END on. The two counts above are window measures over the match and say nothing about the state in front of you, so a set completion only one source has announced can sit beside a perfectly healthy sources_count — and a set-end is the one state whose correction changes a result rather than a number. EVIDENCE, NOT PROOF: a vouched state can still be corrected, so read corroborated_age_seconds beside it and treat a vouch that is younger than the state it vouches for as the weaker signal it is. When we do take a state back the sequence goes down and verdict says so. Null = unknown (the archive fallback on a retention-trimmed completed match, where age_seconds and sources_count are null too). A yes/no, never a name. Present wherever sources_count is. |
corroborated_age_seconds | integer or null | Since 2026-09-24. Seconds since that independent vouch. Null when corroborated is false (nobody has) or null (unknown). Compare it with age_seconds: a vouch OLDER than the state means somebody agreed with this state after it was published; a vouch younger than the state is one that landed late. |
detail | string | Present only when something is withheld — plain-language reason, e.g. a null games array. |
@@ -1773,7 +1773,7 @@ Player
profile_restated_at | string or null | When the published PROFILE of this player was LAST corrected (UTC), added 2026-10-05 — the player twin of result_restated_at. A correction is a change to a published profile field after it was first published; today that is the name (where the held name was another person's, from 2026-10-06) or the birth date, corrected to the results archive's record of the same person where the two disagreed. Null while the profile stands as first published. Poll GET /players?restated_since= to find corrections. |
profile_version | integer | 1 plus the number of corrections to the published profile (added 2026-10-05), the player twin of result_version. 1 = the profile stands as first published. Record it with the profile you hold and compare on re-read — a higher number means a field you hold was superseded. |
archive_player_id | integer or null | The player's person id in the results archive (added 2026-09-23) — the id /history/archive/players publishes as id and archive match rows carry as winner.player_id / loser.player_id. Scoped by tour (men and women are numbered independently). Set by a tour-scoped link built offline from names and birth dates; null where none exists. Measured 2026-09-23: 35 of 5,914 links (0.6%) pointed at a differently named person; all 35 repaired on 2026-09-24. Confirm with name and birthday where identity matters. |
-data_completeness | object | 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. |
+data_completeness | object | How much biographical detail is known for this player, so a consumer can distinguish "never published" from "not yet filled in" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour. |
data_completeness.known | integer or null | 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. |
data_completeness.of | integer or null | Fields considered. NULL on a doubles team, as for known. |
data_completeness.missing | array of string | Names of the unpopulated fields, e.g. ["backhand", "hand"]. |
@@ -1788,23 +1788,23 @@ Match
@@ -2718,7 +2718,7 @@ RallyMatch