diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a2f6c..3d58c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the specification are recorded here. The API surface is versioned as `v1`. Changes within `v1` are **additive only**; removing a field or changing its type would require `v2`. +## [1.13.75] - 2026-10-28 + +### Changed +- **Fields are described by what they hold.** Rounds, stoppages, per-point tags, statistics and surfaces now say what each value means and when it is null. Earlier entries in this changelog were reworded the same way. The 2013 to 2022 archive tape is described as reconstructed point sequences with no capture timestamps. Its `point_source_detail` value is unchanged. The charting endpoints describe the charted corpus by its coverage. Nothing on the wire changed. + ## [1.13.74] - 2026-10-26 ### Changed @@ -148,7 +153,7 @@ endpoint of the REST API changed. ## [1.13.55] - 2026-09-29 ### Fixed -- **ITF World Tennis Tour doubles began stating `outcome`, and the reference still said no doubles draw carries either field.** The 1.13.42 note measured 454 completed doubles matches and found no tagged row. That was true when it was written. The first doubles row carried a tag on 2026-09-23, and by 2026-09-29 `GET /matches/{id}/points` published `outcome` on **104 of 596** completed ITF doubles matches, 17%, against **0 of 371** for the ATP, WTA and Challenger doubles draws in the same window. The vocabulary is ace and double fault, as on the ITF singles draws. `serve` is still unstated on every doubles draw, measured 0, and that sentence is unchanged. The cause sat upstream of the tagger: a dedup change let a doubles fixture be adopted and oriented by its member sets instead of being refused, so the ITF per-point join reached rows it had never been able to match. 17% falls between "almost none" and "a clear majority", so the description states it as a measured fraction and sends the reader to `enrichment` per match instead of to the draw. WTA main-tour qualifying has carried that treatment since 1.13.42. Found by the every-sweep tag-coverage check. No field, endpoint or behaviour changed. +- **ITF World Tennis Tour doubles began stating `outcome`, and the reference still said no doubles draw carries either field.** The 1.13.42 note measured 454 completed doubles matches and found no tagged row. That was true when it was written. The first doubles row carried a tag on 2026-09-23, and by 2026-09-29 `GET /matches/{id}/points` published `outcome` on **104 of 596** completed ITF doubles matches, 17%, against **0 of 371** for the ATP, WTA and Challenger doubles draws in the same window. The vocabulary is ace and double fault, as on the ITF singles draws. `serve` is still unstated on every doubles draw, measured 0, and that sentence is unchanged. The cause sat before the tagger: a dedup change let a doubles fixture be adopted and oriented by its member sets instead of being refused, so the ITF per-point join reached rows it had never been able to match. 17% falls between "almost none" and "a clear majority", so the description states it as a measured fraction and sends the reader to `enrichment` per match instead of to the draw. WTA main-tour qualifying has carried that treatment since 1.13.42. Found by the every-sweep tag-coverage check. No field, endpoint or behaviour changed. ## [1.13.54] - 2026-09-28 ### Added @@ -164,7 +169,7 @@ endpoint of the REST API changed. ## [1.13.52] - 2026-09-25 ### Added -- **The tape's two accept clocks are published.** `HistoryTapeRow.accepted_at` and `HistoryTapeRow.received_at` have ridden every observed row of `GET /history/matches/{matchId}` since 2026-09-24 and were documented nowhere, so a customer doing timing research on a tape had no published field to read. `accepted_at` is the instant our arbiter accepted the state, stamped once and never refreshed, which is the clock to difference against: `timestamp` starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows `timestamp` is a later re-assertion instant (on match 189679, 164 of its 171 observed rows carry a `timestamp` more than a second after `accepted_at`). `received_at` is the instant the state reached our edge before arbitration, so `accepted_at` minus `received_at` is our own arbitration step. Neither figure is a claim about upstream speed and neither is the court's clock. Both are null on every reconstructed row, which never had a clock. +- **The tape's two accept clocks are published.** `HistoryTapeRow.accepted_at` and `HistoryTapeRow.received_at` have ridden every observed row of `GET /history/matches/{matchId}` since 2026-09-24 and were documented nowhere, so a customer doing timing research on a tape had no published field to read. `accepted_at` is the instant our arbiter accepted the state, stamped once and never refreshed, which is the clock to difference against: `timestamp` starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows `timestamp` is a later re-assertion instant (on match 189679, 164 of its 171 observed rows carry a `timestamp` more than a second after `accepted_at`). `received_at` is the instant the state reached our edge before arbitration, so `accepted_at` minus `received_at` is our own arbitration step. Neither figure is a claim about speed before our edge and neither is the court's clock. Both are null on every reconstructed row, which never had a clock. - **The arrival clock's start date, which is the part that bites.** `received_at` 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`; on every observed row accepted before that instant it is null while `timestamp` and `accepted_at` are populated. A study that spans the boundary has to read `accepted_at`, which rides the whole observed corpus. Measured on match 189679 (12 September) after a Basic customer reported it: 171 observed rows, `accepted_at` on all 171, `received_at` on none. - **What `server` guarantees on a tiebreak row, and what it does not.** 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. Where a breaker's rows skip points the rotation cannot be reconstructed from what is served: you have the server of a state, not of a numbered point, and `meta.points.transitions_legal` against `transitions_total` tells you whether that happened. On the set roll-up row carrying `tiebreak_final`, `server` is the value of that committed set-end state and is NOT a preserved copy of the final breaker point's server. Reported by a Basic customer who could not reconcile 21 tiebreak transitions against the standard rotation and was right not to. @@ -241,12 +246,12 @@ No field, endpoint or behaviour changed; every correction is to a description. - **`schema_version` on the packages listing, and bulk tape rows carry the enriched tape.** Under `schema_version` 2 a `kind=tape` JSONL line is exactly what `GET /history/matches/{matchId}` returns for that match on its default basis, produced by the same code — the same rows with `origin`, `serve`, `outcome` and `winner` beside the score columns, and the full `meta` (`enrichment`, `reconstructed_at`, `observed_span`, `meta.points` where served) — so a model reads its serve states from the bulk file just as it would from the endpoint; the CSV appends `origin`, `serve`, `outcome`, `winner` after `danger`, in that order, and its column order grows only at the end. `1` is the shape every month built before 22 September 2026 carries: score columns only, no `meta.enrichment`, and a CSV that ends at `danger`. Months built before that date keep shape `1` until they are rebuilt — newest month first, then backwards, `built_at` and `sha256` moving as each flips — so check the manifest rather than assuming; a consumer that needs the enriched fields should require `schema_version >= 2` and, for a month still at `1`, read the per-match endpoint for the matches it needs. `null` on the non-tape kinds. ### Changed -- **A completed match on the tours whose own point-by-point console we read is re-joined after the match against the console's complete sequence.** ATP 250/500/1000, ATP Challenger and WTA 1000/500/250/125: the live join paired `serve` / `outcome` onto points as they were captured, so a point the console published after our capture went untagged. A completed match is now re-joined against the complete console sequence by the same exact-state rule, and its serve/outcome coverage exceeds what was captured live. The late tags arrive as `point_update` frames and `tagged_at` revisions like any other — collect them with `?changed_since=`. Nothing is inferred; a point the console never states stays null. +- **A completed match on the tours with a complete per-point record is re-joined after the match against that complete sequence.** ATP 250/500/1000, ATP Challenger and WTA 1000/500/250/125: the live join paired `serve` / `outcome` onto points as they were captured, so a point tagged after our capture went untagged. A completed match is now re-joined against the complete sequence by the same exact-state rule, and its serve/outcome coverage exceeds what was captured live. The late tags arrive as `point_update` frames and `tagged_at` revisions like any other — collect them with `?changed_since=`. Nothing is inferred; a point that is never stated stays null. - **The pre-2023 archive tape no longer claims `point_winner` is null throughout a per-game tape.** On a per-game tape (556 matches, 555 of them 2013) consecutive rows differ by a whole game, and the reference said no point was attributable there. Each row in fact reads the side whose game count rose — the game point was theirs — and says nothing about the game's other points; `winner` reads the same. No field changed shape or value. ## [1.13.39] - 2026-09-22 ### Changed -- **`is_qualifying` states the one case where a round label DOES decide the draw.** The field said it is "never inferred from the round label", which is the right instinct and slightly too strong: main-draw vocabulary (`Semi-finals`, `Final`) is indeed never read that way, but a round the feed itself spells `Qualification Round 1` is the source STATING the draw, and it reads `true` — a `false` beside such a round is a payload contradicting itself rather than asserting main draw. Three matches (Chengdu, 22 Sep) were briefly served `is_qualifying: false` with `round_code: "Q1"` beside them and were repaired the same morning. Readers segregating a corpus by draw need to know which way that conflict resolves. No field changed shape or type. +- **`is_qualifying` states the one case where a round label DOES decide the draw.** The field said it is "never inferred from the round label", which is the right instinct and slightly too strong: main-draw vocabulary (`Semi-finals`, `Final`) is indeed never read that way, but a round label that itself reads `Qualification Round 1` is STATING the draw, and it reads `true` — a `false` beside such a round is a payload contradicting itself rather than asserting main draw. Three matches (Chengdu, 22 Sep) were briefly served `is_qualifying: false` with `round_code: "Q1"` beside them and were repaired the same morning. Readers segregating a corpus by draw need to know which way that conflict resolves. No field changed shape or type. ## [1.13.38] - 2026-09-22 ### Changed @@ -257,11 +262,11 @@ No field, endpoint or behaviour changed; every correction is to a description. - **`is_qualifying` is documented on the match object.** The field has been served on `GET /matches`, `GET /matches/{matchId}` and `GET /history/matches` for some time and was absent from this reference entirely, which left the one field that separates a qualifying draw from the main draw invisible to anyone reading the docs. Three-valued: `true`/`false` are the source's own assertion, `null` means no source has ever stated it, and `null` is not `false`. ### Changed -- **`round` and `round_code` now state that they are DRAW-RELATIVE.** Both describe the round *within* the draw the match belongs to, and the draw is named by `is_qualifying` — not by the round. A qualifying semi-final carries `round: "... - Semi-finals"` and `round_code: SF`, exactly as a main-draw semi-final does; `Q`/`Q1`..`Q4` appear only where the feed itself names the round as qualifying, which most feeds do not. `round_code` previously said "this is the field to branch on" without that limit, and two customers independently read a qualifying match as a main-draw one and reported it as a data fault. The data was correct in every case; the documentation was not. No field changed shape or value. +- **`round` and `round_code` now state that they are DRAW-RELATIVE.** Both describe the round *within* the draw the match belongs to, and the draw is named by `is_qualifying` — not by the round. A qualifying semi-final carries `round: "... - Semi-finals"` and `round_code: SF`, exactly as a main-draw semi-final does; `Q`/`Q1`..`Q4` appear only where the label itself names the round as qualifying, which most labels do not. `round_code` previously said "this is the field to branch on" without that limit, and two customers independently read a qualifying match as a main-draw one and reported it as a data fault. The data was correct in every case; the documentation was not. No field changed shape or value. ## [1.13.36] - 2026-09-21 ### Changed -- **`GET /charting/players` states a sample of 11,803 charted matches, not 11,646.** The charted corpus was refreshed on 2026-09-19 and 184 matches — 133 of them played after 2026-05-24, including the US Open women's draw to the quarter-finals — were loaded into the product on 2026-09-21. `matches_charted` on every response has always been the true per-player denominator and was never affected; the curated-coverage note beside it was quoting an older total and understating the sample. `GET /rally/matches` grew in the same load: 11,822 charted matches and 1,875,132 shot-by-shot points, with the newest women's chart now 2026-09-09. The men's corpus is unchanged because nothing has been charted upstream since 2026-05-21. No field changed shape. +- **`GET /charting/players` states a sample of 11,803 charted matches, not 11,646.** The charted corpus was refreshed on 2026-09-19 and 184 matches — 133 of them played after 2026-05-24, including the US Open women's draw to the quarter-finals — were loaded into the product on 2026-09-21. `matches_charted` on every response has always been the true per-player denominator and was never affected; the curated-coverage note beside it was quoting an older total and understating the sample. `GET /rally/matches` grew in the same load: 11,822 charted matches and 1,875,132 shot-by-shot points, with the newest women's chart now 2026-09-09. The men's corpus is unchanged because no men's match has been charted since 2026-05-21. No field changed shape. ## [1.13.35] - 2026-09-21 ### Added @@ -277,7 +282,7 @@ No field, endpoint or behaviour changed; every correction is to a description. - **A double fault stated on a first serve is a contradiction; the point is served untagged.** A source point whose serve number and outcome contradict each other — a double fault on serve 1 — was carried through as stated. `serve` and `outcome` on such a point now both read `null` on `GET /matches/{matchId}/points` and on the WebSocket frames rather than tagged wrongly; nothing is corrected to a second serve. Stored tags that carried the contradiction have been cleared. ## [1.13.34] - 2026-09-21 ### Changed -- **The reference said Davis Cup and the Grand Slams "read null throughout" for the per-point tags; `serve` is in fact stated on the top Davis Cup tiers.** `serve` and `outcome` are joined from *different* outside sources, and the old sentence excluded both on the strength of one of them: the tour's own per-point console does not carry team ties or Slams, so `outcome` is genuinely `null` there — but the source that states the serve number does carry them. Measured over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group states `serve` on 22 of 22 matches and World Group I on 41 of 42, while World Group II states it on 0 of 38; `outcome` is null on all of them, on every one of the 14,132 rows. The machine-readable authority was right throughout and is unchanged — a World Group I match answers `enrichment: {"serve": "stated", "outcome": "none"}` and a World Group II match answers `{"serve": "none", "outcome": "none"}` — so this corrects the prose to match what the API already returns, in the direction of more coverage, not less. The `serve` description now names the Davis Cup tiers in its coverage list with the measurement; the `outcome` description now separates the two sources instead of excluding a competition from both. No field, endpoint or behaviour change. +- **The reference said Davis Cup and the Grand Slams "read null throughout" for the per-point tags; `serve` is in fact stated on the top Davis Cup tiers.** `serve` and `outcome` are stated separately, and the old sentence excluded both on the strength of one of them: team ties and Slams carry no per-point outcome, so `outcome` is genuinely `null` there — but the serve number is stated on them. Measured over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group states `serve` on 22 of 22 matches and World Group I on 41 of 42, while World Group II states it on 0 of 38; `outcome` is null on all of them, on every one of the 14,132 rows. The machine-readable authority was right throughout and is unchanged — a World Group I match answers `enrichment: {"serve": "stated", "outcome": "none"}` and a World Group II match answers `{"serve": "none", "outcome": "none"}` — so this corrects the prose to match what the API already returns, in the direction of more coverage, not less. The `serve` description now names the Davis Cup tiers in its coverage list with the measurement; the `outcome` description now treats the two fields separately instead of excluding a competition from both. No field, endpoint or behaviour change. ## [1.13.33] - 2026-09-21 ### Changed @@ -294,7 +299,7 @@ No field, endpoint or behaviour changed; every correction is to a description. ## [1.13.30] - 2026-09-20 ### Changed -- **The ATP per-point outcome row said "full" as a projection from the feed's content, not a measurement.** In the 30 days since the tour's own per-point console went live (2026-09-12), no ATP main-tour event was actually played — the only tour tennis in that window was a Grand Slam and Davis Cup, neither of which is on that feed — so every ATP main-tour match carried no outcome tags at all, while the docs asserted `full` (ace, double fault, winner, forced error, unforced error) unconditionally. The row now names the tours the feed covers (ATP 250 / 500 / 1000), states plainly that Grand Slams and Davis Cup are not on it and read null throughout (check `enrichment` per match), and gives the first date the claim was actually measured against a played tour event: the week of 2026-09-22. +- **The ATP per-point outcome row said "full" as a projection, not a measurement.** In the 30 days since ATP per-point outcomes went live (2026-09-12), no ATP main-tour event was actually played — the only tour tennis in that window was a Grand Slam and Davis Cup, neither of which carries per-point outcomes — so every ATP main-tour match carried no outcome tags at all, while the docs asserted `full` (ace, double fault, winner, forced error, unforced error) unconditionally. The row now names the tours covered (ATP 250 / 500 / 1000), states plainly that Grand Slams and Davis Cup are not on it and read null throughout (check `enrichment` per match), and gives the first date the claim was actually measured against a played tour event: the week of 2026-09-22. ## [1.13.29] - 2026-09-20 ### Changed @@ -304,11 +309,11 @@ No field, endpoint or behaviour changed; every correction is to a description. ## [1.13.28] - 2026-09-20 ### Added -- **`meta.points.games_withheld` on `GET /history/matches/{matchId}`.** Present whenever reconstructed rows are in the response: the count of games whose vendor point record was internally inconsistent — a recorded score state that repeats or moves backwards partway through the game — and whose in-game points are therefore 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` forces `meta.points.complete` to `false`. `null` means not measured: either the reconstruction predates this check, or it was built from the multi-vendor union rather than a single vendor's point record. +- **`meta.points.games_withheld` on `GET /history/matches/{matchId}`.** Present whenever reconstructed rows are in the response: the count of games whose recorded points were internally inconsistent — a recorded score state that repeats or moves backwards partway through the game — and whose in-game points are therefore 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` forces `meta.points.complete` to `false`. `null` means not measured: either the reconstruction predates this check, or it was built by merging more than one point record rather than from a single one. - **`tape.points_complete_default`, `tape.points_complete_recon` and `tape.points_basis` on the `GET /history/matches` listing.** `points_complete` on that listing is a best-basis OR: the ledger's default basis is the `clean` collapse of an observed tape, while the per-match read serves and measures the `raw` sequence, so `points_complete: true` can sit next to `meta.points.complete: false` on the same match with both honest about their own sequence. These three fields say which measurement produced the `true` and on which sequence — `points_complete_recon: true` means "fetch it with `?points=complete`". All null when not yet measured. -- **`meta.points.resent_rows` on `GET /history/matches/{matchId}`.** A row identical to the row before it is the same state re-sent by a source on its timer, which the raw tape carries by design — no point separates the two rows, so they no longer count as a non-point transition. `transitions_total` is now `rows − 1 − resent_rows`. `resent_rows` is `0` on a `clean` or `recon` basis: the collapse already removes re-sends, and in a reconstruction one row is one point, so a repeat there is a vendor tear and is never dropped. +- **`meta.points.resent_rows` on `GET /history/matches/{matchId}`.** A row identical to the row before it is the same state re-sent by a source on its timer, which the raw tape carries by design — no point separates the two rows, so they no longer count as a non-point transition. `transitions_total` is now `rows − 1 − resent_rows`. `resent_rows` is `0` on a `clean` or `recon` basis: the collapse already removes re-sends, and in a reconstruction one row is one point, so a repeat there is a tear in the recorded sequence and is never dropped. ### Fixed -- **A vendor's finished-match point record that moved backwards inside a game was being reconstructed as if it were legal.** Since July 2026 one vendor's point-by-point feed has not been monotone within a game (a real example: 40-30, 15-15, 15-30, 30-30, 15-30, 30-30, 30-30, 40-30 — 547 of 586 August ATP/WTA matches carried at least one such game). The tape builder kept the longest forward-moving chain through the inconsistency and published the result as an ordinary reconstructed row, so a state like 30-30 repeating three times in a row was served as fact. Those games are now withheld whole instead — counted in the new `games_withheld` field — rather than fitted into a plausible-looking sequence. +- **A finished-match point record that moved backwards inside a game was being reconstructed as if it were legal.** Since July 2026 some recorded point sequences have not been monotone within a game (a real example: 40-30, 15-15, 15-30, 30-30, 15-30, 30-30, 30-30, 40-30 — 547 of 586 August ATP/WTA matches carried at least one such game). The tape builder kept the longest forward-moving chain through the inconsistency and published the result as an ordinary reconstructed row, so a state like 30-30 repeating three times in a row was served as fact. Those games are now withheld whole instead — counted in the new `games_withheld` field — rather than fitted into a plausible-looking sequence. ## [1.13.27] - 2026-09-20 ### Added @@ -319,15 +324,15 @@ No field, endpoint or behaviour changed; every correction is to a description. ## [1.13.26] - 2026-09-20 ### Fixed -- **Team-competition matches now carry the surface of their tie.** Davis Cup, Billie Jean King Cup and Laver Cup matches published `surface: null` on the match and on `tournament.surface` — 268 of 268 team-event matches in the 60 days to 2026-09-20. 1.13.25 documented that null as structural; it was only structural for the sources we had. Every surface source was per tournament, and the vendor's team "tournament" (`ATP Davis Cup - World Group I`) is a tier whose surface field carries the draw stage, not a surface; a tie is played on whatever its host nation chose. The ITF's own draw feed states the surface, venue and indoor/outdoor flag per tie, so the API now holds one row per tie and resolves each match to its tie by competition, date window and the nations on court — answering only when a single tie remains, never from a nation, a name or a previous tie. Existing team-event matches (last 60 days and upcoming) were backfilled with one audit row per write; new ties are picked up as they are drawn. Ties whose venue the ITF has not announced stay null. `tournament.surface` for a team competition remains null by design: the surface belongs to the tie, and it is published on the match. Raised by a public report that Davis Cup match 191867 returned `surface: null`; that match now serves `hard`. Payload shape unchanged. +- **Team-competition matches now carry the surface of their tie.** Davis Cup, Billie Jean King Cup and Laver Cup matches published `surface: null` on the match and on `tournament.surface` — 268 of 268 team-event matches in the 60 days to 2026-09-20. 1.13.25 documented that null as structural; it was only structural for the data we held. Every surface we held was per tournament, and a team "tournament" (`ATP Davis Cup - World Group I`) is a tier with a draw stage, not a surface; a tie is played on whatever its host nation chose. Each tie's published record states the surface, venue and indoor/outdoor flag, so the API now holds one row per tie and resolves each match to its tie by competition, date window and the nations on court — answering only when a single tie remains, never from a nation, a name or a previous tie. Existing team-event matches (last 60 days and upcoming) were backfilled with one audit row per write; new ties are picked up as they are drawn. Ties whose venue the ITF has not announced stay null. `tournament.surface` for a team competition remains null by design: the surface belongs to the tie, and it is published on the match. Raised by a public report that Davis Cup match 191867 returned `surface: null`; that match now serves `hard`. Payload shape unchanged. ### Changed - **`Match.surface` and `Tournament.surface` descriptions** now state the per-tie rule for team competitions instead of describing the null as permanent. ## [1.13.25] - 2026-09-20 ### Fixed -- **A match that had been given a `tournament_id` was never given the surface that id serves.** `matches.tournament_key` was backfilled on 2026-09-04 for matches discovered through the secondary source, which had been publishing `tournament_id: null`; the pass gave those rows an id and stopped there. The catalogue-surface fallback runs only in the ingest upsert, and every repaired row was already terminal, so it never ran again: 2,533 matches (2026-06-21..2026-09-04, all ITF) published `surface: null` while `GET /tournaments/{id}` — for the id that same row hands you — answered `"surface": "hard"`. Two surfaces for one question, each written by a different writer. Measured and wire-confirmed before the repair on match `186637` (`surface: null`, `tournament_id: "12158"`) against `/tournaments/12158` (`"surface": "hard"`), and after it on the same row. All 2,533 now carry the surface their own tournament states; `indoor` was filled from the same catalogue row where the match held none. Nothing after 2026-09-04 was affected — the same commit's ingest hook sets the key before the fallback reads it, so the forward path has been correct throughout and the weekly count is flat zero since. The repair resolves surface through the same chain the ingest uses (tournament-name map first, catalogue second), so a later upsert cannot answer one of these rows differently. Match-level `surface` null over the previous 90 days: 19.8% before, 11.8% after. No field or endpoint added. +- **A match that had been given a `tournament_id` was never given the surface that id serves.** `matches.tournament_key` was backfilled on 2026-09-04 for matches that had been publishing `tournament_id: null`; the pass gave those rows an id and stopped there. The tournament-surface fallback runs only in the ingest upsert, and every repaired row was already terminal, so it never ran again: 2,533 matches (2026-06-21..2026-09-04, all ITF) published `surface: null` while `GET /tournaments/{id}` — for the id that same row hands you — answered `"surface": "hard"`. Two surfaces for one question, each written by a different writer. Measured and wire-confirmed before the repair on match `186637` (`surface: null`, `tournament_id: "12158"`) against `/tournaments/12158` (`"surface": "hard"`), and after it on the same row. All 2,533 now carry the surface their own tournament states; `indoor` was filled from the same tournament row where the match held none. Nothing after 2026-09-04 was affected — the same commit's ingest hook sets the key before the fallback reads it, so the forward path has been correct throughout and the weekly count is flat zero since. The repair resolves surface through the same chain the ingest uses (tournament-name map first, tournament row second), so a later upsert cannot answer one of these rows differently. Match-level `surface` null over the previous 90 days: 19.8% before, 11.8% after. No field or endpoint added. ### Changed -- **`Match.surface` and `Tournament.surface` now say when they are null and why.** Both were documented as nullable with no account of the population, while the `tournament_id` field beside them carries a measured one. The remaining match-level nulls are two structural groups and an 8-row residue: UTR events, which are not in the tournament catalogue at all (and so publish `tournament_id: null` too), and TEAM COMPETITIONS — Davis Cup, Billie Jean King Cup, Laver Cup — where the surface is the host nation's choice per tie, so neither the season-long tournament row nor the match has one to state. Raised by a public report that Davis Cup matches return `surface: null`. For those 35 catalogue rows the upstream surface field carries the draw tier ("- Preliminary", "- Play Offs", "- Promotion") rather than a surface, and a value outside hard/clay/grass is rejected rather than published. No behaviour change. +- **`Match.surface` and `Tournament.surface` now say when they are null and why.** Both were documented as nullable with no account of the population, while the `tournament_id` field beside them carries a measured one. The remaining match-level nulls are two structural groups and an 8-row residue: UTR events, which are not in our tournament list at all (and so publish `tournament_id: null` too), and TEAM COMPETITIONS — Davis Cup, Billie Jean King Cup, Laver Cup — where the surface is the host nation's choice per tie, so neither the season-long tournament row nor the match has one to state. Raised by a public report that Davis Cup matches return `surface: null`. For those 35 tournament rows a value outside hard/clay/grass is rejected rather than published. No behaviour change. ## [1.13.24] - 2026-09-20 ### Changed @@ -375,7 +380,7 @@ Documentation only. No field, endpoint or wire value changed. ### Added - Native WebSocket **close codes**, documented for the first time. Every refusal sends its `error` frame and then closes with a code that says what to do next — `1013` Try Again Later for transient refusals (`connection_limit:per_key`, `connection_limit:server`, `service_unavailable`), `1008` Policy Violation for anything a retry cannot fix (`unauthorized`, `upgrade_required`, `email_unverified`, `client_blocked`, `bad_json`, `no_topics`, and any mid-stream loss of access), `1012` on a restart, `1000` on a normal close. The close reason repeats the frame's `error` string, so `(code, reason)` is a complete diagnosis even if the frame was missed. Behaviour change the same day: refusals raised during the *handshake* previously closed `1000` with an empty reason, indistinguishable from an orderly shutdown, so a client awaiting its `subscribed` ack saw only a normal close. The error frame was, and still is, delivered before the close; only the close code and reason changed. ### Changed -- `GET /matches/{matchId}/points`, the `point` frame and the `points` signal: **`seq` is arrival order, not match order, on the live basis.** It is assigned in commit order, 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; 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. `seq` remains the right key for paging, dedup and resume, and is the wrong key for chronology — sort by `(set, game, number)` to replay in playing order. The spec previously said "ordered per match by `seq`", which reads as chronological. Documentation only; nothing on the wire changed. +- `GET /matches/{matchId}/points`, the `point` frame and the `points` signal: **`seq` is arrival order, not match order, on the live basis.** It is assigned in commit order, 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; 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. `seq` remains the right key for paging, dedup and resume, and is the wrong key for chronology — sort by `(set, game, number)` to replay in playing order. The spec previously said "ordered per match by `seq`", which reads as chronological. Documentation only; nothing on the wire changed. ## [1.13.16] - 2026-09-18 ### Changed @@ -385,7 +390,7 @@ Documentation only. No field, endpoint or wire value changed. ### Fixed - `event_status` (match object): the enum omitted **`Finished`** and **`Unresolved`**. `Finished` is the value the field carries on a normally-completed match and is by far its most common — 144,266 of the 150,678 rows carrying one, measured on 2026-09-18 — so a client generated from this spec with strict enum validation rejected the majority of completed matches. Both values are listed now. The API has always published them; only the spec was wrong, so nothing on the wire changed. ### Changed -- `event_status` description: "NULL means the match completed normally" was wrong in the direction that matters — null means the feed never stated anything for the match, which covers matches that ran their course and matches nothing was ever said about alike. Branch on `outcome`, not on the absence of a badge. +- `event_status` description: "NULL means the match completed normally" was wrong in the direction that matters — null means nothing was ever stated for the match, which covers matches that ran their course and matches nothing was ever said about alike. Branch on `outcome`, not on the absence of a badge. - `event_status` description: `event_status: Finished` while `status` is still `live` is documented as the pending-final state (one source has called the match over, the final is not confirmed). The score stands still through it — `stale` true, `age_seconds` climbing. 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. ## [1.13.14] - 2026-09-17 @@ -412,7 +417,7 @@ Documentation only. No field, endpoint or wire value changed. ### Added - `Score.accepted_at` (string|null, live score reads and push frames): the instant we accepted this state, stamped once and never refreshed — the clock to difference a latency study against. ### Changed -- `Score.timestamp` documented for what it is: our clock, stamped at acceptance and then refreshed (at most every 8 s) while the owning source keeps re-asserting an unchanged state, so on a live read it is usually the last-assertion instant. It was described as "when this state was true"; it never was. Never an upstream observation time. +- `Score.timestamp` documented for what it is: our clock, stamped at acceptance and then refreshed (at most every 8 s) while the owning source keeps re-asserting an unchanged state, so on a live read it is usually the last-assertion instant. It was described as "when this state was true"; it never was. Never an observation time from outside our process. - The `Score` schema on this site now carries the full field set and descriptions: `sequence`, `age_seconds`, `stale`, `observed_age_seconds`, `sources_count` and `detail` were missing from the published table, and `sets`/`server`/`is_tiebreak`/`win_probability_p1`/`danger`/`timestamp` had no description. ## [1.13.8] - 2026-09-15 @@ -454,25 +459,25 @@ Documentation only. No field, endpoint or wire value changed. ## [1.13.4] - 2026-09-14 ### Added -- ITF World Tennis Tour **qualifying rounds** (singles) are now listed and live-scored from the ITF's own - live scoring: matches appear in `GET /matches` with `round`, `tournament` and `scheduled_time`, receive - live scores while in play and complete on the feed's result. Sundays and Mondays, the qualifying days, - were previously almost empty because the schedule feed carries main draws only. `GET /fixtures` still - mirrors the schedule feed and does not list qualifying. +- ITF World Tennis Tour **qualifying rounds** (singles) are now listed and live-scored: + matches appear in `GET /matches` with `round`, `tournament` and `scheduled_time`, receive + live scores while in play and complete on the official result. Sundays and Mondays, the qualifying days, + were previously almost empty because the published schedule carries main draws only. `GET /fixtures` still + follows that schedule and does not list qualifying. ## [1.13.3] - 2026-09-13 ### Changed -- WTA / WTA 125 stoppages now arrive in two layers. The console's match state is read within +- WTA / WTA 125 stoppages now arrive in two layers. The match state is read within about 20 seconds and yields `trainer_called`, `toilet_break_*` and `stoppage_*` rows (player - null). The console's event feed, which the tour publishes with a variable delay — measured on + null). A later event layer with a variable delay — measured on 2026-09-13 from about one minute to an hour behind play — adds `medical_timeout_start/end` with the player and the exact instants. The - 1.13.1 note implied the event feed was live; it is not. + 1.13.1 note implied the event layer was live; it is not. ## [1.13.2] - 2026-09-13 ### Added -- ATP main-tour stoppages: a public live-score service's match stage is now watched for its +- ATP main-tour stoppages: the live match stage is now watched for its medical-timeout and interruption stages. Those stages exist in the vocabulary but have not yet been observed on a tennis match, so ATP rows are documented as mapped, not yet proven; they carry `player: null` and an `at` equal to the instant the stage was observed. @@ -484,19 +489,19 @@ Documentation only. No field, endpoint or wire value changed. ### Added - Scorer-stated stoppages for **WTA and WTA 125** singles: `medical_timeout_*`, `trainer_called*`, - `toilet_break_*` and `stoppage_*` rows now come from the chair umpire's console for those tours + `toilet_break_*` and `stoppage_*` rows are now published for those tours (measured over 138 finished matches: 27 physio calls, 65 treatment records, 22 suspensions). Same fields, same endpoints, same WebSocket signal. `stoppage_start.reason` gains `heat`, `darkness` and `injury`; every `stoppage_end` carries `reason: "resumed"`. - Scorer-stated stoppages for **ITF World Tennis Tour** singles from the court's live-scoring state: `toilet_break_*`, `medical_timeout_*`, `trainer_called*` and `stoppage_*` rows on entering - and leaving the state, with `duration_seconds`; `player` is null on these rows because the feed - names the state, not the player. + and leaving the state, with `duration_seconds`; `player` is null on these rows because the + live-scoring state names the state, not the player. ### Changed -- Coverage note: WTA, WTA 125, Challenger, UTR and ITF singles are covered; ATP main tour is still - being sourced and is stated as not covered. +- Coverage note: WTA, WTA 125, Challenger, UTR and ITF singles are covered; ATP main tour is not + covered yet and is stated as not covered. ## [1.13.0] - 2026-09-13 @@ -511,17 +516,17 @@ Documentation only. No field, endpoint or wire value changed. ### Changed - Stoppage coverage, corrected: scorer-stated `medical_timeout_*`, `trainer_called*` and `toilet_break_*` rows exist for **Challenger and UTR singles**. The 1.11.0/1.12.0 text said - ATP, WTA and WTA 125 as well; measured over 11 days those tours' scorer feed carries none + ATP, WTA and WTA 125 as well; measured over 11 days those tours' scorer data carries none (0 of 70 finished matches each, against 22 of 70 for Challenger). Main-tour and ITF medical - timeouts are being sourced; the note changes the day they are live. + timeouts are not covered yet; the note changes the day they are live. ## [1.12.0] - 2026-09-12 ### Added - **`serve` and `outcome` on live point rows** (`GET /matches/{matchId}/points`, the `point` frames): the serve the point was played on (1 | 2) and how it ended - (`ace` | `double_fault` | `winner` | `forced_error` | `unforced_error`), as an - outside source states them, joined onto our rows by exact state; `null` when not + (`ace` | `double_fault` | `winner` | `forced_error` | `unforced_error`), as + stated for the point, joined onto our rows by exact state; `null` when not stated. Coverage per match in the new `enrichment` object; per tour in the reference. - **`point_update` frame** on the point opt-in — a point's `serve`/`outcome` landing after its `point` frame; apply by `seq`. @@ -668,9 +673,9 @@ Documentation only. No field, endpoint or wire value changed. ### Clarified - **`Match.tournament_id` null cases** restated with the real causes and a - measured rate: not in the catalogue (UTR), or a secondary-source match whose - name matched no single catalogue edition. The API now resolves the latter at - ingest by name + event type + date against the vendor's own fixtures, and the + measured rate: not in our tournament list (UTR), or a match whose + name matched no single edition in that list. The API now resolves the latter at + ingest by name + event type + date against the published fixtures, and the 2026 backlog was backfilled the same day (2,547 rows); ITF null rate went from about 25% to about 2.5%. No wire change. @@ -727,8 +732,8 @@ Documentation only. No field, endpoint or wire value changed. ### Added - **`GET /history/archive/matches/{archiveId}/tape`** (`getArchiveTape`) — the RECONSTRUCTED 2013–2022 point-by-point tape for one archive result: the score - sequence behind the published result, rebuilt from the public record after - the fact. Shipped to production 2026-09-01; the spec was the last place it + sequence behind the published result: reconstructed point sequences, no + capture timestamps. Shipped to production 2026-09-01; the spec was the last place it was missing. Tier: core ULTRA, **or any active History plan including Starter** (which opens it on a FREE core key). The archive RESULT stays on BASIC, so core BASIC and core PRO read the result and are refused the tape — @@ -791,7 +796,7 @@ Documentation only. No field, endpoint or wire value changed. asserted only for a match we observed being played or whose match-winner market settled decisively; a closed market alone never finishes a match. `cancelled` with `event_status: null` means positive evidence the match - was not played as scheduled (the market settled void) and no vendor word + was not played as scheduled (the market settled void) and no stated reason for why — `outcome` / fixture `reason` stay null rather than guessed, and the row upgrades to a completed walkover if a `Walk Over` with a stated winner lands later. Before 2026-08-23 a void market closure completed the @@ -806,7 +811,7 @@ Documentation only. No field, endpoint or wire value changed. timestamp, right after `event_status`: when `event_status` last CHANGED — the instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk - or the feed did. It is the field to measure our admin-status latency with. + did. It 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 @@ -850,7 +855,7 @@ Documentation only. No field, endpoint or wire value changed. same vocabulary as the new `?draw=` filter, decided by one shared definition, so filter and field cannot disagree. Evidence order: a doubles-team participant proves doubles over any event type; otherwise the - feed's event type decides. Null means neither says anything — no stated + stated event type decides. Null means neither says anything — no stated event type, or 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. `is_doubles` stays for @@ -950,7 +955,7 @@ Documentation only. No field, endpoint or wire value changed. winners/unforced/forced-errors family historically on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and NONE of Challenger, ITF or juniors (24,552 payloads, 2026-07-31) — and that the - upstream feed has not delivered that family since 2026-07-12 (0 of 4,513 + family has not been available since 2026-07-12 (0 of 4,513 August payloads carry it, measured 2026-08-17). - **`errors_total` is documented as the total of FORCED errors**, with the evidence (3,766 payload sides, June–July 2026: equals the per-stroke @@ -1045,7 +1050,7 @@ Documentation only. No field, endpoint or wire value changed. ### Added - **Shot-level charting.** `GET /charting/players` (ULTRA) — career - serve/return profile from the Match Charting Project: serve placement + serve/return profile: 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, rally-length and @@ -1183,7 +1188,7 @@ Documentation only. No field, endpoint or wire value changed. - **In-play match statistics.** `GET /matches/{matchId}/statistics` (ULTRA): two families, deliberately never merged — DERIVED (rebuilt from the point-by-point record: hold/break %, break points, service & return points) - and MEASURED (counted upstream: aces, double faults, the serve split, + and MEASURED (counted during the match: aces, double faults, the serve split, winners/unforced errors). Each family carries its own `coverage`, `as_of` and `age_seconds`; absent measured fields are omitted, never zero-filled; `coverage: none` on both families is a 200 with null `players`, not a 404; diff --git a/docs/auth-quota-and-health.html b/docs/auth-quota-and-health.html index 03f2083..dc9b198 100644 --- a/docs/auth-quota-and-health.html +++ b/docs/auth-quota-and-health.html @@ -202,7 +202,7 @@
Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.74

