---
title: "List matches by lifecycle status (FREE)"
method: GET
path: "/matches"
---

# List matches by lifecycle status (FREE)

`GET /matches`

## Request

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

`status=live` and `status=upcoming` are the FREE current-state picture. `status=completed` pages historical results and is part of the paid History product — it requires BASIC (the same rule as `/history/matches`). A FREE key may spend its 20 free history calls per calendar month here; past that the answer is `403 upgrade_required` carrying `free_history_taste: "used"`. The `player`, `country`, `from`/`to`, `tour`, `draw`, `has_analysis` and `has_market` filters are optional, AND-composed, applied inside the query (before pagination), and work on every status — omitting them returns exactly what the endpoint returned before they existed. `has_analysis` and `has_market` are the two availability flags every row already carries: filter the slate with them and call `/matches/{matchId}/analysis` and `/matches/{matchId}/prices` only for the ids that have something, rather than probing per match for a 404.

## Query parameters

- `status` 'live' | 'upcoming' | 'completed' | 'cancelled'
- `tour` 'atp' | 'wta' | 'challenger' | 'itf' | 'juniors'
- `draw` 'singles' | 'doubles'
- `player` integer[]
- `country` string
- `tournament_id` string
- `tier` string
- `has_analysis` boolean
- `has_market` boolean
- `is_qualifying` boolean
- `gender` 'men' | 'women'
- `from` string
- `to` string
- `updated_since` string
- `withdrawn_since` string
- `cursor` string
- `limit` integer
- `offset` integer

## Response `200`

Matches with latest score

- object
  - `data` 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`).
  - `meta` ListMeta
    - `limit` integer
    - `offset` integer
    - `count` integer
    - `total` integer, nullable: Size of the whole filtered set. Null when it cannot be counted cheaply.
    - `has_more` boolean: More results exist beyond this page. Read this rather than comparing count to limit. For a complete enumeration of a filtered set, page `offset` forward by `limit` until this is false; on `completed`/`cancelled` listings `total` is null and this is the only end-of-data signal.
    - `next_cursor` string, nullable: Change-feed only (`?updated_since=` / `?cursor=`). Opaque token to pass back as `?cursor=`. Null means you are caught up — stop paging and switch to polling from `watermark`.
    - `watermark` string, date-time, nullable: Change-feed only. The floor to use on your NEXT poll. Deliberately trails the server clock by 5s so a row committed during this request cannot fall through the gap.

## Other responses

- `400`: Bad query parameter
- `401`: Missing, unknown, or disabled credentials
- `403`: Your tier doesn't unlock this endpoint
- `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)
