---
title: "Current score only — lowest-latency REST read (FREE)"
method: GET
path: "/matches/{matchId}/score"
---

# Current score only — lowest-latency REST read (FREE)

`GET /matches/{matchId}/score`

## Request

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

This is a POINT-IN-TIME SNAPSHOT: the single current state, overwritten on every score commit. It carries no history and no accumulated statistics. For the SEQUENCE of states — who served each game, hold/break, every score state in forward order — use `/history/matches/{matchId}?sequence=clean`, which works on a LIVE match, not only a completed one. For in-play statistics use `/matches/{matchId}/statistics` (ULTRA); they are deliberately not on this object, because they can be further behind the match than the score and must carry their own `as_of`. ARCHIVED FINALS (since 2026-09-20). The live-score rows behind this read are retired by a 90-day retention sweep, and a match recovered from an official day list may never have had one. When there is no live row at all, a SETTLED match — `outcome` non-null on the match object: completed, retired, walkover, default, abandoned, unresolved — is answered from its archived final: the same read `GET /matches?status=completed` already embeds, through the same serializer, so the listing and this endpoint can never disagree about whether a score exists. A live tape always outranks the archive — the fallback is reached only when no publishable live row exists, so a live match reads exactly what it did before. An archived final carries `age_seconds: null`, `observed_age_seconds: null`, `sources_count: null` and `accepted_at: null` — no clock is claimed for a state nobody watched — and `timestamp` is null where the archived row has none (a reconstructed final). The Score object carries no data-source label; whether that final was observed or reconstructed is what `GET /history/matches/{matchId}` reports once, in `meta.point_source`. 404 is kept for: an upcoming match with no row (nothing to serve yet), a cancelled match that was never played (nothing settled), and a settled match with nothing recorded anywhere — the fallback serves a final that exists, it never invents one. WITHDRAWN STATES (since 2026-09-23). When the legality gate behind this read refuses a state a stream has already published, the `verdict` object on the score says so: `kind`, `superseded_sequence`, `reason` and `safe_to_resume`. It is null on an ordinary read. The push feed, the WebSocket and webhooks carry the same judgement as a `score_withdrawn` frame naming the same sequence, so a stream consumer learns a state was taken back without polling. `GET /history/incidents` is the published register of data-quality incidents. THE RECOVERY PATH (since 2026-09-25). A consumer that missed the frame cannot get the live `verdict` back, because it is recomputed over the newest state and returns null as soon as the next state is accepted. Every withdrawal is now recorded durably when the judgement is made: pass `?sequence=N` here to have `verdict` answer for the sequence you hold, and read `GET /matches/{matchId}/withdrawals` for the whole record on a match, including the frame exactly as the feed sent it. Before 2026-09-20 every settled match older than the retention window answered 404 here (13,437 completed matches from the previous 180 days, 157,170 all time, measured at the change) while the completed listing served its score.

## Path parameters

- `matchId` integer, required

## Query parameters

- `sequence` integer

## Response `200`

Current score (ULTRA adds win_probability_p1 + danger). On a settled match with no live row, the archived final (since 2026-09-20) — `age_seconds`, `observed_age_seconds`, `sources_count` and `accepted_at` null.

- 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

## Other responses

- `401`: Missing, unknown, or disabled credentials
- `404`: No such match; an upcoming match with no score yet; a cancelled match that was never played; or a settled match with nothing recorded anywhere — no live row and no archived final. Since 2026-09-20 a settled match whose live rows were retired by the 90-day sweep is NOT a 404: it answers 200 with its archived final (`age_seconds: null`), the same score `GET /matches?status=completed` embeds for it.
- `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)