+

Live Tennis API · docs · version 1.13.75

Tennis API authentication, quota and status

How do you authenticate, and how do you see what quota is left? Send your key as X-API-Key on every request. GET /usage returns your own consumption against your plan's quota and works on every tier including FREE, so a client can check its own headroom before a burst. GET /health is the unauthenticated liveness probe.

2 endpoints on this page. Base URL diff --git a/docs/broadcast-graphics.html b/docs/broadcast-graphics.html index 173b611..20f077f 100644 --- a/docs/broadcast-graphics.html +++ b/docs/broadcast-graphics.html @@ -202,7 +202,7 @@

Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.74

+

Live Tennis API · docs · version 1.13.75

Tennis scores for on-air graphics

How do you put live tennis scores into a broadcast graphics template? GET /broadcast/match/{matchId} returns ONE flat object for one match and GET /broadcast/live the same object for every live match in a single call. Both are ULTRA. A graphics template binds each field to a fixed path, so three things are contractual: the object is flat, every key is present on every read (null, never missing), and the score is already in display form — p1_points reads "40" or "AD" and set_line is one string. Built for 1 Hz: every response carries a strong ETag, so If-None-Match makes an unchanged state a 304 with no body. Detect a new state on sequence, never on served_at, which changes on every read.

2 endpoints on this page. Base URL diff --git a/docs/changelog.html b/docs/changelog.html index dd586c0..cc52594 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -4,7 +4,7 @@ Live Tennis API — Changelog - + @@ -14,7 +14,7 @@ @@ -204,8 +204,13 @@

Live Tennis API · docs

Changelog

-

Every change to the specification, dated and versioned. The API surface is v1; changes within it are additive only. Current version 1.13.74, last change . Also as the full reference, OpenAPI YAML / JSON, and the dated facts file.

+

Every change to the specification, dated and versioned. The API surface is v1; changes within it are additive only. Current version 1.13.75, last change . Also as the full reference, OpenAPI YAML / JSON, and the dated facts file.

+

1.13.75

+

Changed

+
    +
  • Fields are described by what they hold. Rounds, stoppages, per-point tags, statistics and surfaces now say what each value means and when it is null. Earlier entries in this changelog were reworded the same way. The 2013 to 2022 archive tape is described as reconstructed point sequences with no capture timestamps. Its point_source_detail value is unchanged. The charting endpoints describe the charted corpus by its coverage. Nothing on the wire changed.
  • +

1.13.74

Changed

    @@ -331,7 +336,7 @@

    Fixed

    1.13.55

    Fixed

      -
    • ITF World Tennis Tour doubles began stating outcome, and the reference still said no doubles draw carries either field. The 1.13.42 note measured 454 completed doubles matches and found no tagged row. That was true when it was written. The first doubles row carried a tag on 2026-09-23, and by 2026-09-29 GET /matches/{id}/points published outcome on 104 of 596 completed ITF doubles matches, 17%, against 0 of 371 for the ATP, WTA and Challenger doubles draws in the same window. The vocabulary is ace and double fault, as on the ITF singles draws. serve is still unstated on every doubles draw, measured 0, and that sentence is unchanged. The cause sat upstream of the tagger: a dedup change let a doubles fixture be adopted and oriented by its member sets instead of being refused, so the ITF per-point join reached rows it had never been able to match. 17% falls between "almost none" and "a clear majority", so the description states it as a measured fraction and sends the reader to enrichment per match instead of to the draw. WTA main-tour qualifying has carried that treatment since 1.13.42. Found by the every-sweep tag-coverage check. No field, endpoint or behaviour changed.
    • +
    • ITF World Tennis Tour doubles began stating outcome, and the reference still said no doubles draw carries either field. The 1.13.42 note measured 454 completed doubles matches and found no tagged row. That was true when it was written. The first doubles row carried a tag on 2026-09-23, and by 2026-09-29 GET /matches/{id}/points published outcome on 104 of 596 completed ITF doubles matches, 17%, against 0 of 371 for the ATP, WTA and Challenger doubles draws in the same window. The vocabulary is ace and double fault, as on the ITF singles draws. serve is still unstated on every doubles draw, measured 0, and that sentence is unchanged. The cause sat before the tagger: a dedup change let a doubles fixture be adopted and oriented by its member sets instead of being refused, so the ITF per-point join reached rows it had never been able to match. 17% falls between "almost none" and "a clear majority", so the description states it as a measured fraction and sends the reader to enrichment per match instead of to the draw. WTA main-tour qualifying has carried that treatment since 1.13.42. Found by the every-sweep tag-coverage check. No field, endpoint or behaviour changed.

    1.13.54

    Added

    @@ -351,7 +356,7 @@

    Fixed

    1.13.52

    Added

      -
    • The tape's two accept clocks are published. HistoryTapeRow.accepted_at and HistoryTapeRow.received_at have ridden every observed row of GET /history/matches/{matchId} since 2026-09-24 and were documented nowhere, so a customer doing timing research on a tape had no published field to read. accepted_at is the instant our arbiter accepted the state, stamped once and never refreshed, which is the clock to difference against: timestamp starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows timestamp is a later re-assertion instant (on match 189679, 164 of its 171 observed rows carry a timestamp more than a second after accepted_at). received_at is the instant the state reached our edge before arbitration, so accepted_at minus received_at is our own arbitration step. Neither figure is a claim about upstream speed and neither is the court's clock. Both are null on every reconstructed row, which never had a clock.
    • +
    • The tape's two accept clocks are published. HistoryTapeRow.accepted_at and HistoryTapeRow.received_at have ridden every observed row of GET /history/matches/{matchId} since 2026-09-24 and were documented nowhere, so a customer doing timing research on a tape had no published field to read. accepted_at is the instant our arbiter accepted the state, stamped once and never refreshed, which is the clock to difference against: timestamp starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows timestamp is a later re-assertion instant (on match 189679, 164 of its 171 observed rows carry a timestamp more than a second after accepted_at). received_at is the instant the state reached our edge before arbitration, so accepted_at minus received_at is our own arbitration step. Neither figure is a claim about speed before our edge and neither is the court's clock. Both are null on every reconstructed row, which never had a clock.
    • The arrival clock's start date, which is the part that bites. received_at 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; on every observed row accepted before that instant it is null while timestamp and accepted_at are populated. A study that spans the boundary has to read accepted_at, which rides the whole observed corpus. Measured on match 189679 (12 September) after a Basic customer reported it: 171 observed rows, accepted_at on all 171, received_at on none.
    • What server guarantees on a tiebreak row, and what it does not. 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. Where a breaker's rows skip points the rotation cannot be reconstructed from what is served: you have the server of a state, not of a numbered point, and meta.points.transitions_legal against transitions_total tells you whether that happened. On the set roll-up row carrying tiebreak_final, server is the value of that committed set-end state and is NOT a preserved copy of the final breaker point's server. Reported by a Basic customer who could not reconcile 21 tiebreak transitions against the standard rotation and was right not to.
    @@ -438,13 +443,13 @@

    Added

Changed

    -
  • A completed match on the tours whose own point-by-point console we read is re-joined after the match against the console's complete sequence. ATP 250/500/1000, ATP Challenger and WTA 1000/500/250/125: the live join paired serve / outcome onto points as they were captured, so a point the console published after our capture went untagged. A completed match is now re-joined against the complete console sequence by the same exact-state rule, and its serve/outcome coverage exceeds what was captured live. The late tags arrive as point_update frames and tagged_at revisions like any other — collect them with ?changed_since=. Nothing is inferred; a point the console never states stays null.
  • +
  • A completed match on the tours with a complete per-point record is re-joined after the match against that complete sequence. ATP 250/500/1000, ATP Challenger and WTA 1000/500/250/125: the live join paired serve / outcome onto points as they were captured, so a point tagged after our capture went untagged. A completed match is now re-joined against the complete sequence by the same exact-state rule, and its serve/outcome coverage exceeds what was captured live. The late tags arrive as point_update frames and tagged_at revisions like any other — collect them with ?changed_since=. Nothing is inferred; a point that is never stated stays null.
  • The pre-2023 archive tape no longer claims point_winner is null throughout a per-game tape. On a per-game tape (556 matches, 555 of them 2013) consecutive rows differ by a whole game, and the reference said no point was attributable there. Each row in fact reads the side whose game count rose — the game point was theirs — and says nothing about the game's other points; winner reads the same. No field changed shape or value.

1.13.39

Changed

    -
  • is_qualifying states the one case where a round label DOES decide the draw. The field said it is "never inferred from the round label", which is the right instinct and slightly too strong: main-draw vocabulary (Semi-finals, Final) is indeed never read that way, but a round the feed itself spells Qualification Round 1 is the source STATING the draw, and it reads true — a false beside such a round is a payload contradicting itself rather than asserting main draw. Three matches (Chengdu, 22 Sep) were briefly served is_qualifying: false with round_code: "Q1" beside them and were repaired the same morning. Readers segregating a corpus by draw need to know which way that conflict resolves. No field changed shape or type.
  • +
  • is_qualifying states the one case where a round label DOES decide the draw. The field said it is "never inferred from the round label", which is the right instinct and slightly too strong: main-draw vocabulary (Semi-finals, Final) is indeed never read that way, but a round label that itself reads Qualification Round 1 is STATING the draw, and it reads true — a false beside such a round is a payload contradicting itself rather than asserting main draw. Three matches (Chengdu, 22 Sep) were briefly served is_qualifying: false with round_code: "Q1" beside them and were repaired the same morning. Readers segregating a corpus by draw need to know which way that conflict resolves. No field changed shape or type.

1.13.38

Changed

@@ -458,12 +463,12 @@

Added

Changed

    -
  • round and round_code now state that they are DRAW-RELATIVE. Both describe the round *within* the draw the match belongs to, and the draw is named by is_qualifying — not by the round. A qualifying semi-final carries round: "... - Semi-finals" and round_code: SF, exactly as a main-draw semi-final does; Q/Q1..Q4 appear only where the feed itself names the round as qualifying, which most feeds do not. round_code previously said "this is the field to branch on" without that limit, and two customers independently read a qualifying match as a main-draw one and reported it as a data fault. The data was correct in every case; the documentation was not. No field changed shape or value.
  • +
  • round and round_code now state that they are DRAW-RELATIVE. Both describe the round *within* the draw the match belongs to, and the draw is named by is_qualifying — not by the round. A qualifying semi-final carries round: "... - Semi-finals" and round_code: SF, exactly as a main-draw semi-final does; Q/Q1..Q4 appear only where the label itself names the round as qualifying, which most labels do not. round_code previously said "this is the field to branch on" without that limit, and two customers independently read a qualifying match as a main-draw one and reported it as a data fault. The data was correct in every case; the documentation was not. No field changed shape or value.

1.13.36

Changed

    -
  • GET /charting/players states a sample of 11,803 charted matches, not 11,646. The charted corpus was refreshed on 2026-09-19 and 184 matches — 133 of them played after 2026-05-24, including the US Open women's draw to the quarter-finals — were loaded into the product on 2026-09-21. matches_charted on every response has always been the true per-player denominator and was never affected; the curated-coverage note beside it was quoting an older total and understating the sample. GET /rally/matches grew in the same load: 11,822 charted matches and 1,875,132 shot-by-shot points, with the newest women's chart now 2026-09-09. The men's corpus is unchanged because nothing has been charted upstream since 2026-05-21. No field changed shape.
  • +
  • GET /charting/players states a sample of 11,803 charted matches, not 11,646. The charted corpus was refreshed on 2026-09-19 and 184 matches — 133 of them played after 2026-05-24, including the US Open women's draw to the quarter-finals — were loaded into the product on 2026-09-21. matches_charted on every response has always been the true per-player denominator and was never affected; the curated-coverage note beside it was quoting an older total and understating the sample. GET /rally/matches grew in the same load: 11,822 charted matches and 1,875,132 shot-by-shot points, with the newest women's chart now 2026-09-09. The men's corpus is unchanged because no men's match has been charted since 2026-05-21. No field changed shape.

1.13.35

Added

@@ -484,7 +489,7 @@

Fixed

1.13.34

Changed

    -
  • The reference said Davis Cup and the Grand Slams "read null throughout" for the per-point tags; serve is in fact stated on the top Davis Cup tiers. serve and outcome are joined from *different* outside sources, and the old sentence excluded both on the strength of one of them: the tour's own per-point console does not carry team ties or Slams, so outcome is genuinely null there — but the source that states the serve number does carry them. Measured over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group states serve on 22 of 22 matches and World Group I on 41 of 42, while World Group II states it on 0 of 38; outcome is null on all of them, on every one of the 14,132 rows. The machine-readable authority was right throughout and is unchanged — a World Group I match answers enrichment: {"serve": "stated", "outcome": "none"} and a World Group II match answers {"serve": "none", "outcome": "none"} — so this corrects the prose to match what the API already returns, in the direction of more coverage, not less. The serve description now names the Davis Cup tiers in its coverage list with the measurement; the outcome description now separates the two sources instead of excluding a competition from both. No field, endpoint or behaviour change.
  • +
  • The reference said Davis Cup and the Grand Slams "read null throughout" for the per-point tags; serve is in fact stated on the top Davis Cup tiers. serve and outcome are stated separately, and the old sentence excluded both on the strength of one of them: team ties and Slams carry no per-point outcome, so outcome is genuinely null there — but the serve number is stated on them. Measured over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group states serve on 22 of 22 matches and World Group I on 41 of 42, while World Group II states it on 0 of 38; outcome is null on all of them, on every one of the 14,132 rows. The machine-readable authority was right throughout and is unchanged — a World Group I match answers enrichment: {"serve": "stated", "outcome": "none"} and a World Group II match answers {"serve": "none", "outcome": "none"} — so this corrects the prose to match what the API already returns, in the direction of more coverage, not less. The serve description now names the Davis Cup tiers in its coverage list with the measurement; the outcome description now treats the two fields separately instead of excluding a competition from both. No field, endpoint or behaviour change.

1.13.33

Changed

@@ -505,7 +510,7 @@

Added

1.13.30

Changed

    -
  • The ATP per-point outcome row said "full" as a projection from the feed's content, not a measurement. In the 30 days since the tour's own per-point console went live (2026-09-12), no ATP main-tour event was actually played — the only tour tennis in that window was a Grand Slam and Davis Cup, neither of which is on that feed — so every ATP main-tour match carried no outcome tags at all, while the docs asserted full (ace, double fault, winner, forced error, unforced error) unconditionally. The row now names the tours the feed covers (ATP 250 / 500 / 1000), states plainly that Grand Slams and Davis Cup are not on it and read null throughout (check enrichment per match), and gives the first date the claim was actually measured against a played tour event: the week of 2026-09-22.
  • +
  • The ATP per-point outcome row said "full" as a projection, not a measurement. In the 30 days since ATP per-point outcomes went live (2026-09-12), no ATP main-tour event was actually played — the only tour tennis in that window was a Grand Slam and Davis Cup, neither of which carries per-point outcomes — so every ATP main-tour match carried no outcome tags at all, while the docs asserted full (ace, double fault, winner, forced error, unforced error) unconditionally. The row now names the tours covered (ATP 250 / 500 / 1000), states plainly that Grand Slams and Davis Cup are not on it and read null throughout (check enrichment per match), and gives the first date the claim was actually measured against a played tour event: the week of 2026-09-22.

1.13.29

Changed

@@ -519,13 +524,13 @@

Fixed

1.13.28

Added

    -
  • meta.points.games_withheld on GET /history/matches/{matchId}. Present whenever reconstructed rows are in the response: the count of games whose vendor point record was internally inconsistent — a recorded score state that repeats or moves backwards partway through the game — and whose in-game points are therefore 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 forces meta.points.complete to false. null means not measured: either the reconstruction predates this check, or it was built from the multi-vendor union rather than a single vendor's point record.
  • +
  • meta.points.games_withheld on GET /history/matches/{matchId}. Present whenever reconstructed rows are in the response: the count of games whose recorded points were internally inconsistent — a recorded score state that repeats or moves backwards partway through the game — and whose in-game points are therefore 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 forces meta.points.complete to false. null means not measured: either the reconstruction predates this check, or it was built by merging more than one point record rather than from a single one.
  • tape.points_complete_default, tape.points_complete_recon and tape.points_basis on the GET /history/matches listing. points_complete on that listing is a best-basis OR: the ledger's default basis is the clean collapse of an observed tape, while the per-match read serves and measures the raw sequence, so points_complete: true can sit next to meta.points.complete: false on the same match with both honest about their own sequence. These three fields say which measurement produced the true and on which sequence — points_complete_recon: true means "fetch it with ?points=complete". All null when not yet measured.
  • -
  • meta.points.resent_rows on GET /history/matches/{matchId}. A row identical to the row before it is the same state re-sent by a source on its timer, which the raw tape carries by design — no point separates the two rows, so they no longer count as a non-point transition. transitions_total is now rows − 1 − resent_rows. resent_rows is 0 on a clean or recon basis: the collapse already removes re-sends, and in a reconstruction one row is one point, so a repeat there is a vendor tear and is never dropped.
  • +
  • meta.points.resent_rows on GET /history/matches/{matchId}. A row identical to the row before it is the same state re-sent by a source on its timer, which the raw tape carries by design — no point separates the two rows, so they no longer count as a non-point transition. transitions_total is now rows − 1 − resent_rows. resent_rows is 0 on a clean or recon basis: the collapse already removes re-sends, and in a reconstruction one row is one point, so a repeat there is a tear in the recorded sequence and is never dropped.

