---
title: "Per-match tape — point-by-point score + per-point model probabilities (BASIC)"
method: GET
path: "/history/matches/{matchId}"
---

# Per-match tape — point-by-point score + per-point model probabilities (BASIC)

`GET /history/matches/{matchId}`

## Request

- Base URL: `https://api.livetennisapi.com/api/public/v1`
- URL: `https://api.livetennisapi.com/api/public/v1/history/matches/{matchId}`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

The tape is the point-by-point score sequence we hold for this match — every recorded score row including the model fields `win_probability_p1` and `danger` at that point — plus match metadata and the model profiles produced during the match. The model fields here are part of the paid History product by design, distinct from the ULTRA-gated LIVE model fields. One match per request. Requires BASIC on the live API, or the Historical Data API Starter plan and up.

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

WORKS ON A LIVE MATCH, not only a completed one. The tape is assembled from whatever has been committed so far, so it is how you read the point-by-point history of a match in progress — including games played before you started watching, where we were already watching them. The LIST endpoint is completed-only; get live ids from `/matches?status=live`. `/matches/{matchId}/score` is one state; this is the sequence of states.

## Path parameters

- `matchId` integer, required

## Query parameters

- `sequence` 'raw' | 'clean'
- `points` 'default' | 'complete'

## Response `200`

The full tape (`match` + `tape` + `profiles` + coverage `meta`)