Fixed

    -
  • A vendor's finished-match point record that moved backwards inside a game was being reconstructed as if it were legal. Since July 2026 one vendor's point-by-point feed has not been monotone within a game (a real example: 40-30, 15-15, 15-30, 30-30, 15-30, 30-30, 30-30, 40-30 — 547 of 586 August ATP/WTA matches carried at least one such game). The tape builder kept the longest forward-moving chain through the inconsistency and published the result as an ordinary reconstructed row, so a state like 30-30 repeating three times in a row was served as fact. Those games are now withheld whole instead — counted in the new games_withheld field — rather than fitted into a plausible-looking sequence.
  • +
  • A finished-match point record that moved backwards inside a game was being reconstructed as if it were legal. Since July 2026 some recorded point sequences have not been monotone within a game (a real example: 40-30, 15-15, 15-30, 30-30, 15-30, 30-30, 30-30, 40-30 — 547 of 586 August ATP/WTA matches carried at least one such game). The tape builder kept the longest forward-moving chain through the inconsistency and published the result as an ordinary reconstructed row, so a state like 30-30 repeating three times in a row was served as fact. Those games are now withheld whole instead — counted in the new games_withheld field — rather than fitted into a plausible-looking sequence.

1.13.27

Added

@@ -540,7 +545,7 @@

Changed

1.13.26

Fixed

    -
  • Team-competition matches now carry the surface of their tie. Davis Cup, Billie Jean King Cup and Laver Cup matches published surface: null on the match and on tournament.surface — 268 of 268 team-event matches in the 60 days to 2026-09-20. 1.13.25 documented that null as structural; it was only structural for the sources we had. Every surface source was per tournament, and the vendor's team "tournament" (ATP Davis Cup - World Group I) is a tier whose surface field carries the draw stage, not a surface; a tie is played on whatever its host nation chose. The ITF's own draw feed states the surface, venue and indoor/outdoor flag per tie, so the API now holds one row per tie and resolves each match to its tie by competition, date window and the nations on court — answering only when a single tie remains, never from a nation, a name or a previous tie. Existing team-event matches (last 60 days and upcoming) were backfilled with one audit row per write; new ties are picked up as they are drawn. Ties whose venue the ITF has not announced stay null. tournament.surface for a team competition remains null by design: the surface belongs to the tie, and it is published on the match. Raised by a public report that Davis Cup match 191867 returned surface: null; that match now serves hard. Payload shape unchanged.
  • +
  • Team-competition matches now carry the surface of their tie. Davis Cup, Billie Jean King Cup and Laver Cup matches published surface: null on the match and on tournament.surface — 268 of 268 team-event matches in the 60 days to 2026-09-20. 1.13.25 documented that null as structural; it was only structural for the data we held. Every surface we held was per tournament, and a team "tournament" (ATP Davis Cup - World Group I) is a tier with a draw stage, not a surface; a tie is played on whatever its host nation chose. Each tie's published record states the surface, venue and indoor/outdoor flag, so the API now holds one row per tie and resolves each match to its tie by competition, date window and the nations on court — answering only when a single tie remains, never from a nation, a name or a previous tie. Existing team-event matches (last 60 days and upcoming) were backfilled with one audit row per write; new ties are picked up as they are drawn. Ties whose venue the ITF has not announced stay null. tournament.surface for a team competition remains null by design: the surface belongs to the tie, and it is published on the match. Raised by a public report that Davis Cup match 191867 returned surface: null; that match now serves hard. Payload shape unchanged.

Changed

    @@ -549,11 +554,11 @@

    Changed

    1.13.25

    Fixed

      -
    • A match that had been given a tournament_id was never given the surface that id serves. matches.tournament_key was backfilled on 2026-09-04 for matches discovered through the secondary source, which had been publishing tournament_id: null; the pass gave those rows an id and stopped there. The catalogue-surface fallback runs only in the ingest upsert, and every repaired row was already terminal, so it never ran again: 2,533 matches (2026-06-21..2026-09-04, all ITF) published surface: null while GET /tournaments/{id} — for the id that same row hands you — answered "surface": "hard". Two surfaces for one question, each written by a different writer. Measured and wire-confirmed before the repair on match 186637 (surface: null, tournament_id: "12158") against /tournaments/12158 ("surface": "hard"), and after it on the same row. All 2,533 now carry the surface their own tournament states; indoor was filled from the same catalogue row where the match held none. Nothing after 2026-09-04 was affected — the same commit's ingest hook sets the key before the fallback reads it, so the forward path has been correct throughout and the weekly count is flat zero since. The repair resolves surface through the same chain the ingest uses (tournament-name map first, catalogue second), so a later upsert cannot answer one of these rows differently. Match-level surface null over the previous 90 days: 19.8% before, 11.8% after. No field or endpoint added.
    • +
    • A match that had been given a tournament_id was never given the surface that id serves. matches.tournament_key was backfilled on 2026-09-04 for matches that had been publishing tournament_id: null; the pass gave those rows an id and stopped there. The tournament-surface fallback runs only in the ingest upsert, and every repaired row was already terminal, so it never ran again: 2,533 matches (2026-06-21..2026-09-04, all ITF) published surface: null while GET /tournaments/{id} — for the id that same row hands you — answered "surface": "hard". Two surfaces for one question, each written by a different writer. Measured and wire-confirmed before the repair on match 186637 (surface: null, tournament_id: "12158") against /tournaments/12158 ("surface": "hard"), and after it on the same row. All 2,533 now carry the surface their own tournament states; indoor was filled from the same tournament row where the match held none. Nothing after 2026-09-04 was affected — the same commit's ingest hook sets the key before the fallback reads it, so the forward path has been correct throughout and the weekly count is flat zero since. The repair resolves surface through the same chain the ingest uses (tournament-name map first, tournament row second), so a later upsert cannot answer one of these rows differently. Match-level surface null over the previous 90 days: 19.8% before, 11.8% after. No field or endpoint added.

    Changed

      -
    • Match.surface and Tournament.surface now say when they are null and why. Both were documented as nullable with no account of the population, while the tournament_id field beside them carries a measured one. The remaining match-level nulls are two structural groups and an 8-row residue: UTR events, which are not in the tournament catalogue at all (and so publish tournament_id: null too), and TEAM COMPETITIONS — Davis Cup, Billie Jean King Cup, Laver Cup — where the surface is the host nation's choice per tie, so neither the season-long tournament row nor the match has one to state. Raised by a public report that Davis Cup matches return surface: null. For those 35 catalogue rows the upstream surface field carries the draw tier ("- Preliminary", "- Play Offs", "- Promotion") rather than a surface, and a value outside hard/clay/grass is rejected rather than published. No behaviour change.
    • +
    • Match.surface and Tournament.surface now say when they are null and why. Both were documented as nullable with no account of the population, while the tournament_id field beside them carries a measured one. The remaining match-level nulls are two structural groups and an 8-row residue: UTR events, which are not in our tournament list at all (and so publish tournament_id: null too), and TEAM COMPETITIONS — Davis Cup, Billie Jean King Cup, Laver Cup — where the surface is the host nation's choice per tie, so neither the season-long tournament row nor the match has one to state. Raised by a public report that Davis Cup matches return surface: null. For those 35 tournament rows a value outside hard/clay/grass is rejected rather than published. No behaviour change.

    1.13.24

    Changed

    @@ -616,7 +621,7 @@

    Added

Changed

    -
  • GET /matches/{matchId}/points, the point frame and the points signal: seq is arrival order, not match order, on the live basis. It is assigned in commit order, 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; 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. seq remains the right key for paging, dedup and resume, and is the wrong key for chronology — sort by (set, game, number) to replay in playing order. The spec previously said "ordered per match by seq", which reads as chronological. Documentation only; nothing on the wire changed.
  • +
  • GET /matches/{matchId}/points, the point frame and the points signal: seq is arrival order, not match order, on the live basis. It is assigned in commit order, 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; 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. seq remains the right key for paging, dedup and resume, and is the wrong key for chronology — sort by (set, game, number) to replay in playing order. The spec previously said "ordered per match by seq", which reads as chronological. Documentation only; nothing on the wire changed.

1.13.16

Changed

@@ -630,7 +635,7 @@

Fixed

Changed

    -
  • event_status description: "NULL means the match completed normally" was wrong in the direction that matters — null means the feed never stated anything for the match, which covers matches that ran their course and matches nothing was ever said about alike. Branch on outcome, not on the absence of a badge.
  • +
  • event_status description: "NULL means the match completed normally" was wrong in the direction that matters — null means nothing was ever stated for the match, which covers matches that ran their course and matches nothing was ever said about alike. Branch on outcome, not on the absence of a badge.
  • event_status description: event_status: Finished while status is still live is documented as the pending-final state (one source has called the match over, the final is not confirmed). The score stands still through it — stale true, age_seconds climbing. 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.

1.13.14

@@ -665,7 +670,7 @@

Added

Changed

    -
  • Score.timestamp documented for what it is: our clock, stamped at acceptance and then refreshed (at most every 8 s) while the owning source keeps re-asserting an unchanged state, so on a live read it is usually the last-assertion instant. It was described as "when this state was true"; it never was. Never an upstream observation time.
  • +
  • Score.timestamp documented for what it is: our clock, stamped at acceptance and then refreshed (at most every 8 s) while the owning source keeps re-asserting an unchanged state, so on a live read it is usually the last-assertion instant. It was described as "when this state was true"; it never was. Never an observation time from outside our process.
  • The Score schema on this site now carries the full field set and descriptions: sequence, age_seconds, stale, observed_age_seconds, sources_count and detail were missing from the published table, and sets/server/is_tiebreak/win_probability_p1/danger/timestamp had no description.

1.13.8

@@ -701,17 +706,17 @@

Changed

1.13.4

Added

    -
  • ITF World Tennis Tour qualifying rounds (singles) are now listed and live-scored from the ITF's own live scoring: matches appear in GET /matches with round, tournament and scheduled_time, receive live scores while in play and complete on the feed's result. Sundays and Mondays, the qualifying days, were previously almost empty because the schedule feed carries main draws only. GET /fixtures still mirrors the schedule feed and does not list qualifying.
  • +
  • ITF World Tennis Tour qualifying rounds (singles) are now listed and live-scored: matches appear in GET /matches with round, tournament and scheduled_time, receive live scores while in play and complete on the official result. Sundays and Mondays, the qualifying days, were previously almost empty because the published schedule carries main draws only. GET /fixtures still follows that schedule and does not list qualifying.

1.13.3

Changed

    -
  • WTA / WTA 125 stoppages now arrive in two layers. The console's match state is read within about 20 seconds and yields trainer_called, toilet_break_* and stoppage_* rows (player null). The console's event feed, which the tour publishes with a variable delay — measured on 2026-09-13 from about one minute to an hour behind play — adds medical_timeout_start/end with the player and the exact instants. The 1.13.1 note implied the event feed was live; it is not.
  • +
  • WTA / WTA 125 stoppages now arrive in two layers. The match state is read within about 20 seconds and yields trainer_called, toilet_break_* and stoppage_* rows (player null). A later event layer with a variable delay — measured on 2026-09-13 from about one minute to an hour behind play — adds medical_timeout_start/end with the player and the exact instants. The 1.13.1 note implied the event layer was live; it is not.

1.13.2

Added

    -
  • ATP main-tour stoppages: a public live-score service's match stage is now watched for its medical-timeout and interruption stages. Those stages exist in the vocabulary but have not yet been observed on a tennis match, so ATP rows are documented as mapped, not yet proven; they carry player: null and an at equal to the instant the stage was observed.
  • +
  • ATP main-tour stoppages: the live match stage is now watched for its medical-timeout and interruption stages. Those stages exist in the vocabulary but have not yet been observed on a tennis match, so ATP rows are documented as mapped, not yet proven; they carry player: null and an at equal to the instant the stage was observed.

Changed

    @@ -720,14 +725,14 @@

    Changed

    1.13.1

    Added

      -
    • Scorer-stated stoppages for WTA and WTA 125 singles: medical_timeout_*, trainer_called*, toilet_break_* and stoppage_* rows now come from the chair umpire's console for those tours (measured over 138 finished matches: 27 physio calls, 65 treatment records, 22 suspensions). Same fields, same endpoints, same WebSocket signal. stoppage_start.reason gains heat, darkness and injury; every stoppage_end carries reason: "resumed".
    • +
    • Scorer-stated stoppages for WTA and WTA 125 singles: medical_timeout_*, trainer_called*, toilet_break_* and stoppage_* rows are now published for those tours (measured over 138 finished matches: 27 physio calls, 65 treatment records, 22 suspensions). Same fields, same endpoints, same WebSocket signal. stoppage_start.reason gains heat, darkness and injury; every stoppage_end carries reason: "resumed".
      -
    • Scorer-stated stoppages for ITF World Tennis Tour singles from the court's live-scoring state: toilet_break_*, medical_timeout_*, trainer_called* and stoppage_* rows on entering and leaving the state, with duration_seconds; player is null on these rows because the feed names the state, not the player.
    • +
    • Scorer-stated stoppages for ITF World Tennis Tour singles from the court's live-scoring state: toilet_break_*, medical_timeout_*, trainer_called* and stoppage_* rows on entering and leaving the state, with duration_seconds; player is null on these rows because the live-scoring state names the state, not the player.

    Changed

      -
    • Coverage note: WTA, WTA 125, Challenger, UTR and ITF singles are covered; ATP main tour is still being sourced and is stated as not covered.
    • +
    • Coverage note: WTA, WTA 125, Challenger, UTR and ITF singles are covered; ATP main tour is not covered yet and is stated as not covered.

    1.13.0

    Added

    @@ -737,12 +742,12 @@

    Added

Changed

    -
  • Stoppage coverage, corrected: scorer-stated medical_timeout_*, trainer_called* and toilet_break_* rows exist for Challenger and UTR singles. The 1.11.0/1.12.0 text said ATP, WTA and WTA 125 as well; measured over 11 days those tours' scorer feed carries none (0 of 70 finished matches each, against 22 of 70 for Challenger). Main-tour and ITF medical timeouts are being sourced; the note changes the day they are live.
  • +
  • Stoppage coverage, corrected: scorer-stated medical_timeout_*, trainer_called* and toilet_break_* rows exist for Challenger and UTR singles. The 1.11.0/1.12.0 text said ATP, WTA and WTA 125 as well; measured over 11 days those tours' scorer data carries none (0 of 70 finished matches each, against 22 of 70 for Challenger). Main-tour and ITF medical timeouts are not covered yet; the note changes the day they are live.

1.12.0

Added

    -
  • serve and outcome on live point rows (GET /matches/{matchId}/points, the point frames): the serve the point was played on (1 | 2) and how it ended (ace | double_fault | winner | forced_error | unforced_error), as an outside source states them, joined onto our rows by exact state; null when not stated. Coverage per match in the new enrichment object; per tour in the reference.
  • +
  • serve and outcome on live point rows (GET /matches/{matchId}/points, the point frames): the serve the point was played on (1 | 2) and how it ended (ace | double_fault | winner | forced_error | unforced_error), as stated for the point, joined onto our rows by exact state; null when not stated. Coverage per match in the new enrichment object; per tour in the reference.
  • point_update frame on the point opt-in — a point's serve/outcome landing after its point frame; apply by seq. Asked by an ULTRA customer trading on serve outcome.
  • published_at on every data frame of the native WebSocket and the push feed: the UTC instant (ms) the frame left our process — the third clock next to the state's timestamp and a point's ts, so processing and transport latency can be separated. Asked by a prospect running a latency benchmark.
@@ -822,7 +827,7 @@

Clarified

## [1.9.3] — 2026-09-04

Clarified

    -
  • Match.tournament_id null cases restated with the real causes and a measured rate: not in the catalogue (UTR), or a secondary-source match whose name matched no single catalogue edition. The API now resolves the latter at ingest by name + event type + date against the vendor's own fixtures, and the 2026 backlog was backfilled the same day (2,547 rows); ITF null rate went from about 25% to about 2.5%. No wire change.
  • +
  • Match.tournament_id null cases restated with the real causes and a measured rate: not in our tournament list (UTR), or a match whose name matched no single edition in that list. The API now resolves the latter at ingest by name + event type + date against the published fixtures, and the 2026 backlog was backfilled the same day (2,547 rows); ITF null rate went from about 25% to about 2.5%. No wire change.

## [1.9.2] — 2026-09-04

Added (spec catch-up — every one of these has been live on the API; the document lagged)

@@ -849,7 +854,7 @@

Added

## [1.8.0] — 2026-09-01

Added

    -
  • GET /history/archive/matches/{archiveId}/tape (getArchiveTape) — the RECONSTRUCTED 2013–2022 point-by-point tape for one archive result: the score sequence behind the published result, rebuilt from the public record after the fact. Shipped to production 2026-09-01; the spec was the last place it was missing. Tier: core ULTRA, **or any active History plan including Starter** (which opens it on a FREE core key). The archive RESULT stays on BASIC, so core BASIC and core PRO read the result and are refused the tape — 403 upgrade_required carrying capability: archive_tape.
  • +
  • GET /history/archive/matches/{archiveId}/tape (getArchiveTape) — the RECONSTRUCTED 2013–2022 point-by-point tape for one archive result: the score sequence behind the published result: reconstructed point sequences, no capture timestamps. Shipped to production 2026-09-01; the spec was the last place it was missing. Tier: core ULTRA, **or any active History plan including Starter** (which opens it on a FREE core key). The archive RESULT stays on BASIC, so core BASIC and core PRO read the result and are refused the tape — 403 upgrade_required carrying capability: archive_tape.
  • ArchiveTape schema. Same envelope as HistoryTape so one parser reads both halves of the tape product, with the differences that are true: match is the winner/loser-shaped archive row (rows are WINNER-FIRST, not p1/p2), profiles is always [], and meta carries archive_match_id rather than match_id — an archive id is not a match id, and passing one to the other's routes resolves a different, real record without erroring. Rows are HistoryTapeRow, reused unchanged.
  • **kind=archive_tape on /history/packages and /history/packages/{period}, and on HistoryPackage.kind.** Ten per-year bulk files, period 2013 through 2022, JSONL + CSV, all ready. The JSONL record is byte-for-byte what the per-match endpoint returns; the CSV is the flat per-row view keyed on archive_match_id and deliberately carries no timestamp, win_probability_p1 or danger column. A SEPARATE gate from the per-match tape: ULTRA, a History Pro/Business subscription, or an active one-off package window — a Starter grant reads tapes one at a time and does not download years of them. Core PRO carries neither gate.
@@ -865,13 +870,13 @@

Changed

## [1.7.2] — 2026-08-23

Changed

    -
  • status on Match now documents the lifecycle rule. completed is asserted only for a match we observed being played or whose match-winner market settled decisively; a closed market alone never finishes a match. cancelled with event_status: null means positive evidence the match was not played as scheduled (the market settled void) and no vendor word for why — outcome / fixture reason stay null rather than guessed, and the row upgrades to a completed walkover if a Walk Over with a stated winner lands later. Before 2026-08-23 a void market closure completed the match and stamped Finished; those rows were corrected. No field added or changed type — prose only.
  • +
  • status on Match now documents the lifecycle rule. completed is asserted only for a match we observed being played or whose match-winner market settled decisively; a closed market alone never finishes a match. cancelled with event_status: null means positive evidence the match was not played as scheduled (the market settled void) and no stated reason for why — outcome / fixture reason stay null rather than guessed, and the row upgrades to a completed walkover if a Walk Over with a stated winner lands later. Before 2026-08-23 a void market closure completed the match and stamped Finished; those rows were corrected. No field added or changed type — prose only.
  • info.version is now 1.7.2.

## [1.7.1] — 2026-08-19

Added

    -
  • event_status_updated_at on Match. Nullable ISO-8601 UTC (Z) timestamp, right after event_status: when event_status last CHANGED — the instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did. It 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. Inherited by every schema built on Match (MatchDetail, HistoryMatch). Additive only — no existing field moved or changed type.
  • +
  • event_status_updated_at on Match. Nullable ISO-8601 UTC (Z) timestamp, right after event_status: when event_status last CHANGED — the instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk did. It 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. Inherited by every schema built on Match (MatchDetail, HistoryMatch). Additive only — no existing field moved or changed type.

Changed

    @@ -889,7 +894,7 @@

    Changed

    ## [1.6.0] — 2026-08-18

    Added

      -
    • The three-valued draw field on Match. singles | doubles | null — same vocabulary as the new ?draw= filter, decided by one shared definition, so filter and field cannot disagree. Evidence order: a doubles-team participant proves doubles over any event type; otherwise the feed's event type decides. Null means neither says anything — no stated event type, or 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. is_doubles stays for compatibility and is now documented as LOSSY, with its evidence order: false also covers "unknown", which is not a claim of singles — prefer draw, whose null says so honestly.
    • +
    • The three-valued draw field on Match. singles | doubles | null — same vocabulary as the new ?draw= filter, decided by one shared definition, so filter and field cannot disagree. Evidence order: a doubles-team participant proves doubles over any event type; otherwise the stated event type decides. Null means neither says anything — no stated event type, or 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. is_doubles stays for compatibility and is now documented as LOSSY, with its evidence order: false also covers "unknown", which is not a claim of singles — prefer draw, whose null says so honestly.
    • ?draw=singles|doubles on four listings — GET /matches, /history/matches, /tournaments and /fixtures: the axis the tour filter deliberately collapses; the two compose (?tour=itf&draw=doubles is the ITF doubles slice). A row whose draw is null matches NEITHER value — null is an answer, not a wildcard. An unknown value is a 400 bad_draw with the allowed values. Two honesty notes carried into the spec: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and ?draw=doubles alone also returns mixed and exhibition doubles that no ?tour= value reaches.
    • GET /history/coverage (BASIC, or any Historical Data API plan) — the measured completeness rollup per tour × draw bucket: the numbers to read BEFORE choosing what to backtest, in one call instead of paging the archive. A prebuilt snapshot rebuilt nightly right after the completeness ledger reconverges — never computed at read time — so as_of (= built_at) dates every number and ledger_max_computed_at is the newest underlying per-match measurement; 503 coverage_unavailable before the first nightly build. Buckets are atp/wta/challenger/itf/juniors × singles/doubles plus other (team ties, mixed, exhibitions and matches with no stated event type — counted, never dropped, so the totals cannot lie); a bucket with zero completed matches is OMITTED, never emitted as zeros. Each bucket carries the five verifiable numbers (completed, any_tape, point_complete, complete_on_default_read, share), and method states the full measurement rule so every number carries its own definition. As of 2026-08-18: 174,393 completed matches; 91,318 point-complete on the best basis (52.4%) against 81,196 on the default read alone (46.6%); ITF singles 51.1% against ITF doubles 3.5% — do not extrapolate a completeness rate across a tour group.
    • **tape.starts_at_love and tape.computed_at on /history/matches items** (both nullable, present where enabled). starts_at_love follows the SAME best-basis rule as points_complete — true when either measured basis opens at the 0-0 state, so if any on-disk sequence opens at love you can obtain one that does. computed_at is when the ledger last measured the match: every field in the tape's measured block is a nightly-reconverged cache, and this is the as-of to quote with any of them. Null on either means the match has not been measured — never a guess.
    • @@ -908,7 +913,7 @@

      Added

    Changed

      -
    • Measured statistics stop rounding the truth. MatchStatisticsMeasured now states: match totals only (no per-set measured statistics); absent = not measured while a present 0 is a real measured zero; the three coverage tiers with their hard zeros — the winners/unforced/forced-errors family historically on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and NONE of Challenger, ITF or juniors (24,552 payloads, 2026-07-31) — and that the upstream feed has not delivered that family since 2026-07-12 (0 of 4,513 August payloads carry it, measured 2026-08-17).
    • +
    • Measured statistics stop rounding the truth. MatchStatisticsMeasured now states: match totals only (no per-set measured statistics); absent = not measured while a present 0 is a real measured zero; the three coverage tiers with their hard zeros — the winners/unforced/forced-errors family historically on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and NONE of Challenger, ITF or juniors (24,552 payloads, 2026-07-31) — and that the family has not been available since 2026-07-12 (0 of 4,513 August payloads carry it, measured 2026-08-17).
    • errors_total is documented as the total of FORCED errors, with the evidence (3,766 payload sides, June–July 2026: equals the per-stroke error sum in 96.2%; smaller than unforced_errors_total in 11.7% of sides — impossible for a superset; per-match points accounting closes only under the forced reading, median residual 0 over 367 matches). Total errors = errors_total + unforced_errors_total; the *_errors shot family is the forced-error breakdown, and groundstroke_errors = forehand_errors + backhand_errors, a rollup rather than an addition.
    • UTR honesty. system=utr is documented as observed from UTR's public search: withheld ratings are ABSENT, never 0; per-player as-of only — no listing by design (a table of only the players we happen to track would be a fake leaderboard); history since 2026-07-29; and the sweep's deliberate rankless / no-Elo (ITF-skewed) bias with its measured coverage (2026-08-17, players active in the last 60 days): ITF 931 of 5,606 (16.6%), Challenger 197 of 1,903 (10.4%), WTA 43 of 573 (7.5%), ATP 15 of 525 (2.9%).
    • *(recorded retroactively)* On 2026-08-16 the /ws-token description was expanded in place to teach the push-feed protocol (Centrifugo v2 connect/subscribe/heartbeat, token-per-reconnect, the SDK PushStream pointer) without a version bump; this entry is the changelog record of that change.
    • @@ -936,7 +941,7 @@

      Added

      ## [1.3.0] — 2026-08-07

      Added

        -
      • Shot-level charting. GET /charting/players (ULTRA) — career serve/return profile 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, rally-length and shot-direction tendencies, summed over the player's charted matches (name keyed, gender=men|women disambiguates, ambiguous fragments refused with candidates). GET /charting/matches/{chartingMatchId} (ULTRA) — every stat family for one charted match, both players, per-set split. Coverage is curated: 11,646 charted matches back to the 1960s, concentrated on the majors, not full-slate.
      • +
      • Shot-level charting. GET /charting/players (ULTRA) — career serve/return profile: 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, rally-length and shot-direction tendencies, summed over the player's charted matches (name keyed, gender=men|women disambiguates, ambiguous fragments refused with candidates). GET /charting/matches/{chartingMatchId} (ULTRA) — every stat family for one charted match, both players, per-set split. Coverage is curated: 11,646 charted matches back to the 1960s, concentrated on the majors, not full-slate.
      • Push-feed token. GET /ws-token (ULTRA): mints a short-lived signed token plus the push WebSocket URL and channel vocabulary — match:{match_id} per-match streams and slate:all for every live score frame. A separate high-fan-out surface from the native /ws feed.
      • H2H stat splits. On ULTRA, GET /h2h adds a per-player stats block: serve/return/break-point aggregates over the pairing — archive_serve (serve-side, from 1991) and current (2023+, adding return and break-point conversion, aces and winners), each with meetings_with_stats.
      • Abuse throttle documented. The 429 family now documents all three body shapes: the per-minute limit (rate_limited with upgrade_url/tier/ price), the per-day quota (rate_limited with scope: "day", limit_per_day and resets_at — an absolute ISO instant), and abuse_throttled with retry_at_epoch — a 24-hour block for clients hammering far past their cap, which a well-behaved retry loop never sees.
      • @@ -972,7 +977,7 @@

        Added

        • Usage endpoint. GET /usage (FREE — any tier): your own durable daily usage vs quota — tier, limits, today's calls and a 30-day history. Calls to it are quota-exempt. New schema Usage.
        • As-of rankings. GET /rankings (ULTRA): ranking records as they stood on a date — player (required, repeatable, max 50), as_of, and system (atp, wta, itf_jt, itf_mt, itf_wt, utr). Systems are never collapsed into a single "rank"; UTR carries a rating with null rank/points. meta.coverage.oldest_available gives the earliest date each system can answer for. New schemas RankingRecord, RankingListMeta.
        • -
        • In-play match statistics. GET /matches/{matchId}/statistics (ULTRA): two families, deliberately never merged — DERIVED (rebuilt from the point-by-point record: hold/break %, break points, service & return points) and MEASURED (counted upstream: aces, double faults, the serve split, winners/unforced errors). Each family carries its own coverage, as_of and age_seconds; absent measured fields are omitted, never zero-filled; coverage: none on both families is a 200 with null players, not a 404; a divergence guard withholds measured values when the families disagree. New schemas MatchStatistics, MatchStatisticsSide, MatchStatisticsMeasured, MatchStatisticsFreshness, MatchStatisticsFamily.
        • +
        • In-play match statistics. GET /matches/{matchId}/statistics (ULTRA): two families, deliberately never merged — DERIVED (rebuilt from the point-by-point record: hold/break %, break points, service & return points) and MEASURED (counted during the match: aces, double faults, the serve split, winners/unforced errors). Each family carries its own coverage, as_of and age_seconds; absent measured fields are omitted, never zero-filled; coverage: none on both families is a 200 with null players, not a 404; a divergence guard withholds measured values when the families disagree. New schemas MatchStatistics, MatchStatisticsSide, MatchStatisticsMeasured, MatchStatisticsFreshness, MatchStatisticsFamily.
        • Webhooks. POST /webhooks, GET /webhooks, DELETE /webhooks/{webhookId} (ULTRA, direct keys only): we POST the same frames the WebSocket sends to your HTTPS endpoint. Deliveries carry X-LTAPI-Signature (sha256=<hex> HMAC-SHA256 over the raw body), X-LTAPI-Timestamp and X-LTAPI-Event; up to 3 webhooks per key (409 webhook_limit); auto-disable after 25 consecutive failures. The signing secret is returned exactly once, on the 201. New schema Webhook.
        • Bare price ticks. GET /matches/{matchId}/prices (PRO): recent ticks of the mapped match-winner market without the market wrapper — limit caps at 500, minutes bounds the lookback, 404 when the match has no mapped market.
        • Tape coverage and sequence. /history/matches gains ?coverage= (items are now HistoryMatch — Match plus a tape coverage object); /history/matches/{matchId} gains ?sequence=raw|clean, documents that it works on a LIVE match, and its meta now carries coverage, point_source, raw_rows, unique_states and sequence. Tape rows are now the explicit HistoryTapeRow (null timestamp marks a reconstructed row). New schemas Coverage, HistoryMatch, HistoryTapeRow.
        • diff --git a/docs/historical-results-archive.html b/docs/historical-results-archive.html index fd77347..bd52ef2 100644 --- a/docs/historical-results-archive.html +++ b/docs/historical-results-archive.html @@ -202,7 +202,7 @@
          Live Tennis API DocsGet a free key
          Explore the documentation
          -

          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

          GET /history/archive/matches/{archiveId}/tape — parameters
          NameInTypeRequiredNotes
          archiveIdpathintegeryesAn archive_matches.id — the id on a /history/archive/matches row, never a match id.

          Responses

          GET /history/archive/matches/{archiveId}/tape — responses
          StatusMeaning
          200The reconstructed tape (match + tape + tiebreaks + an always-empty profiles + provenance meta)
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No tape for this archive result — it is 1968–2012, it is 2013–2022 and was never reconstructed, or a reconstruction exists and did not prove its binding. The three are deliberately not distinguished: "we hold something we will not stand behind" is not a state this product reports.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /history/archive/matches/{archiveId}/tape — response fields
          FieldTypeDescription
          matchArchiveMatchOne result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will.
          tapearray of HistoryTapeRowThe score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game.
          tiebreaksarray or nullPer-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectAlways empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match.
          metaobject
          meta.archive_match_idintegerThe archive_matches.id this tape belongs to. Named in full because it is NOT a match id — passing it to a /matches/... route resolves a different, real record without erroring.
          meta.tiebreaks_sourcearray or nullAligned to tiebreaks, same contract as on HistoryTape. Every populated entry here reads reconstruction — the archive tape IS the reconstruction and nothing else is consulted.
          meta.rowsintegerRows returned. Read next to granularity before comparing it with a 2023+ tape's row count. Across the corpus a tape averages 146 rows (median 138, range 4-570); a per-game tape carries one row per game and is far shorter than a point tape of the same match.
          meta.coveragestring (reconstructed, reconstructed_partial)reconstructed (96.3% of the corpus) = the tape reproduces the published final score exactly, and that score is a match that finished. reconstructed_partial (3.7%) HAS TWO CAUSES and the label does not say which: either the match itself stopped early (a retirement or abandonment prefix), OR the tape is per-GAME — every one of the 556 game-granular tapes carries this label however complete it is, because the coverage vocabulary is closed and game has no word of its own. It therefore does NOT on its own mean incomplete match coverage: read granularity and the match's own score/outcome for that. Never from_start or partial: both assert we watched the match, and nobody watched these. On the 2023+ tape the same word means only short-at-either-end.
          meta.granularitystring (point, game)What one row resolves to. point = the record advances within a game — 99.4% of the corpus (97,293 matches). game = one row per game, with points expanded only inside tiebreaks — 556 matches, 555 of them in 2013 and one in 2014; every season from 2015 on is 100% point-granular. point_winner / winner on a per-game tape read the side whose game count rose on each row, and nothing about the game's other points. Orthogonal to coverage as an axis, though a game tape is always labelled reconstructed_partial.
          meta.point_sourcestring (reconstructed)Always reconstructed. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take.
          meta.model_rowsintegerAlways 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape.
          meta.observed_rowsintegerAlways 0 — no row here was watched live.
          meta.point_source_detailstringAlways 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.
          meta.sequencestring (raw)Always raw. There is nothing to collapse — a reconstructed tape has one source and no corrections, so ?sequence=clean has no meaning here; the parameter is ignored, not refused.
          meta.generated_atstring
          +

          Response fields

          GET /history/archive/matches/{archiveId}/tape — response fields
          FieldTypeDescription
          matchArchiveMatchOne result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will.
          tapearray of HistoryTapeRowThe score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game.
          tiebreaksarray or nullPer-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectAlways empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match.
          metaobject
          meta.archive_match_idintegerThe archive_matches.id this tape belongs to. Named in full because it is NOT a match id — passing it to a /matches/... route resolves a different, real record without erroring.
          meta.tiebreaks_sourcearray or nullAligned to tiebreaks, same contract as on HistoryTape. Every populated entry here reads reconstruction — the archive tape IS the reconstruction and nothing else is consulted.
          meta.rowsintegerRows returned. Read next to granularity before comparing it with a 2023+ tape's row count. Across the corpus a tape averages 146 rows (median 138, range 4-570); a per-game tape carries one row per game and is far shorter than a point tape of the same match.
          meta.coveragestring (reconstructed, reconstructed_partial)reconstructed (96.3% of the corpus) = the tape reproduces the published final score exactly, and that score is a match that finished. reconstructed_partial (3.7%) HAS TWO CAUSES and the label does not say which: either the match itself stopped early (a retirement or abandonment prefix), OR the tape is per-GAME — every one of the 556 game-granular tapes carries this label however complete it is, because the coverage vocabulary is closed and game has no word of its own. It therefore does NOT on its own mean incomplete match coverage: read granularity and the match's own score/outcome for that. Never from_start or partial: both assert we watched the match, and nobody watched these. On the 2023+ tape the same word means only short-at-either-end.
          meta.granularitystring (point, game)What one row resolves to. point = the record advances within a game — 99.4% of the corpus (97,293 matches). game = one row per game, with points expanded only inside tiebreaks — 556 matches, 555 of them in 2013 and one in 2014; every season from 2015 on is 100% point-granular. point_winner / winner on a per-game tape read the side whose game count rose on each row, and nothing about the game's other points. Orthogonal to coverage as an axis, though a game tape is always labelled reconstructed_partial.
          meta.point_sourcestring (reconstructed)Always reconstructed. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take.
          meta.model_rowsintegerAlways 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape.
          meta.observed_rowsintegerAlways 0 — no row here was watched live.
          meta.point_source_detailstringAlways 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.
          meta.sequencestring (raw)Always raw. There is nothing to collapse — a reconstructed tape has one source and no corrections, so ?sequence=clean has no meaning here; the parameter is ignored, not refused.
          meta.generated_atstring

          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 DocsGet a free key
          Explore the documentation
          -

          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

          GET /matches/{matchId} — responses
          StatusMeaning
          200Match with score; market embed at PRO+, analysis embed at ULTRA
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId} — response fields
          FieldTypeDescription
          idinteger
          tournamentstring
          tourstring or null (atp, wta, challenger, itf, juniors, null)The tour, in the SAME vocabulary the tour query filter accepts — both are derived from one registry, so a match selected by ?tour=X always carries that value in tour. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.
          tournament_idstring or nullStable 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.)
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level the tournament was played at IN THIS MATCH'S SEASON (added 2026-09-22) — the official category as the tour publishes it, in a closed vocabulary: grand_slam; the ATP levels (atp_finals, atp_1000, atp_500, atp_250, next_gen_finals); the WTA levels (wta_finals, wta_elite_trophy — last held 2023 — wta_1000, wta_500, wta_250, wta_125); the ATP Challenger levels (challenger_175 … challenger_50); the ITF World Tennis Tour categories (itf_m15, itf_m25, itf_w15 … itf_w100 — the 2023 women's categories itf_w25/itf_w40/itf_w60/itf_w80 are kept as printed that season, not mapped onto the 2024 ladder, because the level a match was played at is a fact about that season); the team events (united_cup, davis_cup and bjk_cup in every group and stage, laver_cup, olympics); juniors; utr; and exhibition (no ranking points — Hopman Cup class). Same vocabulary as the ?tier= filter, read from the same per-season table, so filter and field cannot disagree. Resolved by the MATCH'S season (the calendar year of scheduled_time), never copied from the tournament row: a tournament keeps one tournament_id across seasons while its level moves — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025, so a 2024 Dallas match reads atp_250 and a 2025 one atp_500 under the same id. Coverage of the 2023–2026 seasons was verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest are deliberately null — the Asian Games tennis event, which no tour lists). A new event may read null for a short while after its first match until its tier is resolved. Null when nothing we hold names the level — never guessed; null is "we do not know", never a level. category on /tournaments is unchanged and remains the coarse class (challenger, itf, atp_250 …); read this field for the level.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          formatstring or null (BO3, BO5, null)
          roundstring or nullThe 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.
          round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. -IT 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.
          is_qualifyingboolean or nullWhether 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. -THREE-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.
          statusstring (upcoming, live, completed, cancelled)The settled lifecycle. completed is asserted only for a match we observed being played or whose match-winner market settled decisively (one side paid out) — a closed market alone never finishes a match (rule stated 2026-08-23; rows mis-published before it were corrected). cancelled with event_status: null means we hold positive evidence the match was not played as scheduled (the market settled void) but no vendor word for why — outcome and the fixture reason stay null rather than guessed, and the row upgrades to a completed walkover with winner / withdrew if a Walk Over with a stated winner lands later. The flip upcoming → cancelled runs through a gate (well past the scheduled time, no play recorded), never inline on a feed flap.
          event_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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.
          event_status_updated_atstring or nullWhen event_status last CHANGED, UTC (ISO-8601, Z) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while event_status has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.
          is_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)The honest THREE-VALUED draw (added 2026-08-18) — same vocabulary as the ?draw= filter, decided by the same shared definition, so filter and field cannot disagree. Evidence order as is_doubles: a doubles-team participant proves doubles over any event type; otherwise the feed's event type decides. Null means neither says anything — the feed stated no event type, or the match is part of a team tie (Davis Cup / BJK Cup / United Cup class), where one event type covers both singles and doubles rubbers and we will not guess which this is. Null is NOT singles.
          scheduled_timestring or null
          live_atstring or nullThe instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after scheduled_time, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
          result_restated_atstring or nullWhen the published result of this match was LAST corrected (UTC), added 2026-09-22 — the instant of the newest restatement row on GET /matches/{matchId}/status-history. A correction is any change to status, event_status, winner or the final score (sets, games) after the match was first published as completed (or as a cancelled walkover with a winner). Null when the result stands as first published. Present on every match surface. Poll GET /history/matches?restated_since= to find corrections.
          result_versioninteger1 plus the number of corrections to the published result (added 2026-09-22). 1 = the result stands as first published; each restatement row on the status history adds one. Record it with the result you settle on and compare on re-read — a higher number means the result you hold was superseded, and the status history says what changed.
          playersobject
          players.p1Player
          players.p2Player
          scoreScore or null
          winnerinteger or nullCompleted matches only — derived from final sets. Served for the full archive age: a match older than the live-table window reads its final state from the same store the tape serves, so old completed matches carry a winner too.
          outcomestring or null (completed, retired, walkover, default, abandoned, unresolved, null)The settlement outcome (added 2026-08-18), one closed vocabulary derived from status + event_status so the two can never disagree: completed = ran its full course; retired = a player retired in play; walkover = conceded before a ball was struck; default = a player was defaulted; abandoned = called off unfinished with no result awarded; unresolved (added 2026-09-10) = every source lost the match before a result and it was closed unfinished — score is the last state we observed, winner is null and no result is asserted; it flips to completed with the proven final when an authority confirms the result. NULL while the match is upcoming/live and on a cancelled fixture that never produced a result — null is not completed. Branch settlement on this field, never on event_status spellings. New values may be added within v1; handle unknowns gracefully.
          withdrewinteger or nullCompleted matches only — which player retired or conceded the walkover (1|2). Present only when event_status is Retired/Walk Over and the winner is derivable; the withdrawer is the loser by the rules of the sport. Absent means "not a withdrawal, or no evidence" — never a guess.
          has_analysisbooleanWhether a model thesis or profile exists for this match — on every list row and the detail, every tier (since 2026-09-02). Filter the slate on this before calling /matches/{matchId}/analysis, which answers 404 no_analysis about the same fact.
          has_marketbooleanWhether a match-winner market is mapped to this match (every tier, since 2026-09-02). Same role for /markets/{matchId}/prices (404 no_market).
          analysisAnalysisULTRA only (absent below)
          marketMarket or nullPRO+ only (absent below)
          +

          Response fields

          GET /matches/{matchId} — response fields
          FieldTypeDescription
          idinteger
          tournamentstring
          tourstring or null (atp, wta, challenger, itf, juniors, null)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_idstring or nullStable 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.)
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level the tournament was played at IN THIS MATCH'S SEASON (added 2026-09-22) — the official category as the tour publishes it, in a closed vocabulary: grand_slam; the ATP levels (atp_finals, atp_1000, atp_500, atp_250, next_gen_finals); the WTA levels (wta_finals, wta_elite_trophy — last held 2023 — wta_1000, wta_500, wta_250, wta_125); the ATP Challenger levels (challenger_175 … challenger_50); the ITF World Tennis Tour categories (itf_m15, itf_m25, itf_w15 … itf_w100 — the 2023 women's categories itf_w25/itf_w40/itf_w60/itf_w80 are kept as printed that season, not mapped onto the 2024 ladder, because the level a match was played at is a fact about that season); the team events (united_cup, davis_cup and bjk_cup in every group and stage, laver_cup, olympics); juniors; utr; and exhibition (no ranking points — Hopman Cup class). Same vocabulary as the ?tier= filter, read from the same per-season table, so filter and field cannot disagree. Resolved by the MATCH'S season (the calendar year of scheduled_time), never copied from the tournament row: a tournament keeps one tournament_id across seasons while its level moves — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025, so a 2024 Dallas match reads atp_250 and a 2025 one atp_500 under the same id. Coverage of the 2023–2026 seasons was verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest are deliberately null — the Asian Games tennis event, which no tour lists). A new event may read null for a short while after its first match until its tier is resolved. Null when nothing we hold names the level — never guessed; null is "we do not know", never a level. category on /tournaments is unchanged and remains the coarse class (challenger, itf, atp_250 …); read this field for the level.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          formatstring or null (BO3, BO5, null)
          roundstring or nullThe 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_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. +IT 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_qualifyingboolean or nullWhether 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. +THREE-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.
          statusstring (upcoming, live, completed, cancelled)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_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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_atstring or nullWhen 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_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)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_timestring or null
          live_atstring or nullThe instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after scheduled_time, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
          result_restated_atstring or nullWhen the published result of this match was LAST corrected (UTC), added 2026-09-22 — the instant of the newest restatement row on GET /matches/{matchId}/status-history. A correction is any change to status, event_status, winner or the final score (sets, games) after the match was first published as completed (or as a cancelled walkover with a winner). Null when the result stands as first published. Present on every match surface. Poll GET /history/matches?restated_since= to find corrections.
          result_versioninteger1 plus the number of corrections to the published result (added 2026-09-22). 1 = the result stands as first published; each restatement row on the status history adds one. Record it with the result you settle on and compare on re-read — a higher number means the result you hold was superseded, and the status history says what changed.
          playersobject
          players.p1Player
          players.p2Player
          scoreScore or null
          winnerinteger or nullCompleted matches only — derived from final sets. Served for the full archive age: a match older than the live-table window reads its final state from the same store the tape serves, so old completed matches carry a winner too.
          outcomestring or null (completed, retired, walkover, default, abandoned, unresolved, null)The settlement outcome (added 2026-08-18), one closed vocabulary derived from status + event_status so the two can never disagree: completed = ran its full course; retired = a player retired in play; walkover = conceded before a ball was struck; default = a player was defaulted; abandoned = called off unfinished with no result awarded; unresolved (added 2026-09-10) = every source lost the match before a result and it was closed unfinished — score is the last state we observed, winner is null and no result is asserted; it flips to completed with the proven final when an authority confirms the result. NULL while the match is upcoming/live and on a cancelled fixture that never produced a result — null is not completed. Branch settlement on this field, never on event_status spellings. New values may be added within v1; handle unknowns gracefully.
          withdrewinteger or nullCompleted matches only — which player retired or conceded the walkover (1|2). Present only when event_status is Retired/Walk Over and the winner is derivable; the withdrawer is the loser by the rules of the sport. Absent means "not a withdrawal, or no evidence" — never a guess.
          has_analysisbooleanWhether a model thesis or profile exists for this match — on every list row and the detail, every tier (since 2026-09-02). Filter the slate on this before calling /matches/{matchId}/analysis, which answers 404 no_analysis about the same fact.
          has_marketbooleanWhether a match-winner market is mapped to this match (every tier, since 2026-09-02). Same role for /markets/{matchId}/prices (404 no_market).
          analysisAnalysisULTRA only (absent below)
          marketMarket or nullPRO+ only (absent below)

          Example

          curl https://api.livetennisapi.com/api/public/v1/matches/18953 \
             -H "Authorization: Bearer twjp_..."
          @@ -266,7 +266,7 @@

          Parameters

          GET /matches/{matchId}/score — responses
          StatusMeaning
          200Current score (ULTRA adds win_probability_p1 + danger). On a settled match with no live row, the archived final (since 2026-09-20) — age_seconds, observed_age_seconds, sources_count and accepted_at null.
          401Missing, unknown, or disabled credentials
          404No such match; an upcoming match with no score yet; a cancelled match that was never played; or a settled match with nothing recorded anywhere — no live row and no archived final. Since 2026-09-20 a settled match whose live rows were retired by the 90-day sweep is NOT a 404: it answers 200 with its archived final (age_seconds: null), the same score GET /matches?status=completed embeds for it.
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/score — response fields
          FieldTypeDescription
          setsarray of integer
          gamesarray or null[games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see detail); it never means 0-0, and an empty array is never used to mean "unknown". A deciding set played as a 10-point MATCH TIEBREAK (super-tiebreak) carries the BREAKER's score in its slot WHILE IT IS BEING PLAYED — [[6,4,10],[4,6,5]] is 6-4 3-6 [10-5], not a 10-game set — and is_tiebreak is true then. Once the breaker is over that set is often published as a one-game 1-0: take the match winner from sets, never from the deciding set's games.
          pointsarray of string
          serverinteger or null (1, 2, null)
          is_tiebreakbooleanThe current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose games slot holds the breaker.
          win_probability_p1number or null
          dangernumber or null
          win_probability_p1_modelnumber or nullULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and also on some rows of an anchored match (the anchor shifts the pre-match hold rates, which enter the engine snapped to a 0.01 grid, so a shift smaller than a grid step often solves to an identical value); the per-row test is win_probability_p1 != win_probability_p1_model. Null on rows stamped before it existed (never back-filled).
          win_probability_metaobject or nullULTRA, since 2026-09-12. Our own model-regime identifier (model_version), the UTC instant the two probabilities were generated (generated_at), whether the anchor applies to this MATCH (market_anchored — per match, not per row) and, since 2026-09-15, whether it moved THIS ROW's win_probability_p1 away from the twin (anchor_effective). Null on rows stamped before it existed. SEGREGATING A RECORDED CORPUS — cut on the CLOCK, not on the version string. Two changes moved published live probabilities before model_version reflected them, so split a corpus on generated_at (or the row's timestamp) at these two instants: 2026-08-18T08:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began (some final sets priced as a race-to-ten breaker), and 2026-09-16T07:59:54Z, when the draw-based rule replaced it (main-draw singles priced as a full set — a move of up to 0.20 on affected deciding-set states). model_version is NOT a safe discriminator across the second boundary: the +itfdraw-2026-09-16 suffix first appears on rows generated 2026-09-17T05:04:25Z, 21 h 04 m AFTER the rule itself landed, and in that gap 33,510 states across 351 matches (9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs) were computed under the CORRECTED rule while still carrying markov-population-2026-08-23. Cutting on the version string — or on a whole-day boundary — mislabels exactly those rows; cutting at 2026-09-16T07:59:54Z does not. Measured against production 2026-09-19. CORRECTION (2026-09-19): both instants were first published, the same day, THREE HOURS LATE — 2026-08-18T11:29:28Z and 2026-09-16T11:02:00Z — because the switch times were read off a column stored in local wall time and labelled UTC. The instants above are the corrected UTC ones. If you cut a corpus on either of the old values, re-cut: everything in the three hours before them is on the wrong side. Earlier than that there is no stamp to cut on at all: no row generated before 2026-09-12T12:08:17Z carries model_version or generated_at, and the bulk tape packages have no version column, so for those the row timestamp is the only discriminator. The model_version on an analysis profile (ModelProfile) is a different identifier for a different object — the forecast's prompt scaffold — and does not apply to score rows.
          win_probability_meta.model_versionstringThe model regime that produced this row: engine family, calibration key scheme and — since 2026-09-17 — every pricing rule in force, as a +… suffix. It is not a safe cut for a recorded corpus across the 2026-09-16 boundary; see this object's description for the two instants to segregate on.
          win_probability_meta.generated_atstring or null
          win_probability_meta.market_anchoredbooleanThe 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_effectiveboolean or nullSince 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.
          timestampstring or nullOur 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.
          accepted_atstring or nullSince 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.
          sequenceinteger or nullPer-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_secondsinteger or nullSeconds 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.
          stalebooleanOur 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_secondsinteger or nullSeconds 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_countinteger or nullHow 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_countinteger or nullSince 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.
          corroboratedboolean or nullSince 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_secondsinteger or nullSince 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.
          detailstringPresent only when something is withheld — plain-language reason, e.g. a null games array.
          verdictobject or nullWhy this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. superseded_sequence is the sequence of the refused state, the value a stream consumer holds. reason is the gate's slug. kind is withdrawn (discard the refused state and resume from the served one), deferred (it may be early and is pending the next accept) or withheld (nothing publishable, games is null). safe_to_resume is true exactly when kind is withdrawn. The push feed, the WebSocket and webhooks announce the same judgement as a score_withdrawn frame naming the same sequence, so a stream consumer never has to poll to learn a state was taken back. That frame carries type, match_id, kind, superseded_sequence, reason, safe_to_resume, ts and published_at, and no replacement score: resume from the next score frame or from a fresh read. Since 2026-09-25 this field also answers for a sequence you name with ?sequence=N, from the durable record served by GET /matches/{matchId}/withdrawals — which is how a consumer that missed the frame recovers it.
          verdict.kindstring (withdrawn, deferred, withheld)
          verdict.superseded_sequenceinteger or null
          verdict.reasonstring
          verdict.safe_to_resumeboolean
          +

          Response fields

          GET /matches/{matchId}/score — response fields
          FieldTypeDescription
          setsarray of integer
          gamesarray or null[games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see detail); it never means 0-0, and an empty array is never used to mean "unknown". A deciding set played as a 10-point MATCH TIEBREAK (super-tiebreak) carries the BREAKER's score in its slot WHILE IT IS BEING PLAYED — [[6,4,10],[4,6,5]] is 6-4 3-6 [10-5], not a 10-game set — and is_tiebreak is true then. Once the breaker is over that set is often published as a one-game 1-0: take the match winner from sets, never from the deciding set's games.
          pointsarray of string
          serverinteger or null (1, 2, null)
          is_tiebreakbooleanThe current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose games slot holds the breaker.
          win_probability_p1number or null
          dangernumber or null
          win_probability_p1_modelnumber or nullULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and also on some rows of an anchored match (the anchor shifts the pre-match hold rates, which enter the engine snapped to a 0.01 grid, so a shift smaller than a grid step often solves to an identical value); the per-row test is win_probability_p1 != win_probability_p1_model. Null on rows stamped before it existed (never back-filled).
          win_probability_metaobject or nullULTRA, since 2026-09-12. Our own model-regime identifier (model_version), the UTC instant the two probabilities were generated (generated_at), whether the anchor applies to this MATCH (market_anchored — per match, not per row) and, since 2026-09-15, whether it moved THIS ROW's win_probability_p1 away from the twin (anchor_effective). Null on rows stamped before it existed. SEGREGATING A RECORDED CORPUS — cut on the CLOCK, not on the version string. Two changes moved published live probabilities before model_version reflected them, so split a corpus on generated_at (or the row's timestamp) at these two instants: 2026-08-18T08:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began (some final sets priced as a race-to-ten breaker), and 2026-09-16T07:59:54Z, when the draw-based rule replaced it (main-draw singles priced as a full set — a move of up to 0.20 on affected deciding-set states). model_version is NOT a safe discriminator across the second boundary: the +itfdraw-2026-09-16 suffix first appears on rows generated 2026-09-17T05:04:25Z, 21 h 04 m AFTER the rule itself landed, and in that gap 33,510 states across 351 matches (9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs) were computed under the CORRECTED rule while still carrying markov-population-2026-08-23. Cutting on the version string — or on a whole-day boundary — mislabels exactly those rows; cutting at 2026-09-16T07:59:54Z does not. Measured against production 2026-09-19. CORRECTION (2026-09-19): both instants were first published, the same day, THREE HOURS LATE — 2026-08-18T11:29:28Z and 2026-09-16T11:02:00Z — because the switch times were read off a column stored in local wall time and labelled UTC. The instants above are the corrected UTC ones. If you cut a corpus on either of the old values, re-cut: everything in the three hours before them is on the wrong side. Earlier than that there is no stamp to cut on at all: no row generated before 2026-09-12T12:08:17Z carries model_version or generated_at, and the bulk tape packages have no version column, so for those the row timestamp is the only discriminator. The model_version on an analysis profile (ModelProfile) is a different identifier for a different object — the forecast's prompt scaffold — and does not apply to score rows.
          win_probability_meta.model_versionstringThe model regime that produced this row: engine family, calibration key scheme and — since 2026-09-17 — every pricing rule in force, as a +… suffix. It is not a safe cut for a recorded corpus across the 2026-09-16 boundary; see this object's description for the two instants to segregate on.
          win_probability_meta.generated_atstring or null
          win_probability_meta.market_anchoredbooleanThe 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_effectiveboolean or nullSince 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.
          timestampstring or nullOur 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_atstring or nullSince 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.
          sequenceinteger or nullPer-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_secondsinteger or nullSeconds 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.
          stalebooleanOur 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_secondsinteger or nullSeconds 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_countinteger or nullHow 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_countinteger or nullSince 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.
          corroboratedboolean or nullSince 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_secondsinteger or nullSince 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.
          detailstringPresent only when something is withheld — plain-language reason, e.g. a null games array.
          verdictobject or nullWhy this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. superseded_sequence is the sequence of the refused state, the value a stream consumer holds. reason is the gate's slug. kind is withdrawn (discard the refused state and resume from the served one), deferred (it may be early and is pending the next accept) or withheld (nothing publishable, games is null). safe_to_resume is true exactly when kind is withdrawn. The push feed, the WebSocket and webhooks announce the same judgement as a score_withdrawn frame naming the same sequence, so a stream consumer never has to poll to learn a state was taken back. That frame carries type, match_id, kind, superseded_sequence, reason, safe_to_resume, ts and published_at, and no replacement score: resume from the next score frame or from a fresh read. Since 2026-09-25 this field also answers for a sequence you name with ?sequence=N, from the durable record served by GET /matches/{matchId}/withdrawals — which is how a consumer that missed the frame recovers it.
          verdict.kindstring (withdrawn, deferred, withheld)
          verdict.superseded_sequenceinteger or null
          verdict.reasonstring
          verdict.safe_to_resumeboolean

          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

          GET /events — parameters
          NameInTypeRequiredNotes
          typequerystringnoComma-separated event types (see Event.type), or stoppages for the whole stoppage family.
          after_idqueryintegernoServe rows with id greater than this, ascending. Take it from meta.next_cursor or the last row's id.
          sincequerystringnoFirst-call lower bound, a UTC instant. Rows stamped after it, ascending.
          limitqueryintegerno Default 50. Range 1–200.

          Responses

          GET /events — responses
          StatusMeaning
          200Events across the slate, ascending id
          400bad_type, bad_after_id or bad_since
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /events — response fields
          FieldTypeDescription
          dataarray of SlateEvent
          metaListMeta
          @@ -360,9 +360,9 @@

          GET /matches/{matchId}/statisticsplayers.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

          GET /matches/{matchId}/statistics — responses
          StatusMeaning
          200Statistics with their own coverage and as_of
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/statistics — response fields
          FieldTypeDescription
          match_idinteger
          coveragestring (live, final, stale, none, diverged)
          as_ofstring or nullWhen the underlying record was last updated (UTC)
          age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
          games_countedinteger
          tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
          inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
          sets_coveredarray of integer
          freshnessMatchStatisticsFreshnessPer-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence.
          detailstringPresent only when coverage is none
          playersobject or null
          players.p1MatchStatisticsSideOne 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.
          players.p2MatchStatisticsSideOne 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.
          +

          Response fields

          GET /matches/{matchId}/statistics — response fields
          FieldTypeDescription
          match_idinteger
          coveragestring (live, final, stale, none, diverged)
          as_ofstring or nullWhen the underlying record was last updated (UTC)
          age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
          games_countedinteger
          tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
          inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
          sets_coveredarray of integer
          freshnessMatchStatisticsFreshnessPer-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence.
          detailstringPresent only when coverage is none
          playersobject or null
          players.p1MatchStatisticsSideOne 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.
          players.p2MatchStatisticsSideOne 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.

          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

          GET /matches/{matchId}/points — responses
          StatusMeaning
          200The point events page, seq order
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/points — response fields
          FieldTypeDescription
          match_idinteger
          pbp_coveragestring (point, game)point = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); game = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to point on the first played point — gate on last_seq > 1).
          qualitystring (clean, revised)revised = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
          enrichmentobjectAdded 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.
          enrichment.servestring (stated, none)
          enrichment.outcomestring (full, ace_double_fault, none)
          covers_from_startboolean or nullWhether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from after_seq=0 yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
          pointsarray of LivePoint
          last_seqintegerThe resume cursor — pass as after_seq to continue.
          has_moreboolean
          basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows, served whenever the stream is complete, or tagged and legal end to end (a projection carries no clock and no tag, so the stream is strictly more information); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time, served only when the stream falls short (see basis_reason) — includes the match-closing point, seq contiguous 1..N, ts null on every frame. The two bases are never interleaved.
          basis_reasonstring (stream_absent, stream_incomplete, stream_illegal)Added 2026-09-21. Present on the reconstruction basis only — why the stored live stream was not served. stream_absent = no stream rows; stream_incomplete = the stream is legal but does not measure complete (joined mid-match or stopped short) and carries no tags; stream_illegal = at least one transition is not attributable to one point (a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served (basis live) and this key is absent.
          duplicates_collapsedintegerAdded 2026-10-26. How many rows of this page repeated a point the match had already published (same set, game, number and score) and were left out. The writer no longer stores such repeats; this counts the ones written before that, so paging stays dense and last_seq still advances past them. A page of repeats alone returns points: [] and a cursor.
          ends_at_finalbooleanAdded 2026-09-20. Whether the sequence served ends on the match-closing point — the state after the match-winning point: number 0, score 0-0, server null, winner the match winner. On a completed match served on the live basis that row is appended at read time from the stored stream's last row and the observed final score, and only when the two are one point apart (when the match ends in a tiebreak, the set roll-up after the decisive tiebreak row: tiebreak false, the set banked 7-6, the same winner); false when the match is completed but the stream stops short of it (a retirement or walkover, a truncated capture, or a closer that cannot be stated as one point, such as a 10-point match tiebreak), and always false on a live match. On the reconstruction basis it is judged from the last projected frame.
          +

          Response fields

          GET /matches/{matchId}/points — response fields
          FieldTypeDescription
          match_idinteger
          pbp_coveragestring (point, game)point = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); game = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to point on the first played point — gate on last_seq > 1).
          qualitystring (clean, revised)revised = the point record was rewritten over an already-served prefix at least once during this match; served rows are never edited.
          enrichmentobjectAdded 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.
          enrichment.servestring (stated, none)
          enrichment.outcomestring (full, ace_double_fault, none)
          covers_from_startboolean or nullWhether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from after_seq=0 yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
          pointsarray of LivePoint
          last_seqintegerThe resume cursor — pass as after_seq to continue.
          has_moreboolean
          basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows, served whenever the stream is complete, or tagged and legal end to end (a projection carries no clock and no tag, so the stream is strictly more information); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time, served only when the stream falls short (see basis_reason) — includes the match-closing point, seq contiguous 1..N, ts null on every frame. The two bases are never interleaved.
          basis_reasonstring (stream_absent, stream_incomplete, stream_illegal)Added 2026-09-21. Present on the reconstruction basis only — why the stored live stream was not served. stream_absent = no stream rows; stream_incomplete = the stream is legal but does not measure complete (joined mid-match or stopped short) and carries no tags; stream_illegal = at least one transition is not attributable to one point (a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served (basis live) and this key is absent.
          duplicates_collapsedintegerAdded 2026-10-26. How many rows of this page repeated a point the match had already published (same set, game, number and score) and were left out. The writer no longer stores such repeats; this counts the ones written before that, so paging stays dense and last_seq still advances past them. A page of repeats alone returns points: [] and a cursor.
          ends_at_finalbooleanAdded 2026-09-20. Whether the sequence served ends on the match-closing point — the state after the match-winning point: number 0, score 0-0, server null, winner the match winner. On a completed match served on the live basis that row is appended at read time from the stored stream's last row and the observed final score, and only when the two are one point apart (when the match ends in a tiebreak, the set roll-up after the decisive tiebreak row: tiebreak false, the set banked 7-6, the same winner); false when the match is completed but the stream stops short of it (a retirement or walkover, a truncated capture, or a closer that cannot be stated as one point, such as a 10-point match tiebreak), and always false on a live match. On the reconstruction basis it is judged from the last projected frame.

          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 DocsGet a free key
          Explore the documentation
          -

          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

          GET /players/{playerId} — parameters
          NameInTypeRequiredNotes
          playerIdpathintegeryes

          Responses

          GET /players/{playerId} — responses
          StatusMeaning
          200Player with stats ({ratings, season})
          401Missing, unknown, or disabled credentials
          404No roster player holds this id. When the id IS a corpus person id the body adds detail and see (the archive lookup); otherwise it is the plain Error body.
          410The player id existed and was merged into another player record, or was retired with no successor. The body is PlayerMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404. An id that was NEVER issued keeps its 404 — a 410 would assert it once existed.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /players/{playerId} — response fields
          FieldTypeDescription
          idinteger
          namestring
          tourstring or null (atp, wta, null)The circuit the player is filed under: atp | wta | null (normalised 2026-09-24 — the capitalised and level values no longer appear). Not the competition level (use the match's tier) and not the draw (use the match's draw). A doubles team filed under a circuit of its own carries that circuit; one filed under a level word or nothing falls back to the circuit both its members are filed under, and is null when they disagree or either is unknown. null means not filed, never a guess. Distinct from the match-level tour, whose vocabulary is the tour-group filter's (atp | wta | challenger | itf | juniors).
          countrystring or null
          rankinginteger or nullOfficial singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date.
          ranking_pointsinteger or nullPoints on the same official table, refreshed with ranking.
          ranking_statusstring or null (ranked, unranked, unverified, null)What the last reconcile against the published ATP/WTA singles tables found (added 2026-09-23). ranked = on the table (ranking is that row). unranked = the full table was fetched and the player was not on it (a ranking here is the last value held, not current). unverified = could not be checked (no id, a name mismatch, or never checked). Null on doubles teams.
          ranking_checked_atstring or nullWhen that reconcile ran, UTC (added 2026-09-23).
          ranking_movementstring or null (up, down, same, null)
          itf_rankingobject or nullAdded 2026-10-12, paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The player's CURRENT rank on the ITF's own published World Tennis Tour ranking — the rank most ITF and junior players hold while they have no ATP/WTA one. system names the circuit (itf_mt men's pro, itf_wt women's pro, itf_jt juniors); a player ranked on a pro circuit and the junior one carries the pro rank. rank_date is the ITF's publication date of that row (weekly), so a stale rank is visible as such. NOT comparable with ranking (a circuit rank 1 is tens of points) and never written into it. Joined by the same exact ITF id itf_id exposes at ULTRA, never by name. Null when no ITF link is held, when that id is held by more than one roster row, when no published row exists, and on doubles teams. The weekly history of these ranks stays at /rankings?system=itf_* (ULTRA).
          itf_ranking.systemstring (itf_mt, itf_wt, itf_jt)
          itf_ranking.rankinteger
          itf_ranking.pointsnumber or null
          itf_ranking.rank_datestring
          handstring or null (R, L, null)
          backhandinteger or null (1, 2, null)
          birthdaystring or null
          height_cminteger or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. Height in centimetres from the results archive's record of the same person. Null when the player has no archive record or the archive holds no height. Never inferred.
          doubles_rankingobject or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The current position on the official ATP/WTA doubles table, from our weekly refresh of the published tables. observed_on is the UTC date that refresh last found the player on the table. Null when the player is not on the table or the last sighting is more than 21 days old. Never copied into ranking, which stays the singles position.
          doubles_ranking.rankinteger
          doubles_ranking.pointsinteger or null
          doubles_ranking.observed_onstring
          itf_idinteger or nullULTRA only (added 2026-09-24). The player's identifier in the ITF ranking tables we publish under system=itf_* — the itf_jt / itf_mt / itf_wt rows of /rankings are linked to this player by it (an id join, never a name match). One human's id across singles and doubles rows, so a join key, not a per-row primary key. Null when no ITF link is held and on doubles teams. Absent, not null, below ULTRA.
          is_doubles_teamboolean
          profile_restated_atstring or nullWhen 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_versioninteger1 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_idinteger or nullThe 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_completenessobjectHow 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.knowninteger or nullFields 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.ofinteger or nullFields considered. NULL on a doubles team, as for known.
          data_completeness.missingarray of stringNames of the unpopulated fields, e.g. ["backhand", "hand"].
          data_completeness.notestringPresent only when the object is not applicable, explaining why (e.g. a doubles team).
          statsobjectSingle-player endpoint only
          stats.ratingsobject or null
          stats.ratings_as_ofstring or nullThe date (UTC) the ratings block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          stats.seasonarray or null
          +

          Response fields

          GET /players/{playerId} — response fields
          FieldTypeDescription
          idinteger
          namestring
          tourstring or null (atp, wta, null)The circuit the player is filed under: atp | wta | null (normalised 2026-09-24 — the capitalised and level values no longer appear). Not the competition level (use the match's tier) and not the draw (use the match's draw). A doubles team filed under a circuit of its own carries that circuit; one filed under a level word or nothing falls back to the circuit both its members are filed under, and is null when they disagree or either is unknown. null means not filed, never a guess. Distinct from the match-level tour, whose vocabulary is the tour-group filter's (atp | wta | challenger | itf | juniors).
          countrystring or null
          rankinginteger or nullOfficial singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date.
          ranking_pointsinteger or nullPoints on the same official table, refreshed with ranking.
          ranking_statusstring or null (ranked, unranked, unverified, null)What the last reconcile against the published ATP/WTA singles tables found (added 2026-09-23). ranked = on the table (ranking is that row). unranked = the full table was fetched and the player was not on it (a ranking here is the last value held, not current). unverified = could not be checked (no id, a name mismatch, or never checked). Null on doubles teams.
          ranking_checked_atstring or nullWhen that reconcile ran, UTC (added 2026-09-23).
          ranking_movementstring or null (up, down, same, null)
          itf_rankingobject or nullAdded 2026-10-12, paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The player's CURRENT rank on the ITF's own published World Tennis Tour ranking — the rank most ITF and junior players hold while they have no ATP/WTA one. system names the circuit (itf_mt men's pro, itf_wt women's pro, itf_jt juniors); a player ranked on a pro circuit and the junior one carries the pro rank. rank_date is the ITF's publication date of that row (weekly), so a stale rank is visible as such. NOT comparable with ranking (a circuit rank 1 is tens of points) and never written into it. Joined by the same exact ITF id itf_id exposes at ULTRA, never by name. Null when no ITF link is held, when that id is held by more than one roster row, when no published row exists, and on doubles teams. The weekly history of these ranks stays at /rankings?system=itf_* (ULTRA).
          itf_ranking.systemstring (itf_mt, itf_wt, itf_jt)
          itf_ranking.rankinteger
          itf_ranking.pointsnumber or null
          itf_ranking.rank_datestring
          handstring or null (R, L, null)
          backhandinteger or null (1, 2, null)
          birthdaystring or null
          height_cminteger or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. Height in centimetres from the results archive's record of the same person. Null when the player has no archive record or the archive holds no height. Never inferred.
          doubles_rankingobject or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The current position on the official ATP/WTA doubles table, from our weekly refresh of the published tables. observed_on is the UTC date that refresh last found the player on the table. Null when the player is not on the table or the last sighting is more than 21 days old. Never copied into ranking, which stays the singles position.
          doubles_ranking.rankinteger
          doubles_ranking.pointsinteger or null
          doubles_ranking.observed_onstring
          itf_idinteger or nullULTRA only (added 2026-09-24). The player's identifier in the ITF ranking tables we publish under system=itf_* — the itf_jt / itf_mt / itf_wt rows of /rankings are linked to this player by it (an id join, never a name match). One human's id across singles and doubles rows, so a join key, not a per-row primary key. Null when no ITF link is held and on doubles teams. Absent, not null, below ULTRA.
          is_doubles_teamboolean
          profile_restated_atstring or nullWhen 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_versioninteger1 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_idinteger or nullThe 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_completenessobjectHow 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.knowninteger or nullFields 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.ofinteger or nullFields considered. NULL on a doubles team, as for known.
          data_completeness.missingarray of stringNames of the unpopulated fields, e.g. ["backhand", "hand"].
          data_completeness.notestringPresent only when the object is not applicable, explaining why (e.g. a doubles team).
          statsobjectSingle-player endpoint only
          stats.ratingsobject or null
          stats.ratings_as_ofstring or nullThe date (UTC) the ratings block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          stats.seasonarray or null

          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

          GET /tournaments/{tournamentId} — parameters
          NameInTypeRequiredNotes
          tournamentIdpathstringyesThe tournament_id carried on match objects.

          Responses

          GET /tournaments/{tournamentId} — responses
          StatusMeaning
          200The tournament
          401Missing, unknown, or disabled credentials
          404No such resource, or no data yet
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /tournaments/{tournamentId} — response fields
          FieldTypeDescription
          idstringThe stable id Match.tournament_id joins.
          namestring or null
          tourstring or null (atp, wta, challenger, itf, juniors, null)
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          citystring or nullHost city, from a curated table — null where not curated.
          countrystring or nullHost country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.)
          categorystring or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level.
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          +

          Response fields

          GET /tournaments/{tournamentId} — response fields
          FieldTypeDescription
          idstringThe stable id Match.tournament_id joins.
          namestring or null
          tourstring or null (atp, wta, challenger, itf, juniors, null)
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          citystring or nullHost city, from a curated table — null where not curated.
          countrystring or nullHost country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.)
          categorystring or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level.
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.

          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

          GET /rankings — parameters
          NameInTypeRequiredNotes
          playerqueryarray of integernoPlayer id — repeatable, max 50 (ULTRA per-player mode). OMIT it for the rank-ordered listing mode (PRO), which then requires exactly one system.
          archive_playerqueryarray of integernoHistorical-corpus person id — repeatable, max 50, system=elo only, and REQUIRES tour because that id is only unique within one tour. It is the same id GET /history/archive/players returns as its id. Use it to reach the ~62,000 rated people who have no row in our player roster; player reaches the rest.
          tourquerystring (atp, wta)noREQUIRED for an elo listing and for archive_player. The ATP and WTA Elo walks are disjoint universes whose players never meet, so a combined leaderboard would not be comparable. On an OFFICIAL system it is accepted when it agrees with that system's own tour (system=atp&tour=atp) and REFUSED with 400 when it contradicts it. CORRECTION 2026-09-20: until that date system=atp&tour=wta returned 200 and the ATP table — the parameter was accepted and ignored. If you sent a contradicting pair, the rows you received were the table named by system.
          surfacequeryarray of string (overall, hard, clay, grass)nosystem=elo only. Listing mode takes exactly one (default overall); per-player mode is repeatable and defaults to all four ladders. A ladder the player has never played is omitted, never substituted. REFUSED with 400 when no requested system is elo — there is no clay ATP ranking, so the filter cannot be made to act. CORRECTION 2026-09-20: until that date system=atp&surface=clay returned 200 and the OVERALL ATP table. A mixed system=atp&system=elo&surface=clay still passes, because an elo system is present.
          min_matchesqueryintegernosystem=elo listing only. Minimum matches on that ladder. Every ladder starts at 1500, so without a floor the top of the table is players who won a handful of matches from the starting rating. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 20. Minimum 0.
          activity_weeksqueryintegernosystem=elo listing only. The ladder must have moved within this many weeks of as_of. Elo does not decay, so without an activity window every leaderboard is topped permanently by players who have stopped playing. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 52. Range 1–104.
          as_ofquerystringnoYYYY-MM-DD. Omit for the latest known record.
          systemqueryarray of string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo, atp_doubles, wta_doubles)noRestrict to one or more systems. Omit for all of the official systems — elo, atp_doubles and wta_doubles are NEVER included implicitly and must be named (a doubles tie prints as repeated ranks, as the publisher prints it), so an existing request's response is unchanged. Naming a system your plan does not cover refuses the whole call with 403 rather than silently returning the part you are entitled to.
          limitqueryintegerno Default 50. Range 1–200.
          offsetqueryintegerno Default 0. Minimum 0.

          Responses

          GET /rankings — responses
          StatusMeaning
          200Ranking records in force at as_of
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /rankings — response fields
          FieldTypeDescription
          dataarray of RankingRecord
          metaRankingListMeta
          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 DocsGet a free key
          Explore the documentation
          -

          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

          GET /history/matches/{matchId} — responses
          StatusMeaning
          200The full tape (match + tape + profiles + coverage meta)
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /history/matches/{matchId} — response fields
          FieldTypeDescription
          matchMatch
          tapearray of HistoryTapeRowChronological score sequence — see HistoryTapeRow
          tiebreaksarray or nullPer-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectModel profiles, oldest first (Analysis profile shape)
          metaobject
          meta.match_idinteger
          meta.rowsintegerRows RETURNED — after any sequence=clean collapse.
          meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
          meta.point_sourcestring or null (observed, reconstructed, mixed, null)Where the rows came from — observed (every row watched live), reconstructed (every row expanded after the fact), mixed (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance.
          meta.raw_rowsintegerRows BEFORE any collapse — equals rows when sequence=raw.
          meta.unique_statesintegerDistinct score states in the raw tape. raw_rows minus this is pure repetition.
          meta.sequencestring (raw, clean)Echoes the requested ?sequence=.
          meta.from_archivebooleanThe rows were served from the immutable archive rather than the live table. Informational — the content contract is identical.
          meta.pointsPointsMetaMeasured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on ?points=complete responses (with available_complete); on default reads it appears where enabled. Orthogonal to coverage: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
          meta.tiebreaks_sourcearray or nullAligned to the top-level tiebreaks: per set, where that set's breaker final came from — tape (the served tape reached a terminal breaker state), summary (a source's finished-match summary) or reconstruction (the point-by-point reconstruction) — or null where the entry is null. Null when tiebreaks is null. summary and reconstruction are written by the once-a-day finals pass (see tiebreaks), not at the moment a match ends.
          meta.reconstructed_atstring or nullWhen the newest RECONSTRUCTED row in this response was written (UTC) — when the reconstruction you are holding landed. Null when no reconstructed row is served. Measured on the rows returned, after any sequence=clean collapse. Added 2026-09-20.
          meta.observed_spanobject or nullThe timestamp of the first and last OBSERVED rows in this response, in served order. Null when no observed row is served. Added 2026-09-20.
          meta.observed_span.firststring or null
          meta.observed_span.laststring or null
          meta.enrichmentobjectWhat serve/outcome tags actually landed on the rows of THIS response — the per-point stream's vocabulary. none/none on a wholly reconstructed tape. Added 2026-09-20.
          meta.enrichment.servestring (stated, none)
          meta.enrichment.outcomestring (full, ace_double_fault, none)
          meta.generated_atstring
          +

          Response fields

          GET /history/matches/{matchId} — response fields
          FieldTypeDescription
          matchMatch
          tapearray of HistoryTapeRowChronological score sequence — see HistoryTapeRow
          tiebreaksarray or nullPer-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectModel profiles, oldest first (Analysis profile shape)
          metaobject
          meta.match_idinteger
          meta.rowsintegerRows RETURNED — after any sequence=clean collapse.
          meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
          meta.point_sourcestring or null (observed, reconstructed, mixed, null)Where the rows came from — observed (every row watched live), reconstructed (every row expanded after the fact), mixed (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance.
          meta.raw_rowsintegerRows BEFORE any collapse — equals rows when sequence=raw.
          meta.unique_statesintegerDistinct score states in the raw tape. raw_rows minus this is pure repetition.
          meta.sequencestring (raw, clean)Echoes the requested ?sequence=.
          meta.from_archivebooleanThe rows were served from the immutable archive rather than the live table. Informational — the content contract is identical.
          meta.pointsPointsMetaMeasured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on ?points=complete responses (with available_complete); on default reads it appears where enabled. Orthogonal to coverage: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
          meta.tiebreaks_sourcearray or nullAligned to the top-level tiebreaks: per set, where that set's breaker final came from — tape (the served tape reached a terminal breaker state), summary (a source's finished-match summary) or reconstruction (the point-by-point reconstruction) — or null where the entry is null. Null when tiebreaks is null. summary and reconstruction are written by the once-a-day finals pass (see tiebreaks), not at the moment a match ends.
          meta.reconstructed_atstring or nullWhen the newest RECONSTRUCTED row in this response was written (UTC) — when the reconstruction you are holding landed. Null when no reconstructed row is served. Measured on the rows returned, after any sequence=clean collapse. Added 2026-09-20.
          meta.observed_spanobject or nullThe timestamp of the first and last OBSERVED rows in this response, in served order. Null when no observed row is served. Added 2026-09-20.
          meta.observed_span.firststring or null
          meta.observed_span.laststring or null
          meta.enrichmentobjectWhat serve/outcome tags actually landed on the rows of THIS response — the per-point stream's vocabulary. none/none on a wholly reconstructed tape. Added 2026-09-20.
          meta.enrichment.servestring (stated, none)
          meta.enrichment.outcomestring (full, ace_double_fault, none)
          meta.generated_atstring

          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 DocsGet a free key
          Explore the documentation
          -

          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 @@

          Live Tennis API — Full Reference

          -

          Version 1.13.74 · OpenAPI 3.1.0

          +

          Version 1.13.75 · OpenAPI 3.1.0

          @@ -635,7 +635,7 @@

          Parameters

          GET /matches/{matchId}/score — responses
          StatusMeaning
          200Current score (ULTRA adds win_probability_p1 + danger). On a settled match with no live row, the archived final (since 2026-09-20) — age_seconds, observed_age_seconds, sources_count and accepted_at null.
          401Missing, unknown, or disabled credentials
          404No such match; an upcoming match with no score yet; a cancelled match that was never played; or a settled match with nothing recorded anywhere — no live row and no archived final. Since 2026-09-20 a settled match whose live rows were retired by the 90-day sweep is NOT a 404: it answers 200 with its archived final (age_seconds: null), the same score GET /matches?status=completed embeds for it.
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/score — response fields
          FieldTypeDescription
          setsarray of integer
          gamesarray or null[games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see detail); it never means 0-0, and an empty array is never used to mean "unknown". A deciding set played as a 10-point MATCH TIEBREAK (super-tiebreak) carries the BREAKER's score in its slot WHILE IT IS BEING PLAYED — [[6,4,10],[4,6,5]] is 6-4 3-6 [10-5], not a 10-game set — and is_tiebreak is true then. Once the breaker is over that set is often published as a one-game 1-0: take the match winner from sets, never from the deciding set's games.
          pointsarray of string
          serverinteger or null (1, 2, null)
          is_tiebreakbooleanThe current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose games slot holds the breaker.
          win_probability_p1number or null
          dangernumber or null
          win_probability_p1_modelnumber or nullULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and also on some rows of an anchored match (the anchor shifts the pre-match hold rates, which enter the engine snapped to a 0.01 grid, so a shift smaller than a grid step often solves to an identical value); the per-row test is win_probability_p1 != win_probability_p1_model. Null on rows stamped before it existed (never back-filled).
          win_probability_metaobject or nullULTRA, since 2026-09-12. Our own model-regime identifier (model_version), the UTC instant the two probabilities were generated (generated_at), whether the anchor applies to this MATCH (market_anchored — per match, not per row) and, since 2026-09-15, whether it moved THIS ROW's win_probability_p1 away from the twin (anchor_effective). Null on rows stamped before it existed. SEGREGATING A RECORDED CORPUS — cut on the CLOCK, not on the version string. Two changes moved published live probabilities before model_version reflected them, so split a corpus on generated_at (or the row's timestamp) at these two instants: 2026-08-18T08:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began (some final sets priced as a race-to-ten breaker), and 2026-09-16T07:59:54Z, when the draw-based rule replaced it (main-draw singles priced as a full set — a move of up to 0.20 on affected deciding-set states). model_version is NOT a safe discriminator across the second boundary: the +itfdraw-2026-09-16 suffix first appears on rows generated 2026-09-17T05:04:25Z, 21 h 04 m AFTER the rule itself landed, and in that gap 33,510 states across 351 matches (9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs) were computed under the CORRECTED rule while still carrying markov-population-2026-08-23. Cutting on the version string — or on a whole-day boundary — mislabels exactly those rows; cutting at 2026-09-16T07:59:54Z does not. Measured against production 2026-09-19. CORRECTION (2026-09-19): both instants were first published, the same day, THREE HOURS LATE — 2026-08-18T11:29:28Z and 2026-09-16T11:02:00Z — because the switch times were read off a column stored in local wall time and labelled UTC. The instants above are the corrected UTC ones. If you cut a corpus on either of the old values, re-cut: everything in the three hours before them is on the wrong side. Earlier than that there is no stamp to cut on at all: no row generated before 2026-09-12T12:08:17Z carries model_version or generated_at, and the bulk tape packages have no version column, so for those the row timestamp is the only discriminator. The model_version on an analysis profile (ModelProfile) is a different identifier for a different object — the forecast's prompt scaffold — and does not apply to score rows.
          win_probability_meta.model_versionstringThe model regime that produced this row: engine family, calibration key scheme and — since 2026-09-17 — every pricing rule in force, as a +… suffix. It is not a safe cut for a recorded corpus across the 2026-09-16 boundary; see this object's description for the two instants to segregate on.
          win_probability_meta.generated_atstring or null
          win_probability_meta.market_anchoredbooleanThe 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_effectiveboolean or nullSince 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.
          timestampstring or nullOur 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.
          accepted_atstring or nullSince 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.
          sequenceinteger or nullPer-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_secondsinteger or nullSeconds 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.
          stalebooleanOur 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_secondsinteger or nullSeconds 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_countinteger or nullHow 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_countinteger or nullSince 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.
          corroboratedboolean or nullSince 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_secondsinteger or nullSince 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.
          detailstringPresent only when something is withheld — plain-language reason, e.g. a null games array.
          verdictobject or nullWhy this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. superseded_sequence is the sequence of the refused state, the value a stream consumer holds. reason is the gate's slug. kind is withdrawn (discard the refused state and resume from the served one), deferred (it may be early and is pending the next accept) or withheld (nothing publishable, games is null). safe_to_resume is true exactly when kind is withdrawn. The push feed, the WebSocket and webhooks announce the same judgement as a score_withdrawn frame naming the same sequence, so a stream consumer never has to poll to learn a state was taken back. That frame carries type, match_id, kind, superseded_sequence, reason, safe_to_resume, ts and published_at, and no replacement score: resume from the next score frame or from a fresh read. Since 2026-09-25 this field also answers for a sequence you name with ?sequence=N, from the durable record served by GET /matches/{matchId}/withdrawals — which is how a consumer that missed the frame recovers it.
          verdict.kindstring (withdrawn, deferred, withheld)
          verdict.superseded_sequenceinteger or null
          verdict.reasonstring
          verdict.safe_to_resumeboolean
          +

          Response fields

          GET /matches/{matchId}/score — response fields
          FieldTypeDescription
          setsarray of integer
          gamesarray or null[games_p1, games_p2]; each a per-set list. NULL means WITHHELD — we hold no state for this match we can stand behind (see detail); it never means 0-0, and an empty array is never used to mean "unknown". A deciding set played as a 10-point MATCH TIEBREAK (super-tiebreak) carries the BREAKER's score in its slot WHILE IT IS BEING PLAYED — [[6,4,10],[4,6,5]] is 6-4 3-6 [10-5], not a 10-game set — and is_tiebreak is true then. Once the breaker is over that set is often published as a one-game 1-0: take the match winner from sets, never from the deciding set's games.
          pointsarray of string
          serverinteger or null (1, 2, null)
          is_tiebreakbooleanThe current game is a tiebreak. On a DECIDING set played as a 10-point match tiebreak this is true for the whole set, whose games slot holds the breaker.
          win_probability_p1number or null
          dangernumber or null
          win_probability_p1_modelnumber or nullULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, and also on some rows of an anchored match (the anchor shifts the pre-match hold rates, which enter the engine snapped to a 0.01 grid, so a shift smaller than a grid step often solves to an identical value); the per-row test is win_probability_p1 != win_probability_p1_model. Null on rows stamped before it existed (never back-filled).
          win_probability_metaobject or nullULTRA, since 2026-09-12. Our own model-regime identifier (model_version), the UTC instant the two probabilities were generated (generated_at), whether the anchor applies to this MATCH (market_anchored — per match, not per row) and, since 2026-09-15, whether it moved THIS ROW's win_probability_p1 away from the twin (anchor_effective). Null on rows stamped before it existed. SEGREGATING A RECORDED CORPUS — cut on the CLOCK, not on the version string. Two changes moved published live probabilities before model_version reflected them, so split a corpus on generated_at (or the row's timestamp) at these two instants: 2026-08-18T08:29:28Z, when the over-inclusive deciding-set rule for lower-tier singles began (some final sets priced as a race-to-ten breaker), and 2026-09-16T07:59:54Z, when the draw-based rule replaced it (main-draw singles priced as a full set — a move of up to 0.20 on affected deciding-set states). model_version is NOT a safe discriminator across the second boundary: the +itfdraw-2026-09-16 suffix first appears on rows generated 2026-09-17T05:04:25Z, 21 h 04 m AFTER the rule itself landed, and in that gap 33,510 states across 351 matches (9,719 states over 113 matches at the ITF M15/W15/W35 levels the rule governs) were computed under the CORRECTED rule while still carrying markov-population-2026-08-23. Cutting on the version string — or on a whole-day boundary — mislabels exactly those rows; cutting at 2026-09-16T07:59:54Z does not. Measured against production 2026-09-19. CORRECTION (2026-09-19): both instants were first published, the same day, THREE HOURS LATE — 2026-08-18T11:29:28Z and 2026-09-16T11:02:00Z — because the switch times were read off a column stored in local wall time and labelled UTC. The instants above are the corrected UTC ones. If you cut a corpus on either of the old values, re-cut: everything in the three hours before them is on the wrong side. Earlier than that there is no stamp to cut on at all: no row generated before 2026-09-12T12:08:17Z carries model_version or generated_at, and the bulk tape packages have no version column, so for those the row timestamp is the only discriminator. The model_version on an analysis profile (ModelProfile) is a different identifier for a different object — the forecast's prompt scaffold — and does not apply to score rows.
          win_probability_meta.model_versionstringThe model regime that produced this row: engine family, calibration key scheme and — since 2026-09-17 — every pricing rule in force, as a +… suffix. It is not a safe cut for a recorded corpus across the 2026-09-16 boundary; see this object's description for the two instants to segregate on.
          win_probability_meta.generated_atstring or null
          win_probability_meta.market_anchoredbooleanThe 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_effectiveboolean or nullSince 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.
          timestampstring or nullOur 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_atstring or nullSince 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.
          sequenceinteger or nullPer-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_secondsinteger or nullSeconds 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.
          stalebooleanOur 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_secondsinteger or nullSeconds 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_countinteger or nullHow 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_countinteger or nullSince 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.
          corroboratedboolean or nullSince 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_secondsinteger or nullSince 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.
          detailstringPresent only when something is withheld — plain-language reason, e.g. a null games array.
          verdictobject or nullWhy this read is not the newest state we judged (since 2026-09-23). Null on an ordinary read. Otherwise the legality gate refused a state a stream had already published, and this says which and why. superseded_sequence is the sequence of the refused state, the value a stream consumer holds. reason is the gate's slug. kind is withdrawn (discard the refused state and resume from the served one), deferred (it may be early and is pending the next accept) or withheld (nothing publishable, games is null). safe_to_resume is true exactly when kind is withdrawn. The push feed, the WebSocket and webhooks announce the same judgement as a score_withdrawn frame naming the same sequence, so a stream consumer never has to poll to learn a state was taken back. That frame carries type, match_id, kind, superseded_sequence, reason, safe_to_resume, ts and published_at, and no replacement score: resume from the next score frame or from a fresh read. Since 2026-09-25 this field also answers for a sequence you name with ?sequence=N, from the durable record served by GET /matches/{matchId}/withdrawals — which is how a consumer that missed the frame recovers it.
          verdict.kindstring (withdrawn, deferred, withheld)
          verdict.superseded_sequenceinteger or null
          verdict.reasonstring
          verdict.safe_to_resumeboolean

          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

          GET /events — parameters
          NameInTypeRequiredNotes
          typequerystringnoComma-separated event types (see Event.type), or stoppages for the whole stoppage family.
          after_idqueryintegernoServe rows with id greater than this, ascending. Take it from meta.next_cursor or the last row's id.
          sincequerystringnoFirst-call lower bound, a UTC instant. Rows stamped after it, ascending.
          limitqueryintegerno Default 50. Range 1–200.

          Responses

          GET /events — responses
          StatusMeaning
          200Events across the slate, ascending id
          400bad_type, bad_after_id or bad_since
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /events — response fields
          FieldTypeDescription
          dataarray of SlateEvent
          metaListMeta
          @@ -757,9 +757,9 @@

          GET /matches/{matchId}/statisticsplayers.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

          GET /matches/{matchId}/statistics — responses
          StatusMeaning
          200Statistics with their own coverage and as_of
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/statistics — response fields
          FieldTypeDescription
          match_idinteger
          coveragestring (live, final, stale, none, diverged)
          as_ofstring or nullWhen the underlying record was last updated (UTC)
          age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
          games_countedinteger
          tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
          inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
          sets_coveredarray of integer
          freshnessMatchStatisticsFreshnessPer-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence.
          detailstringPresent only when coverage is none
          playersobject or null
          players.p1MatchStatisticsSideOne 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.
          players.p2MatchStatisticsSideOne 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.
          +

          Response fields

          GET /matches/{matchId}/statistics — response fields
          FieldTypeDescription
          match_idinteger
          coveragestring (live, final, stale, none, diverged)
          as_ofstring or nullWhen the underlying record was last updated (UTC)
          age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
          games_countedinteger
          tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
          inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
          sets_coveredarray of integer
          freshnessMatchStatisticsFreshnessPer-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence.
          detailstringPresent only when coverage is none
          playersobject or null
          players.p1MatchStatisticsSideOne 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.
          players.p2MatchStatisticsSideOne 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.

          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

          GET /matches/{matchId}/points — responses
          StatusMeaning
          200The point events page, seq order
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /matches/{matchId}/points — response fields
          FieldTypeDescription
          match_idinteger
          pbp_coveragestring (point, game)point = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); game = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to point on the first played point — gate on last_seq > 1).
          qualitystring (clean, revised)revised = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
          enrichmentobjectAdded 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.
          enrichment.servestring (stated, none)
          enrichment.outcomestring (full, ace_double_fault, none)
          covers_from_startboolean or nullWhether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from after_seq=0 yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
          pointsarray of LivePoint
          last_seqintegerThe resume cursor — pass as after_seq to continue.
          has_moreboolean
          basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows, served whenever the stream is complete, or tagged and legal end to end (a projection carries no clock and no tag, so the stream is strictly more information); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time, served only when the stream falls short (see basis_reason) — includes the match-closing point, seq contiguous 1..N, ts null on every frame. The two bases are never interleaved.
          basis_reasonstring (stream_absent, stream_incomplete, stream_illegal)Added 2026-09-21. Present on the reconstruction basis only — why the stored live stream was not served. stream_absent = no stream rows; stream_incomplete = the stream is legal but does not measure complete (joined mid-match or stopped short) and carries no tags; stream_illegal = at least one transition is not attributable to one point (a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served (basis live) and this key is absent.
          duplicates_collapsedintegerAdded 2026-10-26. How many rows of this page repeated a point the match had already published (same set, game, number and score) and were left out. The writer no longer stores such repeats; this counts the ones written before that, so paging stays dense and last_seq still advances past them. A page of repeats alone returns points: [] and a cursor.
          ends_at_finalbooleanAdded 2026-09-20. Whether the sequence served ends on the match-closing point — the state after the match-winning point: number 0, score 0-0, server null, winner the match winner. On a completed match served on the live basis that row is appended at read time from the stored stream's last row and the observed final score, and only when the two are one point apart (when the match ends in a tiebreak, the set roll-up after the decisive tiebreak row: tiebreak false, the set banked 7-6, the same winner); false when the match is completed but the stream stops short of it (a retirement or walkover, a truncated capture, or a closer that cannot be stated as one point, such as a 10-point match tiebreak), and always false on a live match. On the reconstruction basis it is judged from the last projected frame.
          +

          Response fields

          GET /matches/{matchId}/points — response fields
          FieldTypeDescription
          match_idinteger
          pbp_coveragestring (point, game)point = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); game = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to point on the first played point — gate on last_seq > 1).
          qualitystring (clean, revised)revised = the point record was rewritten over an already-served prefix at least once during this match; served rows are never edited.
          enrichmentobjectAdded 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.
          enrichment.servestring (stated, none)
          enrichment.outcomestring (full, ace_double_fault, none)
          covers_from_startboolean or nullWhether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from after_seq=0 yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
          pointsarray of LivePoint
          last_seqintegerThe resume cursor — pass as after_seq to continue.
          has_moreboolean
          basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows, served whenever the stream is complete, or tagged and legal end to end (a projection carries no clock and no tag, so the stream is strictly more information); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time, served only when the stream falls short (see basis_reason) — includes the match-closing point, seq contiguous 1..N, ts null on every frame. The two bases are never interleaved.
          basis_reasonstring (stream_absent, stream_incomplete, stream_illegal)Added 2026-09-21. Present on the reconstruction basis only — why the stored live stream was not served. stream_absent = no stream rows; stream_incomplete = the stream is legal but does not measure complete (joined mid-match or stopped short) and carries no tags; stream_illegal = at least one transition is not attributable to one point (a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served (basis live) and this key is absent.
          duplicates_collapsedintegerAdded 2026-10-26. How many rows of this page repeated a point the match had already published (same set, game, number and score) and were left out. The writer no longer stores such repeats; this counts the ones written before that, so paging stays dense and last_seq still advances past them. A page of repeats alone returns points: [] and a cursor.
          ends_at_finalbooleanAdded 2026-09-20. Whether the sequence served ends on the match-closing point — the state after the match-winning point: number 0, score 0-0, server null, winner the match winner. On a completed match served on the live basis that row is appended at read time from the stored stream's last row and the observed final score, and only when the two are one point apart (when the match ends in a tiebreak, the set roll-up after the decisive tiebreak row: tiebreak false, the set banked 7-6, the same winner); false when the match is completed but the stream stops short of it (a retirement or walkover, a truncated capture, or a closer that cannot be stated as one point, such as a 10-point match tiebreak), and always false on a live match. On the reconstruction basis it is judged from the last projected frame.

          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

          GET /players/{playerId} — parameters
          NameInTypeRequiredNotes
          playerIdpathintegeryes

          Responses

          GET /players/{playerId} — responses
          StatusMeaning
          200Player with stats ({ratings, season})
          401Missing, unknown, or disabled credentials
          404No roster player holds this id. When the id IS a corpus person id the body adds detail and see (the archive lookup); otherwise it is the plain Error body.
          410The player id existed and was merged into another player record, or was retired with no successor. The body is PlayerMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404. An id that was NEVER issued keeps its 404 — a 410 would assert it once existed.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /players/{playerId} — response fields
          FieldTypeDescription
          idinteger
          namestring
          tourstring or null (atp, wta, null)The circuit the player is filed under: atp | wta | null (normalised 2026-09-24 — the capitalised and level values no longer appear). Not the competition level (use the match's tier) and not the draw (use the match's draw). A doubles team filed under a circuit of its own carries that circuit; one filed under a level word or nothing falls back to the circuit both its members are filed under, and is null when they disagree or either is unknown. null means not filed, never a guess. Distinct from the match-level tour, whose vocabulary is the tour-group filter's (atp | wta | challenger | itf | juniors).
          countrystring or null
          rankinginteger or nullOfficial singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date.
          ranking_pointsinteger or nullPoints on the same official table, refreshed with ranking.
          ranking_statusstring or null (ranked, unranked, unverified, null)What the last reconcile against the published ATP/WTA singles tables found (added 2026-09-23). ranked = on the table (ranking is that row). unranked = the full table was fetched and the player was not on it (a ranking here is the last value held, not current). unverified = could not be checked (no id, a name mismatch, or never checked). Null on doubles teams.
          ranking_checked_atstring or nullWhen that reconcile ran, UTC (added 2026-09-23).
          ranking_movementstring or null (up, down, same, null)
          itf_rankingobject or nullAdded 2026-10-12, paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The player's CURRENT rank on the ITF's own published World Tennis Tour ranking — the rank most ITF and junior players hold while they have no ATP/WTA one. system names the circuit (itf_mt men's pro, itf_wt women's pro, itf_jt juniors); a player ranked on a pro circuit and the junior one carries the pro rank. rank_date is the ITF's publication date of that row (weekly), so a stale rank is visible as such. NOT comparable with ranking (a circuit rank 1 is tens of points) and never written into it. Joined by the same exact ITF id itf_id exposes at ULTRA, never by name. Null when no ITF link is held, when that id is held by more than one roster row, when no published row exists, and on doubles teams. The weekly history of these ranks stays at /rankings?system=itf_* (ULTRA).
          itf_ranking.systemstring (itf_mt, itf_wt, itf_jt)
          itf_ranking.rankinteger
          itf_ranking.pointsnumber or null
          itf_ranking.rank_datestring
          handstring or null (R, L, null)
          backhandinteger or null (1, 2, null)
          birthdaystring or null
          height_cminteger or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. Height in centimetres from the results archive's record of the same person. Null when the player has no archive record or the archive holds no height. Never inferred.
          doubles_rankingobject or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The current position on the official ATP/WTA doubles table, from our weekly refresh of the published tables. observed_on is the UTC date that refresh last found the player on the table. Null when the player is not on the table or the last sighting is more than 21 days old. Never copied into ranking, which stays the singles position.
          doubles_ranking.rankinteger
          doubles_ranking.pointsinteger or null
          doubles_ranking.observed_onstring
          itf_idinteger or nullULTRA only (added 2026-09-24). The player's identifier in the ITF ranking tables we publish under system=itf_* — the itf_jt / itf_mt / itf_wt rows of /rankings are linked to this player by it (an id join, never a name match). One human's id across singles and doubles rows, so a join key, not a per-row primary key. Null when no ITF link is held and on doubles teams. Absent, not null, below ULTRA.
          is_doubles_teamboolean
          profile_restated_atstring or nullWhen 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_versioninteger1 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_idinteger or nullThe 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_completenessobjectHow 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.knowninteger or nullFields 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.ofinteger or nullFields considered. NULL on a doubles team, as for known.
          data_completeness.missingarray of stringNames of the unpopulated fields, e.g. ["backhand", "hand"].
          data_completeness.notestringPresent only when the object is not applicable, explaining why (e.g. a doubles team).
          statsobjectSingle-player endpoint only
          stats.ratingsobject or null
          stats.ratings_as_ofstring or nullThe date (UTC) the ratings block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          stats.seasonarray or null
          +

          Response fields

          GET /players/{playerId} — response fields
          FieldTypeDescription
          idinteger
          namestring
          tourstring or null (atp, wta, null)The circuit the player is filed under: atp | wta | null (normalised 2026-09-24 — the capitalised and level values no longer appear). Not the competition level (use the match's tier) and not the draw (use the match's draw). A doubles team filed under a circuit of its own carries that circuit; one filed under a level word or nothing falls back to the circuit both its members are filed under, and is null when they disagree or either is unknown. null means not filed, never a guess. Distinct from the match-level tour, whose vocabulary is the tour-group filter's (atp | wta | challenger | itf | juniors).
          countrystring or null
          rankinginteger or nullOfficial singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date.
          ranking_pointsinteger or nullPoints on the same official table, refreshed with ranking.
          ranking_statusstring or null (ranked, unranked, unverified, null)What the last reconcile against the published ATP/WTA singles tables found (added 2026-09-23). ranked = on the table (ranking is that row). unranked = the full table was fetched and the player was not on it (a ranking here is the last value held, not current). unverified = could not be checked (no id, a name mismatch, or never checked). Null on doubles teams.
          ranking_checked_atstring or nullWhen that reconcile ran, UTC (added 2026-09-23).
          ranking_movementstring or null (up, down, same, null)
          itf_rankingobject or nullAdded 2026-10-12, paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The player's CURRENT rank on the ITF's own published World Tennis Tour ranking — the rank most ITF and junior players hold while they have no ATP/WTA one. system names the circuit (itf_mt men's pro, itf_wt women's pro, itf_jt juniors); a player ranked on a pro circuit and the junior one carries the pro rank. rank_date is the ITF's publication date of that row (weekly), so a stale rank is visible as such. NOT comparable with ranking (a circuit rank 1 is tens of points) and never written into it. Joined by the same exact ITF id itf_id exposes at ULTRA, never by name. Null when no ITF link is held, when that id is held by more than one roster row, when no published row exists, and on doubles teams. The weekly history of these ranks stays at /rankings?system=itf_* (ULTRA).
          itf_ranking.systemstring (itf_mt, itf_wt, itf_jt)
          itf_ranking.rankinteger
          itf_ranking.pointsnumber or null
          itf_ranking.rank_datestring
          handstring or null (R, L, null)
          backhandinteger or null (1, 2, null)
          birthdaystring or null
          height_cminteger or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. Height in centimetres from the results archive's record of the same person. Null when the player has no archive record or the archive holds no height. Never inferred.
          doubles_rankingobject or nullAdded 2026-10-22. Single-player read (/players/{playerId}) only; paid tiers (BASIC, PRO, ULTRA), null on a FREE key. The current position on the official ATP/WTA doubles table, from our weekly refresh of the published tables. observed_on is the UTC date that refresh last found the player on the table. Null when the player is not on the table or the last sighting is more than 21 days old. Never copied into ranking, which stays the singles position.
          doubles_ranking.rankinteger
          doubles_ranking.pointsinteger or null
          doubles_ranking.observed_onstring
          itf_idinteger or nullULTRA only (added 2026-09-24). The player's identifier in the ITF ranking tables we publish under system=itf_* — the itf_jt / itf_mt / itf_wt rows of /rankings are linked to this player by it (an id join, never a name match). One human's id across singles and doubles rows, so a join key, not a per-row primary key. Null when no ITF link is held and on doubles teams. Absent, not null, below ULTRA.
          is_doubles_teamboolean
          profile_restated_atstring or nullWhen 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_versioninteger1 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_idinteger or nullThe 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_completenessobjectHow 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.knowninteger or nullFields 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.ofinteger or nullFields considered. NULL on a doubles team, as for known.
          data_completeness.missingarray of stringNames of the unpopulated fields, e.g. ["backhand", "hand"].
          data_completeness.notestringPresent only when the object is not applicable, explaining why (e.g. a doubles team).
          statsobjectSingle-player endpoint only
          stats.ratingsobject or null
          stats.ratings_as_ofstring or nullThe date (UTC) the ratings block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          stats.seasonarray or null

          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

          GET /tournaments/{tournamentId} — parameters
          NameInTypeRequiredNotes
          tournamentIdpathstringyesThe tournament_id carried on match objects.

          Responses

          GET /tournaments/{tournamentId} — responses
          StatusMeaning
          200The tournament
          401Missing, unknown, or disabled credentials
          404No such resource, or no data yet
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /tournaments/{tournamentId} — response fields
          FieldTypeDescription
          idstringThe stable id Match.tournament_id joins.
          namestring or null
          tourstring or null (atp, wta, challenger, itf, juniors, null)
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          citystring or nullHost city, from a curated table — null where not curated.
          countrystring or nullHost country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.)
          categorystring or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level.
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          +

          Response fields

          GET /tournaments/{tournamentId} — response fields
          FieldTypeDescription
          idstringThe stable id Match.tournament_id joins.
          namestring or null
          tourstring or null (atp, wta, challenger, itf, juniors, null)
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          citystring or nullHost city, from a curated table — null where not curated.
          countrystring or nullHost country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.)
          categorystring or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level.
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.

          Example

          curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
             -H "Authorization: Bearer twjp_..."
          @@ -983,7 +983,7 @@

          Parameters

          GET /history/matches/{matchId} — responses
          StatusMeaning
          200The full tape (match + tape + profiles + coverage meta)
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /history/matches/{matchId} — response fields
          FieldTypeDescription
          matchMatch
          tapearray of HistoryTapeRowChronological score sequence — see HistoryTapeRow
          tiebreaksarray or nullPer-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectModel profiles, oldest first (Analysis profile shape)
          metaobject
          meta.match_idinteger
          meta.rowsintegerRows RETURNED — after any sequence=clean collapse.
          meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
          meta.point_sourcestring or null (observed, reconstructed, mixed, null)Where the rows came from — observed (every row watched live), reconstructed (every row expanded after the fact), mixed (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance.
          meta.raw_rowsintegerRows BEFORE any collapse — equals rows when sequence=raw.
          meta.unique_statesintegerDistinct score states in the raw tape. raw_rows minus this is pure repetition.
          meta.sequencestring (raw, clean)Echoes the requested ?sequence=.
          meta.from_archivebooleanThe rows were served from the immutable archive rather than the live table. Informational — the content contract is identical.
          meta.pointsPointsMetaMeasured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on ?points=complete responses (with available_complete); on default reads it appears where enabled. Orthogonal to coverage: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
          meta.tiebreaks_sourcearray or nullAligned to the top-level tiebreaks: per set, where that set's breaker final came from — tape (the served tape reached a terminal breaker state), summary (a source's finished-match summary) or reconstruction (the point-by-point reconstruction) — or null where the entry is null. Null when tiebreaks is null. summary and reconstruction are written by the once-a-day finals pass (see tiebreaks), not at the moment a match ends.
          meta.reconstructed_atstring or nullWhen the newest RECONSTRUCTED row in this response was written (UTC) — when the reconstruction you are holding landed. Null when no reconstructed row is served. Measured on the rows returned, after any sequence=clean collapse. Added 2026-09-20.
          meta.observed_spanobject or nullThe timestamp of the first and last OBSERVED rows in this response, in served order. Null when no observed row is served. Added 2026-09-20.
          meta.observed_span.firststring or null
          meta.observed_span.laststring or null
          meta.enrichmentobjectWhat serve/outcome tags actually landed on the rows of THIS response — the per-point stream's vocabulary. none/none on a wholly reconstructed tape. Added 2026-09-20.
          meta.enrichment.servestring (stated, none)
          meta.enrichment.outcomestring (full, ace_double_fault, none)
          meta.generated_atstring
          +

          Response fields

          GET /history/matches/{matchId} — response fields
          FieldTypeDescription
          matchMatch
          tapearray of HistoryTapeRowChronological score sequence — see HistoryTapeRow
          tiebreaksarray or nullPer-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectModel profiles, oldest first (Analysis profile shape)
          metaobject
          meta.match_idinteger
          meta.rowsintegerRows RETURNED — after any sequence=clean collapse.
          meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
          meta.point_sourcestring or null (observed, reconstructed, mixed, null)Where the rows came from — observed (every row watched live), reconstructed (every row expanded after the fact), mixed (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance.
          meta.raw_rowsintegerRows BEFORE any collapse — equals rows when sequence=raw.
          meta.unique_statesintegerDistinct score states in the raw tape. raw_rows minus this is pure repetition.
          meta.sequencestring (raw, clean)Echoes the requested ?sequence=.
          meta.from_archivebooleanThe rows were served from the immutable archive rather than the live table. Informational — the content contract is identical.
          meta.pointsPointsMetaMeasured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on ?points=complete responses (with available_complete); on default reads it appears where enabled. Orthogonal to coverage: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
          meta.tiebreaks_sourcearray or nullAligned to the top-level tiebreaks: per set, where that set's breaker final came from — tape (the served tape reached a terminal breaker state), summary (a source's finished-match summary) or reconstruction (the point-by-point reconstruction) — or null where the entry is null. Null when tiebreaks is null. summary and reconstruction are written by the once-a-day finals pass (see tiebreaks), not at the moment a match ends.
          meta.reconstructed_atstring or nullWhen the newest RECONSTRUCTED row in this response was written (UTC) — when the reconstruction you are holding landed. Null when no reconstructed row is served. Measured on the rows returned, after any sequence=clean collapse. Added 2026-09-20.
          meta.observed_spanobject or nullThe timestamp of the first and last OBSERVED rows in this response, in served order. Null when no observed row is served. Added 2026-09-20.
          meta.observed_span.firststring or null
          meta.observed_span.laststring or null
          meta.enrichmentobjectWhat serve/outcome tags actually landed on the rows of THIS response — the per-point stream's vocabulary. none/none on a wholly reconstructed tape. Added 2026-09-20.
          meta.enrichment.servestring (stated, none)
          meta.enrichment.outcomestring (full, ace_double_fault, none)
          meta.generated_atstring

          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

          GET /history/archive/matches/{archiveId}/tape — parameters
          NameInTypeRequiredNotes
          archiveIdpathintegeryesAn archive_matches.id — the id on a /history/archive/matches row, never a match id.

          Responses

          GET /history/archive/matches/{archiveId}/tape — responses
          StatusMeaning
          200The reconstructed tape (match + tape + tiebreaks + an always-empty profiles + provenance meta)
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No tape for this archive result — it is 1968–2012, it is 2013–2022 and was never reconstructed, or a reconstruction exists and did not prove its binding. The three are deliberately not distinguished: "we hold something we will not stand behind" is not a state this product reports.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /history/archive/matches/{archiveId}/tape — response fields
          FieldTypeDescription
          matchArchiveMatchOne result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will.
          tapearray of HistoryTapeRowThe score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game.
          tiebreaksarray or nullPer-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectAlways empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match.
          metaobject
          meta.archive_match_idintegerThe archive_matches.id this tape belongs to. Named in full because it is NOT a match id — passing it to a /matches/... route resolves a different, real record without erroring.
          meta.tiebreaks_sourcearray or nullAligned to tiebreaks, same contract as on HistoryTape. Every populated entry here reads reconstruction — the archive tape IS the reconstruction and nothing else is consulted.
          meta.rowsintegerRows returned. Read next to granularity before comparing it with a 2023+ tape's row count. Across the corpus a tape averages 146 rows (median 138, range 4-570); a per-game tape carries one row per game and is far shorter than a point tape of the same match.
          meta.coveragestring (reconstructed, reconstructed_partial)reconstructed (96.3% of the corpus) = the tape reproduces the published final score exactly, and that score is a match that finished. reconstructed_partial (3.7%) HAS TWO CAUSES and the label does not say which: either the match itself stopped early (a retirement or abandonment prefix), OR the tape is per-GAME — every one of the 556 game-granular tapes carries this label however complete it is, because the coverage vocabulary is closed and game has no word of its own. It therefore does NOT on its own mean incomplete match coverage: read granularity and the match's own score/outcome for that. Never from_start or partial: both assert we watched the match, and nobody watched these. On the 2023+ tape the same word means only short-at-either-end.
          meta.granularitystring (point, game)What one row resolves to. point = the record advances within a game — 99.4% of the corpus (97,293 matches). game = one row per game, with points expanded only inside tiebreaks — 556 matches, 555 of them in 2013 and one in 2014; every season from 2015 on is 100% point-granular. point_winner / winner on a per-game tape read the side whose game count rose on each row, and nothing about the game's other points. Orthogonal to coverage as an axis, though a game tape is always labelled reconstructed_partial.
          meta.point_sourcestring (reconstructed)Always reconstructed. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take.
          meta.model_rowsintegerAlways 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape.
          meta.observed_rowsintegerAlways 0 — no row here was watched live.
          meta.point_source_detailstringAlways 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.
          meta.sequencestring (raw)Always raw. There is nothing to collapse — a reconstructed tape has one source and no corrections, so ?sequence=clean has no meaning here; the parameter is ignored, not refused.
          meta.generated_atstring
          +

          Response fields

          GET /history/archive/matches/{archiveId}/tape — response fields
          FieldTypeDescription
          matchArchiveMatchOne result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will.
          tapearray of HistoryTapeRowThe score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game.
          tiebreaksarray or nullPer-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere.
          tiebreaks[].p1integer
          tiebreaks[].p2integer
          profilesarray of objectAlways empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match.
          metaobject
          meta.archive_match_idintegerThe archive_matches.id this tape belongs to. Named in full because it is NOT a match id — passing it to a /matches/... route resolves a different, real record without erroring.
          meta.tiebreaks_sourcearray or nullAligned to tiebreaks, same contract as on HistoryTape. Every populated entry here reads reconstruction — the archive tape IS the reconstruction and nothing else is consulted.
          meta.rowsintegerRows returned. Read next to granularity before comparing it with a 2023+ tape's row count. Across the corpus a tape averages 146 rows (median 138, range 4-570); a per-game tape carries one row per game and is far shorter than a point tape of the same match.
          meta.coveragestring (reconstructed, reconstructed_partial)reconstructed (96.3% of the corpus) = the tape reproduces the published final score exactly, and that score is a match that finished. reconstructed_partial (3.7%) HAS TWO CAUSES and the label does not say which: either the match itself stopped early (a retirement or abandonment prefix), OR the tape is per-GAME — every one of the 556 game-granular tapes carries this label however complete it is, because the coverage vocabulary is closed and game has no word of its own. It therefore does NOT on its own mean incomplete match coverage: read granularity and the match's own score/outcome for that. Never from_start or partial: both assert we watched the match, and nobody watched these. On the 2023+ tape the same word means only short-at-either-end.
          meta.granularitystring (point, game)What one row resolves to. point = the record advances within a game — 99.4% of the corpus (97,293 matches). game = one row per game, with points expanded only inside tiebreaks — 556 matches, 555 of them in 2013 and one in 2014; every season from 2015 on is 100% point-granular. point_winner / winner on a per-game tape read the side whose game count rose on each row, and nothing about the game's other points. Orthogonal to coverage as an axis, though a game tape is always labelled reconstructed_partial.
          meta.point_sourcestring (reconstructed)Always reconstructed. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take.
          meta.model_rowsintegerAlways 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape.
          meta.observed_rowsintegerAlways 0 — no row here was watched live.
          meta.point_source_detailstringAlways 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.
          meta.sequencestring (raw)Always raw. There is nothing to collapse — a reconstructed tape has one source and no corrections, so ?sequence=clean has no meaning here; the parameter is ignored, not refused.
          meta.generated_atstring

          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

          GET /rankings — parameters
          NameInTypeRequiredNotes
          playerqueryarray of integernoPlayer id — repeatable, max 50 (ULTRA per-player mode). OMIT it for the rank-ordered listing mode (PRO), which then requires exactly one system.
          archive_playerqueryarray of integernoHistorical-corpus person id — repeatable, max 50, system=elo only, and REQUIRES tour because that id is only unique within one tour. It is the same id GET /history/archive/players returns as its id. Use it to reach the ~62,000 rated people who have no row in our player roster; player reaches the rest.
          tourquerystring (atp, wta)noREQUIRED for an elo listing and for archive_player. The ATP and WTA Elo walks are disjoint universes whose players never meet, so a combined leaderboard would not be comparable. On an OFFICIAL system it is accepted when it agrees with that system's own tour (system=atp&tour=atp) and REFUSED with 400 when it contradicts it. CORRECTION 2026-09-20: until that date system=atp&tour=wta returned 200 and the ATP table — the parameter was accepted and ignored. If you sent a contradicting pair, the rows you received were the table named by system.
          surfacequeryarray of string (overall, hard, clay, grass)nosystem=elo only. Listing mode takes exactly one (default overall); per-player mode is repeatable and defaults to all four ladders. A ladder the player has never played is omitted, never substituted. REFUSED with 400 when no requested system is elo — there is no clay ATP ranking, so the filter cannot be made to act. CORRECTION 2026-09-20: until that date system=atp&surface=clay returned 200 and the OVERALL ATP table. A mixed system=atp&system=elo&surface=clay still passes, because an elo system is present.
          min_matchesqueryintegernosystem=elo listing only. Minimum matches on that ladder. Every ladder starts at 1500, so without a floor the top of the table is players who won a handful of matches from the starting rating. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 20. Minimum 0.
          activity_weeksqueryintegernosystem=elo listing only. The ladder must have moved within this many weeks of as_of. Elo does not decay, so without an activity window every leaderboard is topped permanently by players who have stopped playing. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 52. Range 1–104.
          as_ofquerystringnoYYYY-MM-DD. Omit for the latest known record.
          systemqueryarray of string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo, atp_doubles, wta_doubles)noRestrict to one or more systems. Omit for all of the official systems — elo, atp_doubles and wta_doubles are NEVER included implicitly and must be named (a doubles tie prints as repeated ranks, as the publisher prints it), so an existing request's response is unchanged. Naming a system your plan does not cover refuses the whole call with 403 rather than silently returning the part you are entitled to.
          limitqueryintegerno Default 50. Range 1–200.
          offsetqueryintegerno Default 0. Minimum 0.

          Responses

          GET /rankings — responses
          StatusMeaning
          200Ranking records in force at as_of
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /rankings — response fields
          FieldTypeDescription
          dataarray of RankingRecord
          metaRankingListMeta
          @@ -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

          GET /rally/matches/{rallyMatchId} — parameters
          NameInTypeRequiredNotes
          rallyMatchIdpathintegeryes
          limitqueryintegerno Default 50. Range 1–200.
          offsetqueryintegerno Default 0. Minimum 0.

          Responses

          GET /rally/matches/{rallyMatchId} — responses
          StatusMeaning
          200The charted match with its rally points
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /rally/matches/{rallyMatchId} — response fields
          FieldTypeDescription
          rally_match_idintegerThe id this product is keyed on.
          source_idstring
          match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
          datestring or null
          tournamentstring or null
          roundstring or null
          surfacestring or null
          genderstring or null (M, W, null)
          best_ofinteger or null
          playersarray of object
          players[].namestring or null
          players[].handstring or null (R, L, U, A, null)
          pointsintegerCharted points in this match.
          points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
          metaListMeta
          rallyarray of RallyPoint
          +

          Response fields

          GET /rally/matches/{rallyMatchId} — response fields
          FieldTypeDescription
          rally_match_idintegerThe id this product is keyed on.
          source_idstring
          match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table.
          datestring or null
          tournamentstring or null
          roundstring or null
          surfacestring or null
          genderstring or null (M, W, null)
          best_ofinteger or null
          playersarray of object
          players[].namestring or null
          players[].handstring or null (R, L, U, A, null)
          pointsintegerCharted points in this match.
          points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
          metaListMeta
          rallyarray of RallyPoint

          Example

          curl https://api.livetennisapi.com/api/public/v1/rally/matches/{rallyMatchId} \
             -H "Authorization: Bearer twjp_..."
          @@ -1189,7 +1189,7 @@

          Parameters

          GET /history/matches/{matchId}/rally — responses
          StatusMeaning
          200The charted match with its rally points
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
          -

          Response fields

          GET /history/matches/{matchId}/rally — response fields
          FieldTypeDescription
          rally_match_idintegerThe id this product is keyed on.
          source_idstring
          match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
          datestring or null
          tournamentstring or null
          roundstring or null
          surfacestring or null
          genderstring or null (M, W, null)
          best_ofinteger or null
          playersarray of object
          players[].namestring or null
          players[].handstring or null (R, L, U, A, null)
          pointsintegerCharted points in this match.
          points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
          metaListMeta
          rallyarray of RallyPoint
          +

          Response fields

          GET /history/matches/{matchId}/rally — response fields
          FieldTypeDescription
          rally_match_idintegerThe id this product is keyed on.
          source_idstring
          match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table.
          datestring or null
          tournamentstring or null
          roundstring or null
          surfacestring or null
          genderstring or null (M, W, null)
          best_ofinteger or null
          playersarray of object
          players[].namestring or null
          players[].handstring or null (R, L, U, A, null)
          pointsintegerCharted points in this match.
          points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
          metaListMeta
          rallyarray of RallyPoint

          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

          GET /charting/players — parameters
          NameInTypeRequiredNotes
          namequerystringyesPlayer name (fragment, min 3 chars).
          genderquerystring (men, women)noDisambiguates a fragment that matches one charted person per tour side.

          Responses

          GET /charting/players — responses
          StatusMeaning
          200The player's summed charting families with the sample size
          400Bad query parameter
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /charting/players — response fields
          FieldTypeDescription
          playerobject
          matches_chartedinteger
          coveragestring
          familiesobjectPer-family summed numeric columns.
          @@ -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

          GET /charting/matches/{chartingMatchId} — parameters
          NameInTypeRequiredNotes
          chartingMatchIdpathintegeryes

          Responses

          GET /charting/matches/{chartingMatchId} — responses
          StatusMeaning
          200The charted match's stat families, both players, per set
          401Missing, unknown, or disabled credentials
          403Your tier doesn't unlock this endpoint
          404No such resource, or no data yet
          429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

          Response fields

          GET /charting/matches/{chartingMatchId} — response fields
          FieldTypeDescription
          charting_match_idinteger
          mcp_idstring
          genderstring
          playersobject
          familiesobject
          @@ -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_atstring or null win_probability_meta.market_anchoredbooleanThe 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_effectiveboolean or nullSince 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. -timestampstring or nullOur 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. +timestampstring or nullOur 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_atstring or nullSince 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. sequenceinteger or nullPer-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_secondsinteger or nullSeconds 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. stalebooleanOur 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_secondsinteger or nullSeconds 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_countinteger or nullHow 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_countinteger or nullSince 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_countinteger or nullSince 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. corroboratedboolean or nullSince 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_secondsinteger or nullSince 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. detailstringPresent only when something is withheld — plain-language reason, e.g. a null games array. @@ -1773,7 +1773,7 @@

          Player

          profile_restated_atstring or nullWhen 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_versioninteger1 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_idinteger or nullThe 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_completenessobjectHow 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_completenessobjectHow 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.knowninteger or nullFields 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.ofinteger or nullFields considered. NULL on a doubles team, as for known. data_completeness.missingarray of stringNames of the unpopulated fields, e.g. ["backhand", "hand"]. @@ -1788,23 +1788,23 @@

          Match

          - - + + - - + + - - - + + - - - - - + + + + + @@ -1824,23 +1824,23 @@

          MatchDetail

          Match schema — fields
          FieldTypeDescription
          idinteger
          tournamentstring
          tourstring or null (atp, wta, challenger, itf, juniors, null)The tour, in the SAME vocabulary the tour query filter accepts — both are derived from one registry, so a match selected by ?tour=X always carries that value in tour. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.
          tournament_idstring or nullStable 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.)
          tourstring or null (atp, wta, challenger, itf, juniors, null)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_idstring or nullStable 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.)
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level the tournament was played at IN THIS MATCH'S SEASON (added 2026-09-22) — the official category as the tour publishes it, in a closed vocabulary: grand_slam; the ATP levels (atp_finals, atp_1000, atp_500, atp_250, next_gen_finals); the WTA levels (wta_finals, wta_elite_trophy — last held 2023 — wta_1000, wta_500, wta_250, wta_125); the ATP Challenger levels (challenger_175 … challenger_50); the ITF World Tennis Tour categories (itf_m15, itf_m25, itf_w15 … itf_w100 — the 2023 women's categories itf_w25/itf_w40/itf_w60/itf_w80 are kept as printed that season, not mapped onto the 2024 ladder, because the level a match was played at is a fact about that season); the team events (united_cup, davis_cup and bjk_cup in every group and stage, laver_cup, olympics); juniors; utr; and exhibition (no ranking points — Hopman Cup class). Same vocabulary as the ?tier= filter, read from the same per-season table, so filter and field cannot disagree. Resolved by the MATCH'S season (the calendar year of scheduled_time), never copied from the tournament row: a tournament keeps one tournament_id across seasons while its level moves — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025, so a 2024 Dallas match reads atp_250 and a 2025 one atp_500 under the same id. Coverage of the 2023–2026 seasons was verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest are deliberately null — the Asian Games tennis event, which no tour lists). A new event may read null for a short while after its first match until its tier is resolved. Null when nothing we hold names the level — never guessed; null is "we do not know", never a level. category on /tournaments is unchanged and remains the coarse class (challenger, itf, atp_250 …); read this field for the level.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          formatstring or null (BO3, BO5, null)
          roundstring or nullThe 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.
          round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. -IT 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.
          is_qualifyingboolean or nullWhether 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. +
          roundstring or nullThe 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_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. +IT 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_qualifyingboolean or nullWhether 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. THREE-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.
          statusstring (upcoming, live, completed, cancelled)The settled lifecycle. completed is asserted only for a match we observed being played or whose match-winner market settled decisively (one side paid out) — a closed market alone never finishes a match (rule stated 2026-08-23; rows mis-published before it were corrected). cancelled with event_status: null means we hold positive evidence the match was not played as scheduled (the market settled void) but no vendor word for why — outcome and the fixture reason stay null rather than guessed, and the row upgrades to a completed walkover with winner / withdrew if a Walk Over with a stated winner lands later. The flip upcoming → cancelled runs through a gate (well past the scheduled time, no play recorded), never inline on a feed flap.
          event_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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.
          event_status_updated_atstring or nullWhen event_status last CHANGED, UTC (ISO-8601, Z) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while event_status has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.
          is_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)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.
          statusstring (upcoming, live, completed, cancelled)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_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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_atstring or nullWhen 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_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)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_timestring or null
          live_atstring or nullThe instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after scheduled_time, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
          result_restated_atstring or nullWhen the published result of this match was LAST corrected (UTC), added 2026-09-22 — the instant of the newest restatement row on GET /matches/{matchId}/status-history. A correction is any change to status, event_status, winner or the final score (sets, games) after the match was first published as completed (or as a cancelled walkover with a winner). Null when the result stands as first published. Present on every match surface. Poll GET /history/matches?restated_since= to find corrections.
          - - + + - - + + - - - + + - - - - - + + + + + @@ -1947,7 +1947,7 @@

          PointPrice

          SlateEvent

          An Event as served by the slate-wide feed (GET /events) — the per-match row plus its id and match.

          MatchDetail schema — fields
          FieldTypeDescription
          idinteger
          tournamentstring
          tourstring or null (atp, wta, challenger, itf, juniors, null)The tour, in the SAME vocabulary the tour query filter accepts — both are derived from one registry, so a match selected by ?tour=X always carries that value in tour. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.
          tournament_idstring or nullStable 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.)
          tourstring or null (atp, wta, challenger, itf, juniors, null)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_idstring or nullStable 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.)
          tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level the tournament was played at IN THIS MATCH'S SEASON (added 2026-09-22) — the official category as the tour publishes it, in a closed vocabulary: grand_slam; the ATP levels (atp_finals, atp_1000, atp_500, atp_250, next_gen_finals); the WTA levels (wta_finals, wta_elite_trophy — last held 2023 — wta_1000, wta_500, wta_250, wta_125); the ATP Challenger levels (challenger_175 … challenger_50); the ITF World Tennis Tour categories (itf_m15, itf_m25, itf_w15 … itf_w100 — the 2023 women's categories itf_w25/itf_w40/itf_w60/itf_w80 are kept as printed that season, not mapped onto the 2024 ladder, because the level a match was played at is a fact about that season); the team events (united_cup, davis_cup and bjk_cup in every group and stage, laver_cup, olympics); juniors; utr; and exhibition (no ranking points — Hopman Cup class). Same vocabulary as the ?tier= filter, read from the same per-season table, so filter and field cannot disagree. Resolved by the MATCH'S season (the calendar year of scheduled_time), never copied from the tournament row: a tournament keeps one tournament_id across seasons while its level moves — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025, so a 2024 Dallas match reads atp_250 and a 2025 one atp_500 under the same id. Coverage of the 2023–2026 seasons was verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest are deliberately null — the Asian Games tennis event, which no tour lists). A new event may read null for a short while after its first match until its tier is resolved. Null when nothing we hold names the level — never guessed; null is "we do not know", never a level. category on /tournaments is unchanged and remains the coarse class (challenger, itf, atp_250 …); read this field for the level.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
          surfacestring or null (hard, clay, grass, null)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.
          indoorboolean
          formatstring or null (BO3, BO5, null)
          roundstring or nullThe 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.
          round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. -IT 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.
          is_qualifyingboolean or nullWhether 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. +
          roundstring or nullThe 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_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. +IT 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_qualifyingboolean or nullWhether 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. THREE-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.
          statusstring (upcoming, live, completed, cancelled)The settled lifecycle. completed is asserted only for a match we observed being played or whose match-winner market settled decisively (one side paid out) — a closed market alone never finishes a match (rule stated 2026-08-23; rows mis-published before it were corrected). cancelled with event_status: null means we hold positive evidence the match was not played as scheduled (the market settled void) but no vendor word for why — outcome and the fixture reason stay null rather than guessed, and the row upgrades to a completed walkover with winner / withdrew if a Walk Over with a stated winner lands later. The flip upcoming → cancelled runs through a gate (well past the scheduled time, no play recorded), never inline on a feed flap.
          event_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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.
          event_status_updated_atstring or nullWhen event_status last CHANGED, UTC (ISO-8601, Z) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while event_status has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.
          is_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)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.
          statusstring (upcoming, live, completed, cancelled)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_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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_atstring or nullWhen 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_doublesbooleanDoubles 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.
          drawstring or null (singles, doubles, null)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_timestring or null
          live_atstring or nullThe instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after scheduled_time, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
          result_restated_atstring or nullWhen the published result of this match was LAST corrected (UTC), added 2026-09-22 — the instant of the newest restatement row on GET /matches/{matchId}/status-history. A correction is any change to status, event_status, winner or the final score (sets, games) after the match was first published as completed (or as a cancelled walkover with a winner). Null when the result stands as first published. Present on every match surface. Poll GET /history/matches?restated_since= to find corrections.
          - + @@ -1963,7 +1963,7 @@

          SlateEvent

          Event

          SlateEvent schema — fields
          FieldTypeDescription
          typestring (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end)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.
          typestring (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end)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.
          playerinteger or null (1, 2, null)
          timestampstring or null
          atstring or nullStoppage family — the UTC instant the stoppage happened (for a pause, its last score change).
          - + @@ -2037,7 +2037,7 @@

          BroadcastObject

          ScoreWithdrawal

          One withdrawn score state on a match (added 2026-09-25), from the durable record behind GET /matches/{matchId}/withdrawals.

          Event schema — fields
          FieldTypeDescription
          typestring (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end)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.
          typestring (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end)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.
          playerinteger or null (1, 2, null)
          timestampstring or null
          atstring or nullStoppage family — the UTC instant the stoppage happened (for a pause, its last score change).
          - + @@ -2091,22 +2091,22 @@

          ModelProfile

          HistoryTapeRow

          -

          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).

          +

          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).

          ScoreWithdrawal schema — fields
          FieldTypeDescription
          sequenceintegerThe refused state's sequence — the value you hold from the feed, and the frame's superseded_sequence.
          sequenceintegerThe refused state's sequence — the value you hold from the push feed, and the frame's superseded_sequence.
          kindstring (withdrawn, deferred, withheld)The frame's kind. Only withdrawn means discard and resume.
          reasonstringThe legality gate's slug, the same value the frame carries.
          published_atstring or nullWhen the refused state's OWN score frame was handed to the push feed. Null when that state never went out on the feed — the judgement was still made and the REST read served it.
          - + - + - +
          HistoryTapeRow schema — fields
          FieldTypeDescription
          originstring (observed, reconstructed)observed — a row our own pipeline committed while the point was being played; reconstructed — a row expanded after the fact from a finished-match point-by-point record (null timestamp, null model fields). From the stored provenance, never inferred from a null timestamp. Added 2026-09-20.
          serveinteger or null (1, 2, null)The serve the point was played on (1 first, 2 second), joined from this match's per-point stream: paired by EXACT score state — set count, every set's games, in-game points and tiebreak flag all equal — and, where a state recurs (a deuce cycle), by nearest clock within 90 seconds (the tape row's observed clock against the stream point's), each stream point used at most once; otherwise null. Observed rows only: null on every reconstructed row, on a row no stream point matches, and on a recurring state with no clock within the window or two at the same distance. Never inferred from the score or the pattern of play. Added 2026-09-20; clock pairing 2026-09-21.
          outcomestring or null (ace, double_fault, winner, forced_error, unforced_error, null)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.
          outcomestring or null (ace, double_fault, winner, forced_error, unforced_error, null)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.
          setsarray of integer
          gamesarray of array of integer
          pointsarray of stringIn-game points as tennis strings ("0", "15", "30", "40", "AD") — EXCEPT during a tiebreak, where they are the running tiebreak count as plain integer strings ("0", "1", "2", …); is_tiebreak tells the two notations apart.
          serverinteger or null (1, 2, null)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.
          serverinteger or null (1, 2, null)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_tiebreakboolean
          tiebreak_finalarray or nullAdded 2026-09-24. The breaker's closing score in our player order, e.g. [2, 7], on the row that CLOSES a tiebreak set. Absent on every other row, including the breaker rows themselves, which stay exactly as they were observed. Same value, from the same resolver, as this set's entry in the response's tiebreaks array, so the two never disagree. The row that closes a set carries the decided games before the set counter catches up, and since 2026-09-24 the games on the row decide it, so the field does not depend on which row the counter moves on. Before that a set whose counter lagged by a row was never stamped and stayed empty on every later read, while tiebreaks stated the final.
          win_probability_p1number or nullThe model read stamped on this row while the match was live. The tape carries NO model-regime stamp — there is no model_version or generated_at column here, nor in the bulk tape packages — so segregate a recorded tape corpus by this row's timestamp against the two regime boundaries documented on Score.win_probability_meta: 2026-08-18T08:29:28Z and 2026-09-16T07:59:54Z.
          dangernumber or null
          timestampstring or null
          accepted_atstring or nullOn tape rows since 2026-09-24. The instant our arbiter accepted this state (UTC), stamped once at insert and never refreshed by a re-assertion. This is the clock to difference a timing study against: timestamp starts equal to it and is then refreshed while the owning source keeps re-asserting the unchanged state, so on most raw rows timestamp is a later re-assertion instant (measured on match 189679: 164 of its 171 observed rows carry a timestamp more than a second after accepted_at). Null on every reconstructed row, which never had a clock. IT ALSO HAS A START DATE ON THE ARCHIVED TAPE, and this description claimed the opposite ("it rides the whole observed corpus") until 2026-10-01. THAT DATE MOVED BACK SIX WEEKS ON 2026-10-01. A tape answered from the archive (meta.from_archive: true) now carries the clock on every observed row from 2026-08-11: 100.0% of every day from 2026-08-11 to 2026-10-01, 92.5% of 2026-08-10 (the live column's own first instant is 2026-08-10T08:07:42Z), and 0.0% of every day before that. Over the whole archived observed corpus, 1,721,102 of 4,663,181 rows carry it (36.9%, against 307,631 before the backfill): 0.0% of every month to July 2026, 68.8% of August, 100.0% of September and of October. The boundary was 2026-09-23, the day the archive column landed, until a backfill took each missing value from the live row that archived row had been copied from: 1,413,471 clocks and 1,393,458 counters, inside 1,538,176 rows over 14,491 matches. Nothing is derived, rounded or taken from a neighbouring row, and a row whose live row was already trimmed at the 90-day retention stays null for good. Wire-verified on match 2168 (26 April 2026), before the boundary: 131 observed rows, timestamp on all 131 and accepted_at on none. Match 189679's own archived copy carried the clock on 0 of its 171 observed rows before that run and on 171 of 171 after it, which is why the 189679 measurement above holds either way: that match is served with from_archive: false. So read meta.from_archive before planning a timing study, and treat a null here as a boundary of the record rather than as a missing value.
          received_atstring or nullOn 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.
          received_atstring or nullOn 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.
          sequenceinteger or nullOn tape rows since 2026-09-26. This state's per-match ACCEPT COUNTER — the same number GET /matches/{matchId}/withdrawals publishes as sequence and replaced_by_sequence, and the same counter as Score.sequence on a live read. It is what makes the withdrawal ledger's pointer resolvable: told "sequence 104 was withdrawn, resume from 103", you can now find 103 on the tape, and because a withdrawn state never enters the record at all the withdrawal shows up here as a GAP in the run of counters. Before this the tape published no such number, so the pointer named a row nothing identified — reported by an Ultra customer who wrote that "the history rows lack sequence IDs, so these responses alone cannot reconstruct exactly what a live client received". NULL, NEVER FABRICATED, IN TWO CASES, and both are honest gaps rather than errors: a row whose archived copy could not be filled from its own live row, and the pre-2023 archive tape, which has no such counter because no live accept ever happened. The 2026-10-01 backfill moved the first of those from 2026-09-26 back to 2026-08-14, so the archived counter is on 68.2% of 2026-08-14, 99.3% of 08-15, 95.9% or more of every day from 2026-08-16 and 100% from 2026-09-01; before that the counter died with the live row at the 90-day retention. Unlike the two clocks it is NOT null on a reconstructed row: a counter is a fact about our own accept order, so wherever one is present a live reader really did receive that state at that position.
          winnerinteger or null (1, 2, null)Who won the point that produced this row — 1 | 2, judged from the score step between this row and the previous served row (the previous raw row on the raw sequence, the previous clean row under ?sequence=clean), by the same rule the completeness ledger counts a legal transition with — and from nothing else: never the serve, the server, the outcome tag or the pattern of play. Null on the first row and wherever the step is not one attributable point: a re-sent row (an identical state — null on raw, collapsed away on clean), a set opener (same games totals, the arrays extended by [0,0]), a backward correction, a multi-game jump. A step whose games total rises by exactly one attributes to the side whose count rose (the game point was theirs) whatever in-game points the step skipped — a missed poll, or a game the strict expander withheld whole; meta.points.games_withheld says when the latter happened. Present on every row of every sequence, observed and reconstructed, and on the pre-2023 ArchiveTape; equal to point_winner wherever that older key is present. Derived once per response, never stored. Added 2026-09-22.
          point_winnerinteger or null (1, 2, null)The same verdict as winner under the older key, kept unchanged for existing readers. Who won the point this row records — PRESENT ONLY on ?sequence=clean and ?points=complete rows, and only where the transition from the previous row of the served sequence is a single attributable point; null on gaps, torn rows and the first row. Never on the plain raw sequence (raw is deliberately non-monotonic: consecutive raw rows are corrections, not points — a points=complete read is the exception, because there the served order IS point order). Derived at read time, never stored or guessed. Since 2026-09-22 winner carries the same verdict on every row of every sequence.
          @@ -2123,7 +2123,7 @@

          HistoryTape

          metaobject meta.match_idinteger meta.rowsintegerRows RETURNED — after any sequence=clean collapse. -meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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. +meta.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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. meta.point_sourcestring or null (observed, reconstructed, mixed, null)Where the rows came from — observed (every row watched live), reconstructed (every row expanded after the fact), mixed (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance. meta.raw_rowsintegerRows BEFORE any collapse — equals rows when sequence=raw. meta.unique_statesintegerDistinct score states in the raw tape. raw_rows minus this is pure repetition. @@ -2149,14 +2149,14 @@

          PointsMeta

          rowsintegerRows measured — always equals meta.rows. transitions_legalintegerTransitions attributable to exactly one side winning one point. transitions_totalintegerAll row-to-row transitions, excluding re-sent rows (rows − 1 − resent_rows). -resent_rowsintegerRows 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. +resent_rowsintegerRows 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_rowsintegerRows carrying a server. starts_at_lovebooleanThe first row is the 0-0 opening state. ends_at_finalbooleanThe last row is a complete, legal final scoreline AND shows no game in progress: points at love or null, not a tiebreak in progress. A row carrying a finished spine with a game still underway (e.g. [[6,6],[3,4]] at 15-0) reads false (since 2026-09-21; before that the spine alone decided). basisstring (raw, clean, recon)Which serve was measured: raw | clean (the default read, as returned) or recon (the whole reconstruction, ?points=complete). available_completeboolean or null?points=complete responses only. Whether a point-complete reconstruction exists for this match — never guessed. Nightly, refreshed on read when absent: the nightly ledger's verdict when it has one; when the ledger has no entry yet (a reconstruction that landed after the last nightly run) and this read served a reconstruction, it is the live verdict just measured on it (complete); null only when there is no ledger entry and nothing to measure. Added 2026-09-20. games_shortinteger or nullA completed game for which the tape holds fewer point transitions than that game must have contained; > 0 forces complete to false; nothing is inferred or repaired. Counted by walking the sequence by game boundary and comparing what is held for each completed game with the FEWEST points that game can have contained given the last state it shows (from 0-0 at least 4; from 15-30 at least 5; from 40-40 at least 8; a tiebreak at least 7) — a lower bound, never an estimate. A game the tape skips whole (0-0 → 0-0 with the games counter up by one) counts; so does a game that ended with no further rows after its last stored point. The game's boundary rows are still served exactly as stored. null = nothing measured (an empty sequence). Added 2026-09-21. -games_withheldinteger or nullPresent 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.

          +games_withheldinteger or nullPresent 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.

HistoryPackage

@@ -2203,23 +2203,23 @@

HistoryMatch

- - + + - - + + - - - + + - - - - - + + + + + @@ -2234,7 +2234,7 @@

HistoryMatch

- + @@ -2269,14 +2269,14 @@

Usage

MatchStatisticsSide

-

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.

+

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.

HistoryMatch schema — fields
FieldTypeDescription
idinteger
tournamentstring
tourstring or null (atp, wta, challenger, itf, juniors, null)The tour, in the SAME vocabulary the tour query filter accepts — both are derived from one registry, so a match selected by ?tour=X always carries that value in tour. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.
tournament_idstring or nullStable 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.)
tourstring or null (atp, wta, challenger, itf, juniors, null)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_idstring or nullStable 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.)
tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level the tournament was played at IN THIS MATCH'S SEASON (added 2026-09-22) — the official category as the tour publishes it, in a closed vocabulary: grand_slam; the ATP levels (atp_finals, atp_1000, atp_500, atp_250, next_gen_finals); the WTA levels (wta_finals, wta_elite_trophy — last held 2023 — wta_1000, wta_500, wta_250, wta_125); the ATP Challenger levels (challenger_175 … challenger_50); the ITF World Tennis Tour categories (itf_m15, itf_m25, itf_w15 … itf_w100 — the 2023 women's categories itf_w25/itf_w40/itf_w60/itf_w80 are kept as printed that season, not mapped onto the 2024 ladder, because the level a match was played at is a fact about that season); the team events (united_cup, davis_cup and bjk_cup in every group and stage, laver_cup, olympics); juniors; utr; and exhibition (no ranking points — Hopman Cup class). Same vocabulary as the ?tier= filter, read from the same per-season table, so filter and field cannot disagree. Resolved by the MATCH'S season (the calendar year of scheduled_time), never copied from the tournament row: a tournament keeps one tournament_id across seasons while its level moves — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025, so a 2024 Dallas match reads atp_250 and a 2025 one atp_500 under the same id. Coverage of the 2023–2026 seasons was verified event by event against the official calendars (99.1% of tournament-seasons resolved; the rest are deliberately null — the Asian Games tennis event, which no tour lists). A new event may read null for a short while after its first match until its tier is resolved. Null when nothing we hold names the level — never guessed; null is "we do not know", never a level. category on /tournaments is unchanged and remains the coarse class (challenger, itf, atp_250 …); read this field for the level.
tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
surfacestring or null (hard, clay, grass, null)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.
tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
surfacestring or null (hard, clay, grass, null)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.
indoorboolean
formatstring or null (BO3, BO5, null)
roundstring or nullThe 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.
round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. -IT 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.
is_qualifyingboolean or nullWhether 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. +
roundstring or nullThe 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_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)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. +IT 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_qualifyingboolean or nullWhether 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. THREE-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.
statusstring (upcoming, live, completed, cancelled)The settled lifecycle. completed is asserted only for a match we observed being played or whose match-winner market settled decisively (one side paid out) — a closed market alone never finishes a match (rule stated 2026-08-23; rows mis-published before it were corrected). cancelled with event_status: null means we hold positive evidence the match was not played as scheduled (the market settled void) but no vendor word for why — outcome and the fixture reason stay null rather than guessed, and the row upgrades to a completed walkover with winner / withdrew if a Walk Over with a stated winner lands later. The flip upcoming → cancelled runs through a gate (well past the scheduled time, no play recorded), never inline on a feed flap.
event_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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.
event_status_updated_atstring or nullWhen event_status last CHANGED, UTC (ISO-8601, Z) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while event_status has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.
is_doublesbooleanDoubles 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.
drawstring or null (singles, doubles, null)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.
statusstring (upcoming, live, completed, cancelled)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_statusstring or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null)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_atstring or nullWhen 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_doublesbooleanDoubles 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.
drawstring or null (singles, doubles, null)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_timestring or null
live_atstring or nullThe instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after scheduled_time, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
result_restated_atstring or nullWhen the published result of this match was LAST corrected (UTC), added 2026-09-22 — the instant of the newest restatement row on GET /matches/{matchId}/status-history. A correction is any change to status, event_status, winner or the final score (sets, games) after the match was first published as completed (or as a cancelled walkover with a winner). Null when the result stands as first published. Present on every match surface. Poll GET /history/matches?restated_since= to find corrections.
has_analysisbooleanWhether a model thesis or profile exists for this match — on every list row and the detail, every tier (since 2026-09-02). Filter the slate on this before calling /matches/{matchId}/analysis, which answers 404 no_analysis about the same fact.
has_marketbooleanWhether a match-winner market is mapped to this match (every tier, since 2026-09-02). Same role for /markets/{matchId}/prices (404 no_market).
tapeobjectWhat point-by-point data we hold for this match.
tape.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
tape.coveragestring (from_start, partial, reconstructed, reconstructed_partial, none)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.
tape.rowsintegerRows we OBSERVED (watched live). This is not the length of the tape you will be served: a reconstructed or mixed tape also includes reconstructed rows, and for a partially-watched match only a prefix of the reconstruction is spliced in front. Use meta.rows on the per-match tape for the served length.
tape.reconstructed_rowsintegerReconstructed rows available for this match.
tape.model_rowsintegerHow many of the OBSERVED rows carry a non-null win_probability_p1. The model stamp is best-effort and no coverage value — from_start included — implies it; read this number to select tapes the model actually scored.
- +TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. 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. +A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read as a rounded figure. @@ -2300,12 +2300,12 @@