- HistoryTape
  - `match` Match
    - `id` integer
    - `tournament` string
    - `tour` 'atp' | 'wta' | 'challenger' | 'itf' | 'juniors' | 'null', nullable: The tour, in the SAME vocabulary the `tour` query filter accepts — both are derived from one registry, so a match selected by `?tour=X` always carries that value in `tour`. Null when the feed never stated a tour or the event type has no public tour name (exhibitions, team and mixed events) — never guessed. Safe to group and filter on; never parse the tournament name for this.
    - `tournament_id` string, nullable: 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.)
    - `tier` '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', nullable: 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_source` 'calendar' | 'name' | 'wikipedia' | 'resolver' | 'null', nullable: 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.
    - `surface` 'hard' | 'clay' | 'grass' | 'null', nullable: 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.
    - `indoor` boolean
    - `format` 'BO3' | 'BO5' | 'null', nullable
    - `round` string, nullable: 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`.
    - `round_code` 'F' | 'SF' | 'QF' | 'R16' | 'R32' | 'R64' | 'R128' | 'RR' | 'BR' | 'Q' | 'Q1' | 'Q2' | 'Q3' | 'Q4' | 'ER' | 'null', nullable: 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_qualifying` boolean, nullable: 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. 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.
    - `status` '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_status` 'Finished' | 'Retired' | 'Cancelled' | 'Walk Over' | 'Postponed' | 'Interrupted' | 'Unresolved' | 'null', nullable: 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_at` string, date-time, nullable: When `event_status` last CHANGED, UTC (ISO-8601, `Z`) — added 2026-08-19. The instant WE recorded the walkover / retirement / cancellation / postponement / suspension (or its clearing), not when the tournament desk or the feed did: this is the field to measure our admin-status latency with. Bumps only on a change of value (a re-read of the same status never moves it; a clear back to null does). Null while `event_status` has never changed since the field was introduced (2026-08-19) — never backfilled, never guessed.
    - `is_doubles` boolean: Doubles match — kept for compatibility, and LOSSY. Evidence order: a doubles-team participant proves true regardless of the event type; otherwise the feed's event type decides. The loss: false also covers "unknown" — a match with no stated event type and no team participant reads false here, which is not a claim of singles. Prefer `draw`, whose null says so honestly.
    - `draw` 'singles' | 'doubles' | 'null', nullable: The honest THREE-VALUED draw (added 2026-08-18) — same vocabulary as the `?draw=` filter, decided by the same shared definition, so filter and field cannot disagree. Evidence order as is_doubles: a doubles-team participant proves doubles over any event type; otherwise the feed's event type decides. Null means neither says anything — the feed stated no event type, or the match is part of a team tie (Davis Cup / BJK Cup / United Cup class), where one event type covers both singles and doubles rubbers and we will not guess which this is. Null is NOT singles.
    - `scheduled_time` string, date-time, nullable
    - `live_at` string, date-time, nullable: The instant our feed last reported the match in play (UTC), added 2026-09-09 — the closest thing to an actual start time; a match that starts late has this well after `scheduled_time`, one that starts early has it before. Null for matches that went live before 2026-09-05 (when the stamp began) or were never observed live; re-stamped if a match is demoted and promoted again.
    - `result_restated_at` string, date-time, nullable: When 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_version` integer: `1` 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.
    - `players` object
      - `p1` Player
        - `id` integer
        - `name` string
        - `tour` string, nullable: The record's OWN tour, which is NOT the `tour` filter vocabulary. It is granular (`juniors_boys`, `juniors_girls`, `challenger_men`) where the filter is grouped (`juniors`, `challenger`), and a doubles team reports it UPPERCASE (`ATP`) where an individual reports lowercase (`atp`). Treat it as an opaque string; do not parse it into the filter enum.
        - `country` string, nullable
        - `ranking` integer, nullable: Official singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. `null` when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use `/rankings?as_of=` for the rank in force on a date.
        - `ranking_points` integer, nullable: Points on the same official table, refreshed with `ranking`.
        - `ranking_movement` 'up' | 'down' | 'same' | 'null', nullable
        - `hand` 'R' | 'L' | 'null', nullable
        - `backhand` 1 | 2 | null, nullable
        - `birthday` string, date, nullable
        - `is_doubles_team` boolean
        - `data_completeness` object: How much biographical detail is known for this player, so a consumer can distinguish "not in the feed" from "not yet fetched" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour.
          - `known` integer, nullable: Fields populated, of `of`. NULL on a doubles team, where per-player biography does not apply — distinct from `0`, which means the fields apply and none are known. Do not decode this into a non-nullable integer.
          - `of` integer, nullable: Fields considered. NULL on a doubles team, as for `known`.
          - `missing` string[]: Names of the unpopulated fields, e.g. ["backhand", "hand"].
          - `note` string: Present only when the object is not applicable, explaining why (e.g. a doubles team).
        - `stats` object: Single-player endpoint only
          - `ratings` object, nullable
          - `ratings_as_of` string, date, nullable: The date (UTC) the `ratings` block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          - `season` unknown
      - `p2` Player
        - `id` integer
        - `name` string
        - `tour` string, nullable: The record's OWN tour, which is NOT the `tour` filter vocabulary. It is granular (`juniors_boys`, `juniors_girls`, `challenger_men`) where the filter is grouped (`juniors`, `challenger`), and a doubles team reports it UPPERCASE (`ATP`) where an individual reports lowercase (`atp`). Treat it as an opaque string; do not parse it into the filter enum.
        - `country` string, nullable
        - `ranking` integer, nullable: Official singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. `null` when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use `/rankings?as_of=` for the rank in force on a date.
        - `ranking_points` integer, nullable: Points on the same official table, refreshed with `ranking`.
        - `ranking_movement` 'up' | 'down' | 'same' | 'null', nullable
        - `hand` 'R' | 'L' | 'null', nullable
        - `backhand` 1 | 2 | null, nullable
        - `birthday` string, date, nullable
        - `is_doubles_team` boolean
        - `data_completeness` object: How much biographical detail is known for this player, so a consumer can distinguish "not in the feed" from "not yet fetched" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour.
          - `known` integer, nullable: Fields populated, of `of`. NULL on a doubles team, where per-player biography does not apply — distinct from `0`, which means the fields apply and none are known. Do not decode this into a non-nullable integer.
          - `of` integer, nullable: Fields considered. NULL on a doubles team, as for `known`.
          - `missing` string[]: Names of the unpopulated fields, e.g. ["backhand", "hand"].
          - `note` string: Present only when the object is not applicable, explaining why (e.g. a doubles team).
        - `stats` object: Single-player endpoint only
          - `ratings` object, nullable
          - `ratings_as_of` string, date, nullable: The date (UTC) the `ratings` block was last refreshed (added 2026-09-10). The current Elo is refreshed every week from the published rating tables; read this before treating a rating as this week's. Null when there is no rating.
          - `season` unknown
    - `score` Score: One state at one instant. ULTRA adds win_probability_p1 + danger. Order and de-duplicate on `sequence`, never on `timestamp`.
      - `sets` integer[]
      - `games` array[], nullable: [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.
        - integer[]
      - `points` string[]
      - `server` 1 | 2 | null, nullable
      - `is_tiebreak` boolean: The 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_p1` number, nullable
      - `danger` number, nullable
      - `win_probability_p1_model` number, nullable: ULTRA, since 2026-09-12. The same model read computed WITHOUT the market-prior anchor — a probability no market price touched. Equals win_probability_p1 on a match the anchor does not apply to, 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_meta` object, nullable: ULTRA, 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.
        - `model_version` string: The 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.
        - `generated_at` string, date-time, nullable
        - `market_anchored` boolean: The market-prior anchor applies to this match: a two-sided pre-match price and a pre-play first score gave the model a shift to its pre-match prior. Per match, not per row.
        - `anchor_effective` boolean, nullable: Since 2026-09-15. This row's win_probability_p1 differs from win_probability_p1_model, i.e. the anchor moved this value. Null when either probability is null.
      - `timestamp` string, date-time, nullable: 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`.
      - `accepted_at` string, date-time, nullable: Since 2026-09-15. The instant we accepted this state (UTC): stamped once at insert, never refreshed by a re-assertion — the clock to difference a latency study against. Present on live score reads and push frames, and since 2026-09-24 on history-tape rows too (this description said "absent on history-tape rows" until 2026-09-25, which had been wrong since the tape started carrying it — see `HistoryTapeRow.accepted_at`). Null on the archive fallback of a retention-trimmed completed match.
      - `sequence` integer, nullable: Per-match accept counter for this state: 1, 2, 3, … in the order we accepted states for THIS match, gapless within a match — higher is later, equal is the same state re-served, and a gap between two reads is exactly that many missed updates. On /matches/{matchId}/score it moves BACKWARDS in two cases only: we withdrew a state we cannot stand behind and re-served the last good one, or the read deferred to a strictly higher-trust source's fresh state (about 1% of live reads, measured 2026-09-15). On the push feed and the native WebSocket it only ever rises. Null on states that predate the counter — never fabricated. Present on live score reads AND, since 2026-09-26, on history-tape rows too — see `HistoryTapeRow.sequence`. This description said "absent on history-tape rows, which are already served in order" until then, and the reasoning was the gap: being served in order does not let you resolve the `replaced_by_sequence` that `GET /matches/{matchId}/withdrawals` hands you, which is a pointer AT a counter and not a position in a list. Since 2026-09-24 a set completion that does not end the match is assigned a sequence only once confirmed — the same source keeps presenting the completed set for 10 s or more, a source at least as trusted presents it, or the same source shows play continuing inside the new set. A live scoring display can render the set-ending frame ON set point, before the point is decided, and step back when it is not won; the unconfirmed frame is now never stored, so no sequence ever carries it and nothing is published as provisional. Until a frame is confirmed every surface keeps serving the set-point state; one nobody confirms within 120 s is discarded. A set completion that ENDS the match is exempt — it follows the completion rules instead. Measured over the 30 days to 2026-09-24 this adds a median 10 s between the point and the published set-end, and on a feed that does not re-present states the set-end lands with the first point of the next set (90th percentile 133 s). It does not cover a set genuinely won and then corrected on court, which can still be published and rewound.
      - `age_seconds` integer, nullable: Seconds since the score CONTENT last changed (not since we last polled) — it keeps rising between points. Null = change-clock unknown for this row, never "fresh". Always null on the archived final that `/matches/{matchId}/score` serves for a settled match whose live rows were retired (since 2026-09-20): no clock is claimed for a state nobody watched.
      - `stale` boolean: Our own staleness verdict on age_seconds for a LIVE match (threshold currently 120s). Always false on a completed match: its last state is final, not stale.
      - `observed_age_seconds` integer, nullable: Seconds since ANY of our sources last CONFIRMED this state — the insert itself, the owning source re-asserting it, or an independent source agreeing with it. The "is anyone still watching" clock, next to `age_seconds`, the "has anything happened" clock: a long age_seconds with a small observed_age_seconds is a changeover or set break, not a stall. Null = unknown: no observation clock stored on the row, or the archive fallback on a retention-trimmed completed match (where age_seconds and sources_count are null too). Absent on history-tape rows.
      - `sources_count` integer, nullable: How many DISTINCT sources inserted or independently confirmed this match's state in the last 60 seconds (our public source window). A count, never a name; it counts sources we accepted a state from or that agreed with the stored state, so it can under-count and never over-counts. 1 is ordinary; 0 on a live match past a full minute means nobody we accepted a state from, or who agreed with the stored state, has reported it in the last 60 seconds; 0 on a completed match is normal. Null = unknown (a read blip, or the archive fallback). Present wherever observed_age_seconds is.
      - `changing_sources_count` integer, nullable: 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.
      - `corroborated` boolean, nullable: Since 2026-09-24. Has an INDEPENDENT source vouched for THIS EXACT state? True when a source other than the one that wrote this state has since presented us the same state; false when none has, including a brand-new state nobody has had a chance to agree with yet. This is the field to gate a SET-END on. The two counts above are window measures over the match and say nothing about the state in front of you, so a set completion only one source has announced can sit beside a perfectly healthy sources_count — and a set-end is the one state whose correction changes a result rather than a number. EVIDENCE, NOT PROOF: a vouched state can still be corrected, so read corroborated_age_seconds beside it and treat a vouch that is younger than the state it vouches for as the weaker signal it is. When we do take a state back the `sequence` goes down and `verdict` says so. Null = unknown (the archive fallback on a retention-trimmed completed match, where age_seconds and sources_count are null too). A yes/no, never a name. Present wherever sources_count is.
      - `corroborated_age_seconds` integer, nullable: Since 2026-09-24. Seconds since that independent vouch. Null when corroborated is false (nobody has) or null (unknown). Compare it with age_seconds: a vouch OLDER than the state means somebody agreed with this state after it was published; a vouch younger than the state is one that landed late.
      - `detail` string: Present only when something is withheld — plain-language reason, e.g. a null `games` array.
      - `verdict` object, nullable: Why 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.
        - `kind` 'withdrawn' | 'deferred' | 'withheld'
        - `superseded_sequence` integer, nullable
        - `reason` string
        - `safe_to_resume` boolean
    - `winner` integer, nullable: Completed matches only — derived from final sets. Served for the full archive age: a match older than the live-table window reads its final state from the same store the tape serves, so old completed matches carry a winner too.
    - `outcome` 'completed' | 'retired' | 'walkover' | 'default' | 'abandoned' | 'unresolved' | 'null', nullable: The settlement outcome (added 2026-08-18), one closed vocabulary derived from `status` + `event_status` so the two can never disagree: `completed` = ran its full course; `retired` = a player retired in play; `walkover` = conceded before a ball was struck; `default` = a player was defaulted; `abandoned` = called off unfinished with no result awarded; `unresolved` (added 2026-09-10) = every source lost the match before a result and it was closed unfinished — `score` is the last state we observed, `winner` is null and no result is asserted; it flips to `completed` with the proven final when an authority confirms the result. NULL while the match is upcoming/live and on a cancelled fixture that never produced a result — null is not `completed`. Branch settlement on this field, never on `event_status` spellings. New values may be added within v1; handle unknowns gracefully.
    - `withdrew` integer, nullable: Completed matches only — which player retired or conceded the walkover (1|2). Present only when `event_status` is `Retired`/`Walk Over` and the winner is derivable; the withdrawer is the loser by the rules of the sport. Absent means "not a withdrawal, or no evidence" — never a guess.
    - `has_analysis` boolean: Whether a model thesis or profile exists for this match — on every list row and the detail, every tier (since 2026-09-02). Filter the slate on this before calling `/matches/{matchId}/analysis`, which answers `404 no_analysis` about the same fact.
    - `has_market` boolean: Whether a match-winner market is mapped to this match (every tier, since 2026-09-02). Same role for `/markets/{matchId}/prices` (`404 no_market`).
  - `tape` HistoryTapeRow[]: Chronological score sequence — see HistoryTapeRow
    - `origin` '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.
    - `serve` 1 | 2 | null, nullable: 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.
    - `outcome` 'ace' | 'double_fault' | 'winner' | 'forced_error' | 'unforced_error' | 'null', nullable: 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.
    - `sets` integer[]
    - `games` array[]
      - integer[]
    - `points` string[]: In-game points as tennis strings ("0", "15", "30", "40", "AD") — EXCEPT during a tiebreak, where they are the running tiebreak count as plain integer strings ("0", "1", "2", …); `is_tiebreak` tells the two notations apart.
    - `server` 1 | 2 | null, nullable: 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.
    - `is_tiebreak` boolean
    - `tiebreak_final` integer[], nullable: Added 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_p1` number, nullable: The 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.
    - `danger` number, nullable
    - `timestamp` string, date-time, nullable
    - `accepted_at` string, date-time, nullable: On 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_at` string, date-time, nullable: 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.
    - `sequence` integer, nullable: On 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.
    - `winner` 1 | 2 | null, nullable: 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_winner` 1 | 2 | null, nullable: 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.
  - `tiebreaks` object[], nullable: Per-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.
    - `p1` integer
    - `p2` integer
  - `profiles` object[]: Model profiles, oldest first (Analysis `profile` shape)
  - `meta` object
    - `match_id` integer
    - `rows` integer: Rows RETURNED — after any `sequence=clean` collapse.
    - `coverage` '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.
    - `point_source` 'observed' | 'reconstructed' | 'mixed' | 'null', nullable: Where the rows came from — `observed` (every row watched live), `reconstructed` (every row expanded after the fact), `mixed` (a reconstructed opening followed by what we watched), or null on an empty tape. Reported once here and never per row, so the row shape cannot carry data-source provenance.
    - `raw_rows` integer: Rows BEFORE any collapse — equals `rows` when `sequence=raw`.
    - `unique_states` integer: Distinct score states in the raw tape. `raw_rows` minus this is pure repetition.
    - `sequence` 'raw' | 'clean': Echoes the requested `?sequence=`.
    - `from_archive` boolean: The rows were served from the immutable archive rather than the live table. Informational — the content contract is identical.
    - `points` PointsMeta: Measured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on `?points=complete` responses (with `available_complete`); on default reads it appears where enabled. Orthogonal to `coverage`: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
      - `complete` boolean: The contract bit: a statement that the served sequence holds AT LEAST EVERY POINT THE SCORE REQUIRES — not merely that consecutive rows are consistent with each other. True only when the sequence opens at 0-0, EVERY transition is a legal single-point step, it carries at least as many point transitions as the final scoreline implies the match contained, EVERY completed game holds at least as many point transitions as that game must have contained (`games_short` is 0 — a whole game the tape skips is a legal 0-0 → 0-0 boundary the transition test alone cannot see, added 2026-09-21), it reaches a finished legal final scoreline at love (or the match ended early — retirement/walkover), it is not known-truncated, and no game was withheld (`games_withheld`).
      - `completeness` number, nullable: Legal single-point transitions over a LOWER-BOUND expected point count derived from the final scoreline. 1.0 for any complete tape, low for a sampled one. Descriptive only — the denominator is a floor, not the true point count — and null when the final scoreline is unreadable.
      - `rows` integer: Rows measured — always equals `meta.rows`.
      - `transitions_legal` integer: Transitions attributable to exactly one side winning one point.
      - `transitions_total` integer: All row-to-row transitions, excluding re-sent rows (rows − 1 − `resent_rows`).
      - `resent_rows` integer: 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.
      - `server_rows` integer: Rows carrying a server.
      - `starts_at_love` boolean: The first row is the 0-0 opening state.
      - `ends_at_final` boolean: The 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).
      - `basis` 'raw' | 'clean' | 'recon': Which serve was measured: `raw` | `clean` (the default read, as returned) or `recon` (the whole reconstruction, `?points=complete`).
      - `available_complete` boolean, nullable: `?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_short` integer, nullable: A 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_withheld` integer, nullable: 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.
    - `tiebreaks_source` string[], nullable: Aligned 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.
    - `reconstructed_at` string, date-time, nullable: When 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.
    - `observed_span` object, nullable: The `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.
      - `first` string, date-time, nullable
      - `last` string, date-time, nullable
    - `enrichment` object: What 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.
      - `serve` 'stated' | 'none'
      - `outcome` 'full' | 'ace_double_fault' | 'none'
    - `generated_at` string, date-time

## Other responses

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

---

[API](https://skmtc.dev/livetennisapi/apis/live-tennis-api.md) · [All operations](https://skmtc.dev/livetennisapi/apis/live-tennis-api/llms.txt) · [OpenAPI document](https://skmtc.dev/livetennisapi/apis/live-tennis-api/revisions/7881947e8e43?raw)