MatchStatisticsSide

MatchStatisticsMeasured

-

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. +

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. EVERY FIELD IS OPTIONAL AND AN ABSENT FIELD IS OMITTED, never zero-filled — absent means NOT MEASURED, and a present 0 is a real measured zero. Do not assume a fixed shape; read the keys you are given. Measured over 24,552 stored payloads (2026-07-31), coverage falls into three tiers: TIER 1 — present on 98.6-100% of payloads, on every tour including ITF: aces, double faults, points won, service and return points won, break points won, service games won, longest point and game runs, return points. TIER 2 — present on essentially all ATP, WTA and Challenger matches and on NO ITF singles match: the first and second serve split, break points saved, tiebreaks won, games won, service games played. -TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. The upstream feed has NOT DELIVERED this group at all since 2026-07-12 (measured 2026-08-17: 0 of 4,513 August payloads carry it, live ATP Masters included), so expect it absent on current matches until the upstream feed resumes it. -A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read from the upstream rounding.

+TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. 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. +A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read as a rounded figure.

MatchStatisticsSide schema — fields
FieldTypeDescription
measuredMatchStatisticsMeasuredMeasured 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. +
measuredMatchStatisticsMeasuredMeasured 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. EVERY FIELD IS OPTIONAL AND AN ABSENT FIELD IS OMITTED, never zero-filled — absent means NOT MEASURED, and a present 0 is a real measured zero. Do not assume a fixed shape; read the keys you are given. Measured over 24,552 stored payloads (2026-07-31), coverage falls into three tiers: TIER 1 — present on 98.6-100% of payloads, on every tour including ITF: aces, double faults, points won, service and return points won, break points won, service games won, longest point and game runs, return points. TIER 2 — present on essentially all ATP, WTA and Challenger matches and on NO ITF singles match: the first and second serve split, break points saved, tiebreaks won, games won, service games played. -TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. The upstream feed has NOT DELIVERED this group at all since 2026-07-12 (measured 2026-08-17: 0 of 4,513 August payloads carry it, live ATP Masters included), so expect it absent on current matches until the upstream feed resumes it. -A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read from the upstream rounding.
service_games_playedinteger
service_games_woninteger
hold_pctinteger or nullnull when no service game was played — never 0, so a present 0 is a real measured zero
@@ -2354,7 +2354,7 @@

MatchStatisticsMeasured

- + @@ -2382,7 +2382,7 @@

MatchStatisticsFamily

- +
MatchStatisticsMeasured schema — fields
FieldTypeDescription
acesinteger or null
break_points_woninteger or null
groundstroke_winnersinteger or nullWinners off the ground strokes.
lob_unforced_errorsinteger or nullUnforced errors on the lob.
lob_winnersinteger or nullWinners on the lob.
overhead_errorsinteger or nullForced errors on the overhead — the upstream's overhead-stroke count.
overhead_errorsinteger or nullForced errors on the overhead — the overhead-stroke count.
overhead_winnersinteger or nullWinners on the overhead.
return_errorsinteger or nullForced errors on the return.
return_winnersinteger or nullWinners on the return.
coveragestring (live, final, stale, none, diverged)final = the closing figures of a completed match. A finished match cannot be "stale", so on final the family's age_seconds is null; as_of still says when the last point/fetch was recorded.
as_ofstring or null
age_secondsinteger or null
describesobject or nullThe match state these statistics describe, per upstream. age_seconds says when we fetched; this says WHAT we fetched. Null when unavailable.
describesobject or nullThe match state these statistics describe, as recorded with them. age_seconds says when we fetched; this says WHAT we fetched. Null when unavailable.
describes.games_p1array of integer
describes.games_p2array of integer
describes.total_gamesinteger
@@ -2401,8 +2401,8 @@

MatchStatistics

freshnessMatchStatisticsFreshnessPer-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence. detailstringPresent only when coverage is none playersobject or null -players.p1MatchStatisticsSideOne 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. -players.p2MatchStatisticsSideOne 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.
+players.p1MatchStatisticsSideOne 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. +players.p2MatchStatisticsSideOne 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.

RankingRecord

@@ -2507,7 +2507,7 @@

LivePoint

gameinteger or nullGame within the current set — 13 for a 6-6 tiebreak. numberinteger or nullPoints already recorded in this game at this state — 0 for a game's opening state, and **null when we joined the game already in progress**: how many points it holds is not derivable from the score (the deuce zone maps many ordinals onto one score) and is never guessed. (set, game, number) is NOT a unique key — a game re-expanded by a second source legitimately re-asserts ordinals it already holds, so the same tuple can name more than one row. seq is the only unique, stable per-row key. tiebreakboolean -serverinteger or null (1, 2, null)Server of the point played FROM this state; null when the feed omits it. +serverinteger or null (1, 2, null)Server of the point played FROM this state; null when it is not stated. winnerinteger or null (1, 2, null)Who won the point that produced this state — null when not attributable to a single point, never guessed. scoreobjectIn-game score AFTER the point ('0'/'15'/'30'/'40'/'AD'; the running tiebreak count as integer strings when tiebreak is true). score.p1string or null @@ -2515,8 +2515,8 @@

LivePoint

setsarray of integer or null gamesarray of array of integer[games_p1, games_p2]; each a per-set list, frozen at the pre-breaker score during a tiebreak. tsstring or nullCapture time (UTC) — when our pipeline committed this state. Null on every frame of a reconstruction-basis page — the recorded sequence carries no per-point clock and none is fabricated. On the match-closing row of a completed match it is the instant the final score was observed. -serveinteger or null (1, 2, null)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. -outcomestring or null (ace, double_fault, winner, forced_error, unforced_error, null)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. +serveinteger or null (1, 2, null)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. +outcomestring or null (ace, double_fault, winner, forced_error, unforced_error, null)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_atstring or nullAdded 2026-09-20. When serve/outcome landed on this row (UTC); null while no tag has — on every reconstruction-basis frame, on the match-closing row, and on any row no source tagged. Tags land a few seconds after the row itself, so a row's tagged_at is later than its ts; changed_since selects on either. tiebreak_finalarray or nullAdded 2026-09-23. The breaker's closing score, in our player order, e.g. [7, 5]. Present ONLY on the row that closes a tiebreak set — the set roll-up (tiebreak false, the set banked 7-6, score 0-0), which is the row carrying the breaker's winning point, every row being the state after a point. Absent on every other row, including the breaker rows themselves. Exact when the previous row is the decided score or one point from it (6-5 for the set's winner gives [7, 5]); otherwise taken from the match's recorded tiebreak finals, the same finals GET /history/matches/{matchId} publishes as tiebreaks; null when neither can state it. Never guessed. A page that *starts* on a roll-up row (an after_seq landing exactly there) reads the one row before your page, so the value does not depend on where the page was cut (since 2026-09-24; before that such a page used the recorded finals alone, and so read null on a set that closed while the match was still live). The deciding set of a match-tiebreak format is judged under the 10-point rule.
@@ -2536,8 +2536,8 @@

MatchPoints

- - + + @@ -2567,13 +2567,13 @@

Tournament

- + -
MatchPoints schema — fields
FieldTypeDescription
match_idinteger
pbp_coveragestring (point, game)point = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); game = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to point on the first played point — gate on last_seq > 1).
qualitystring (clean, revised)revised = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
enrichmentobjectAdded 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.
qualitystring (clean, revised)revised = the point record was rewritten over an already-served prefix at least once during this match; served rows are never edited.
enrichmentobjectAdded 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.
enrichment.servestring (stated, none)
enrichment.outcomestring (full, ace_double_fault, none)
covers_from_startboolean or nullWhether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from after_seq=0 yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
idstringThe stable id Match.tournament_id joins.
namestring or null
tourstring or null (atp, wta, challenger, itf, juniors, null)
surfacestring or null (hard, clay, grass, null)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.
surfacestring or null (hard, clay, grass, null)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.
indoorboolean
citystring or nullHost city, from a curated table — null where not curated.
countrystring or nullHost country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.)
categorystring or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null)Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level.
tierstring or null (grand_slam, atp_finals, atp_1000, atp_500, atp_250, next_gen_finals, united_cup, davis_cup, olympics, laver_cup, wta_finals, wta_elite_trophy, wta_1000, wta_500, wta_250, wta_125, bjk_cup, challenger_175, challenger_125, challenger_100, challenger_75, challenger_50, itf_m15, itf_m25, itf_w15, itf_w25, itf_w35, itf_w40, itf_w50, itf_w60, itf_w75, itf_w80, itf_w100, juniors, utr, exhibition, null)The level this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed.
tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.
+tier_sourcestring or null (calendar, name, wikipedia, resolver, null)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.

ArchiveMatch

@@ -2683,7 +2683,7 @@

ArchiveTape

meta.point_sourcestring (reconstructed)Always reconstructed. It is a property of the dataset, not a measurement of these rows, and there is no other value it can take. meta.model_rowsintegerAlways 0 — no model output exists for a pre-2023 match. Present so a client parsing both halves of the tape product reads one shape. meta.observed_rowsintegerAlways 0 — no row here was watched live. -meta.point_source_detailstringAlways 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. +meta.point_source_detailstringAlways 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. meta.sequencestring (raw)Always raw. There is nothing to collapse — a reconstructed tape has one source and no corrections, so ?sequence=clean has no meaning here; the parameter is ignored, not refused. meta.generated_atstring
@@ -2718,7 +2718,7 @@

RallyMatch

- + diff --git a/docs/shot-level-rally-data.html b/docs/shot-level-rally-data.html index 54822f1..e97ccab 100644 --- a/docs/shot-level-rally-data.html +++ b/docs/shot-level-rally-data.html @@ -202,7 +202,7 @@
Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.74

+

Live Tennis API · docs · version 1.13.75

Shot-by-shot tennis rally and charting API

Is there tennis data below the point — shot by shot? Yes, for charted matches. GET /rally/matches lists them and /rally/matches/{rallyMatchId} returns the rally construction; GET /history/matches/{matchId}/rally reaches the same data by our own match id so a live match and its charting share one identifier. GET /charting/players aggregates a player's career at shot level and /charting/matches/{chartingMatchId} returns every stat family for both players in one charted match. All ULTRA.

5 endpoints on this page. Base URL @@ -241,7 +241,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

RallyMatch schema — fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table.
datestring or null
tournamentstring or null
roundstring or null
GET /rally/matches/{rallyMatchId} — parameters
NameInTypeRequiredNotes
rallyMatchIdpathintegeryes
limitqueryintegerno Default 50. Range 1–200.
offsetqueryintegerno Default 0. Minimum 0.

Responses

GET /rally/matches/{rallyMatchId} — responses
StatusMeaning
200The charted match with its rally points
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
-

Response fields

GET /rally/matches/{rallyMatchId} — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
players[].namestring or null
players[].handstring or null (R, L, U, A, null)
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaListMeta
rallyarray of RallyPoint
+

Response fields

GET /rally/matches/{rallyMatchId} — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
players[].namestring or null
players[].handstring or null (R, L, U, A, null)
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaListMeta
rallyarray of RallyPoint

Example

curl https://api.livetennisapi.com/api/public/v1/rally/matches/{rallyMatchId} \
   -H "Authorization: Bearer twjp_..."
@@ -257,14 +257,14 @@

Parameters

GET /history/matches/{matchId}/rally — responses
StatusMeaning
200The charted match with its rally points
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
410The match id existed and was merged into another match record (since 2026-09-05). The body is MatchMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404.
429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
-

Response fields

GET /history/matches/{matchId}/rally — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
players[].namestring or null
players[].handstring or null (R, L, U, A, null)
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaListMeta
rallyarray of RallyPoint
+

Response fields

GET /history/matches/{matchId}/rally — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches are older than our match table.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
players[].namestring or null
players[].handstring or null (R, L, U, A, null)
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaListMeta
rallyarray of RallyPoint

Example

curl https://api.livetennisapi.com/api/public/v1/history/matches/18953/rally \
   -H "Authorization: Bearer twjp_..."

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

GET /charting/players — parameters
NameInTypeRequiredNotes
namequerystringyesPlayer name (fragment, min 3 chars).
genderquerystring (men, women)noDisambiguates a fragment that matches one charted person per tour side.

Responses

GET /charting/players — responses
StatusMeaning
200The player's summed charting families with the sample size
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

Response fields

GET /charting/players — response fields
FieldTypeDescription
playerobject
matches_chartedinteger
coveragestring
familiesobjectPer-family summed numeric columns.
@@ -274,7 +274,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

GET /charting/matches/{chartingMatchId} — parameters
NameInTypeRequiredNotes
chartingMatchIdpathintegeryes

Responses

GET /charting/matches/{chartingMatchId} — responses
StatusMeaning
200The charted match's stat families, both players, per set
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

Response fields

GET /charting/matches/{chartingMatchId} — response fields
FieldTypeDescription
charting_match_idinteger
mcp_idstring
genderstring
playersobject
familiesobject
diff --git a/docs/sitemap.xml b/docs/sitemap.xml index f67e174..f5ab4ee 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -12,5 +12,5 @@ https://docs.livetennisapi.com/push-feed-and-webhooks.htmlweekly0.8 https://docs.livetennisapi.com/auth-quota-and-health.htmlweekly0.8 https://docs.livetennisapi.com/ai-sdk.html2026-10-22weekly0.8 - https://docs.livetennisapi.com/changelog.html2026-10-26weekly0.6 + https://docs.livetennisapi.com/changelog.html2026-10-28weekly0.6 diff --git a/docs/tennis-odds.html b/docs/tennis-odds.html index 9854798..1295592 100644 --- a/docs/tennis-odds.html +++ b/docs/tennis-odds.html @@ -202,7 +202,7 @@
Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.74

+

Live Tennis API · docs · version 1.13.75

Tennis odds API — markets and price ticks

How do you read tennis match-winner odds and their price history? GET /markets returns the match-winner market for a match and GET /markets/{matchId}/prices the recent price ticks per side, newest first; GET /matches/{matchId}/prices is the same ticks addressed by match id. All three are PRO. Every tick carries its own timestamp, so a series can be reconstructed rather than inferred from poll times.

3 endpoints on this page. Base URL diff --git a/openapi.yaml b/openapi.yaml index 9c2bd81..a544902 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,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 @@ -70,11 +70,11 @@ info: 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. @@ -413,7 +413,7 @@ paths: 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-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: @@ -755,8 +755,8 @@ paths: 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 + 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. @@ -767,7 +767,7 @@ paths: 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 + a minority of main-tour matches and have not been available since 2026-07-12 (measured 2026-08-17). @@ -820,8 +820,8 @@ paths: `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 + 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 @@ -854,7 +854,7 @@ paths: 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 + 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). @@ -1924,8 +1924,8 @@ paths: 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. + 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 @@ -1973,7 +1973,7 @@ paths: 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 + 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 @@ -2406,7 +2406,7 @@ paths: 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 — + 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 @@ -2657,8 +2657,7 @@ paths: 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 + 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 — @@ -2707,7 +2706,7 @@ paths: get: summary: One charted match, every stat family for both players (ULTRA) description: >- - Every Match Charting Project stat family for one charted match, both + 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). @@ -3675,8 +3674,8 @@ components: 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 + 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: @@ -3784,7 +3783,7 @@ components: 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: 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 @@ -4570,7 +4569,7 @@ components: 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 + 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: @@ -4618,8 +4617,8 @@ components: 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 + `?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: @@ -4627,10 +4626,9 @@ components: 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 + 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 @@ -4719,7 +4717,7 @@ components: 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 + 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 @@ -4738,8 +4736,8 @@ components: 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. + 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 } @@ -4747,28 +4745,28 @@ components: round: type: [string, "null"] description: >- - The feed's own free-text round label, verbatim. It is the round + 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 - for most feeds — parse the suffix after the last `" - "`, or + on most matches — parse the suffix after the last `" - "`, or better, use `round_code`. round_code: type: [string, "null"] enum: [F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null] description: >- The round in the archive's controlled vocabulary, normalized from - the free-text label above (`Q` = qualifying round the feed does + 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. IT 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 + 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: @@ -4776,8 +4774,8 @@ components: 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 + 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.) @@ -4805,17 +4803,17 @@ components: 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 — + 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 feed flap. + scheduled time, no play recorded), never inline on a momentary status flap. event_status: type: [string, "null"] enum: [Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null] description: >- - The feed's own designator for the match state, passed through + 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 @@ -4825,7 +4823,7 @@ components: 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, + 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 @@ -4844,7 +4842,7 @@ components: 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 + 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 @@ -4855,7 +4853,7 @@ components: 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 + 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. @@ -4867,8 +4865,8 @@ components: 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 + 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. @@ -5227,17 +5225,17 @@ components: `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 + WTA 125 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 + 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: - a public live-score service's match stage is watched for its - medical-timeout and interruption stages — present in its + 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 @@ -5354,7 +5352,7 @@ components: sequence: type: integer description: >- - The refused state's `sequence` — the value you hold from the feed, + The refused state's `sequence` — the value you hold from the push feed, and the frame's `superseded_sequence`. kind: type: string @@ -5528,7 +5526,7 @@ components: 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 + 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: @@ -5575,7 +5573,7 @@ components: - unforced_error - null description: >- - How the point ended, as an outside source states it — same join + 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: @@ -5598,7 +5596,7 @@ components: type: [integer, "null"] enum: [1, 2, null] description: >- - Who is serving — null where the feed never asserted it. On + 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 @@ -5695,7 +5693,7 @@ components: 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 + 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 @@ -5991,8 +5989,8 @@ components: 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. + 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 description: Rows carrying a server. @@ -6045,7 +6043,7 @@ components: type: [integer, "null"] description: >- Present only when reconstructed rows are in the response. Games - whose vendor point record was internally inconsistent (the + 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 @@ -6053,8 +6051,8 @@ components: 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. + this check, or was built by merging more than one point record + rather than from a single one. Added 2026-09-20. HistoryPackage: type: object description: >- @@ -6195,7 +6193,7 @@ components: 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 + 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 @@ -6424,8 +6422,8 @@ components: 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 + 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. @@ -6460,7 +6458,7 @@ components: type: object description: >- Measured counting statistics for one player. These are COUNTED - upstream, not derived from the point record — which is why they can + 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. @@ -6484,14 +6482,14 @@ components: 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 + 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 the - upstream feed resumes it. + Masters included), so expect it absent on current matches until it + returns. A `_of` suffix is the denominator of its base field and a `_pct` suffix - is the percentage, recomputed from the two counts rather than read from - the upstream rounding. + is the percentage, recomputed from the two counts rather than read + as a rounded figure. properties: aces: { type: [integer, "null"] } break_points_won: { type: [integer, "null"] } @@ -6582,7 +6580,7 @@ components: description: Winners on the lob. overhead_errors: type: [integer, "null"] - description: Forced errors on the overhead — the upstream's + description: Forced errors on the overhead — the overhead-stroke count. overhead_winners: type: [integer, "null"] @@ -6650,7 +6648,7 @@ components: describes: type: [object, "null"] description: >- - The match state these statistics describe, per upstream. + 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: @@ -6967,8 +6965,8 @@ components: type: [integer, "null"] enum: [1, 2, null] description: >- - Server of the point played FROM this state; null when the feed - omits it. + Server of the point played FROM this state; null when it is + not stated. winner: type: [integer, "null"] enum: [1, 2, null] @@ -7009,7 +7007,7 @@ components: enum: [1, 2, null] description: >- Added 2026-09-12. The serve the point was played on (1 first, 2 - second) as an outside source states it, joined onto the row by + 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 — @@ -7042,9 +7040,9 @@ components: 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 + 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 the console's + 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` @@ -7053,22 +7051,21 @@ components: type: [string, "null"] enum: [ace, double_fault, winner, forced_error, unforced_error, null] description: >- - Added 2026-09-12. How the point ended, as an outside source states - it. ATP 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 + 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 the source went live on + 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 tour's - console reports the match — read the page's `enrichment` per + 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 @@ -7094,10 +7091,10 @@ components: 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 + 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 the console's complete sequence by the + 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. @@ -7170,12 +7167,12 @@ components: type: string enum: [clean, revised] description: >- - `revised` = the upstream feed rewrote an already-served prefix at + `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 + 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. @@ -7295,12 +7292,10 @@ components: 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. + 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 } city: type: [string, "null"] @@ -7385,7 +7380,7 @@ components: 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 + 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 @@ -7673,8 +7668,9 @@ components: description: Always 0 — no row here was watched live. 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 + 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 @@ -7761,7 +7757,7 @@ components: type: [integer, "null"] description: >- OUR match id, when the charted match is also one we hold. Null - otherwise — most charted matches predate our own collection. + otherwise — most charted matches are older than our match table. date: { type: [string, "null"], format: date } tournament: { type: [string, "null"] } round: { type: [string, "null"] } diff --git a/scripts/build_reference.py b/scripts/build_reference.py index ac65073..4ccd883 100644 --- a/scripts/build_reference.py +++ b/scripts/build_reference.py @@ -1096,11 +1096,11 @@ def build_html(spec: dict[str, Any]) -> str:

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 @@ -1306,11 +1306,11 @@ def build_llms_txt(spec: dict[str, Any]) -> str: "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", @@ -1555,7 +1555,7 @@ def build_changelog(spec: dict[str, Any], reference_html: str) -> tuple[str, str "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. " + "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." ),