---
title: "Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)"
method: GET
path: "/matches/{matchId}/points"
---

# Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)

`GET /matches/{matchId}/points`

## Request

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

The live per-point event stream of one match, in `seq` order. The WebSocket `point` frames are best-effort with NO replay, so this endpoint is how you join mid-match and how you recover a dropped connection: subscribe the WS first, then GET with `after_seq` set to the last seq you hold, then dedup everything by `seq` — it is per-match, monotonic and never skips a value, so it is the whole reconciliation key.

`seq` IS ARRIVAL ORDER, NOT MATCH ORDER, ON THE LIVE BASIS. It is assigned in the order points are committed, and a live match is fed by more than one upstream at different speeds, so a point from a set that has just ended can be committed AFTER points from the set that follows it and carry the higher `seq`. Each row is self-consistent — its `set`, `game`, `number`, `score`, `sets` and `games` all describe the point that was played — but reading the tape in `seq` order can show the set or game counter step backwards. Measured over a recent seven-day window this affected a minority of live matches, and never the `reconstruction` basis. So `seq` is the right key for paging, dedup and resume (unique, stable, strictly increasing — all `after_seq` needs) and the wrong key for chronology: sort by `(set, game, number)` to replay in playing order. That tuple ORDERS points; it does not IDENTIFY them. `number` is null wherever we joined a game already in progress, and the same tuple can appear on more than one row — a game re-expanded by a second source re-asserts ordinals it already holds, which on the live basis is a normal re-statement rather than a correction. There is no revision id, superseded-seq or correction flag: rows are append-only and never rewritten, so `after_seq` never needs a refetch, and the page-level `quality` field reads `revised` when the page contains such a re-statement. On the `reconstruction` basis of a completed match, `seq` is contiguous 1..N in true match order and the two agree.

READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: `pbp_coverage: "point"` means a per-point stream has DELIVERED for this match — at least one played point past the `seq` 1 opener; `"game"` means no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (a listed match that has not started). An answer, not an error; it flips to `point` on the first played point. To admit a match as advancing, gate on `sequence > 1` (a seeded match is 1) together with `stale: false`. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. `quality: "revised"` means the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited (append-only).

Each row is the state AFTER a played point: `score`/`sets`/`games` (tiebreaks carry the running count in `score` with `games` frozen at the pre-breaker score), its position (`set`/`game`/`number`), `server` (of the next point), the derived `winner` (null when not attributable to a single point — never guessed), and `ts` — CAPTURE time, when our pipeline committed the state, because no feed asserts a per-point clock and we fabricate none.

Up to 500 rows per page; `after_seq=last_seq` fetches the next page while `has_more` is true. 404 unknown match; 400 `points_disabled` while the surface is switched off server-side.

COMPLETED MATCHES: the stored live stream is served on a completed match too, whenever it is itself measured complete (the match-closing point included) or carries `serve`/`outcome` tags and is legal end to end (every transition one attributable point, judged in playing order). A projection never carries a clock or a tag, so a complete tagged stream is strictly more information than any reconstruction of the same match. Only when the stream falls short — no rows at all, incomplete and untagged, or a transition nobody can attribute — and a measured-complete recorded point sequence of the finished match exists does this endpoint serve THAT instead: the complete sequence projected into the same point-frame shape, love-love opener through the match-closing point, `seq` contiguous 1..N. The response field `basis` says which base served the page: `live` (the persisted live stream rows) or `reconstruction` (the projected complete sequence; `quality` is `clean`, every transition measured legal), and on `reconstruction` `basis_reason` says why the stream was not served: `stream_absent` (no stored stream rows), `stream_incomplete` (the stream is legal but does not measure complete — it joined mid-match or stopped short — and carries no tags) or `stream_illegal` (at least one transition is not attributable to one point: a gap or a torn row). When the reconstruction serves it serves wholesale — the two sequences are never interleaved (they share no key, so any merge would fabricate an order). On projected frames `ts` is null on every row: the recorded sequence carries no per-point clock and we fabricate none. `after_seq` pagination and `seq` dedup work identically on either basis, but the two bases are different sequences: if a completed match reads `reconstruction`, re-read from `after_seq=0` rather than resuming a live cursor into it. Precedence fixed 2026-09-21: until then a measured-complete recorded sequence displaced the stream unconditionally, so a completed match could lose its tags the moment a reconstruction landed.

THE MATCH-CLOSING POINT (added 2026-09-20). Every row is the state AFTER a point, so the point that wins a game is carried by the next game's `number: 0` opener — and the point that wins the match had no next row to be carried by: the live stream never held it, and a serve statistic built off the stream was missing every match's last point. On a COMPLETED match served on the `live` basis the page now closes with ONE terminal row: `seq` = last + 1, `number` 0, `sets`/`games` the final score, `score` `{"p1":"0","p2":"0"}`, `tiebreak` false, `server` null (nobody serves next), `winner` the match winner, `ts` the instant the final score was observed. It is built at read time from the stream's last row and the observed final score, and only when the two are one point apart — the winner held game point and the final is the decided score; nothing is fabricated otherwise. `serve`, `outcome` and `tagged_at` on that row are null: no source's tag for a match's last point is stored yet. When the match ends in a tiebreak (added 2026-09-21) the stream's last row is the decisive tiebreak score itself (7-3, 8-6) and the closing row is the set roll-up after it: the next game number, `number` 0, `tiebreak` false, `sets` incremented for the tiebreak winner, the set banked 7-6 in `games`, `score` 0-0, `server` null, the same `winner` — the same row the stream stores after every other set-ending tiebreak. The response field `ends_at_final` says whether the sequence served ends on the match-closing point: `false` on a completed match whose stream stops short of it — a retirement or walkover (no closing point was played), a capture that stopped two or more points short, or a closer that cannot be stated as one point (from deuce, or from a 10-point match tiebreak). Always `false` on a live match. On the `reconstruction` basis it is judged from the projected sequence's last frame (a complete recorded sequence of a retired match ends at the retirement, so it reads `false` there). WebSocket and push frames are unchanged.

REVISIONS: `changed_since` (added 2026-09-20). `after_seq` is a cursor by `seq`, so it can never return a row you already hold — and a `serve`/`outcome` tag that lands late lands on exactly such a row. Every row now carries `tagged_at`, the UTC instant its tags landed (null while none has). Pass `changed_since=<ISO-8601 instant>` (e.g. `2026-09-20T00:35:18Z`; `Z` or an offset, a naive value is read as UTC, a date alone is refused) to get only the rows whose `ts` OR `tagged_at` is later than that instant, in `seq` order, paged like any other read and composable with `after_seq`. The post-match recipe: read the match, keep the instant, re-read with `changed_since=<that instant>` and replace the rows you hold by `seq` — no socket, no full re-fetch. Anything that is not an ISO-8601 timestamp is a 400 `bad_changed_since`. On the `reconstruction` basis no row carries a clock or a tag, so a `changed_since` read of it is an empty page: that sequence is final at first read.

## Path parameters

- `matchId` integer, required

## Query parameters

- `after_seq` integer
- `changed_since` string, date-time

## Response `200`

The point events page, seq order

- MatchPoints: One page of a match's per-point stream, seq order. `basis` says which base served it: the persisted live stream rows, or — for a completed match where a measured-complete recorded sequence exists — that complete sequence projected into point frames.
  - `match_id` integer
  - `pbp_coverage` 'point' | 'game': `point` = a per-point stream has DELIVERED for this match (a played point past the seq-1 opener); `game` = no played point has arrived — only the snapshot score path covers it, or the stream holds only its opener so far (an answer, not an error; flips to `point` on the first played point — gate on `last_seq > 1`).
  - `quality` 'clean' | 'revised': `revised` = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
  - `enrichment` object: Added 2026-09-12. What an outside source has stated for THIS match so far — `serve`: stated | none; `outcome`: full | ace_double_fault | none. Read it before building on the fields; it is never a promise about the rest of the match.
    - `serve` 'stated' | 'none'
    - `outcome` 'full' | 'ace_double_fault' | 'none'
  - `covers_from_start` boolean, nullable: Whether the persisted stream OPENS at the match's 0-0 opener — seq 1 exists and is the love-love state — i.e. whether replaying from `after_seq=0` yields the whole match or joins it mid-play. Null when the match has no rows at all (nothing to judge — null means not measured, never "no").
  - `points` LivePoint[]
    - `seq` integer: Per-match monotonic position 1..N — dedup and resume on this.
    - `set` integer, nullable
    - `game` integer, nullable: Game within the current set — 13 for a 6-6 tiebreak.
    - `number` integer, nullable: Points already recorded in this game at this state — 0 for a game's opening state, and **null when we joined the game already in progress**: how many points it holds is not derivable from the score (the deuce zone maps many ordinals onto one score) and is never guessed. `(set, game, number)` is NOT a unique key — a game re-expanded by a second source legitimately re-asserts ordinals it already holds, so the same tuple can name more than one row. `seq` is the only unique, stable per-row key.
    - `tiebreak` boolean
    - `server` 1 | 2 | null, nullable: Server of the point played FROM this state; null when the feed omits it.
    - `winner` 1 | 2 | null, nullable: Who won the point that produced this state — null when not attributable to a single point, never guessed.
    - `score` object: In-game score AFTER the point ('0'/'15'/'30'/'40'/'AD'; the running tiebreak count as integer strings when `tiebreak` is true).
      - `p1` string, nullable
      - `p2` string, nullable
    - `sets` integer[]
    - `games` array[]: `[games_p1, games_p2]`; each a per-set list, frozen at the pre-breaker score during a tiebreak.
      - integer[]
    - `ts` string, date-time, nullable: Capture time (UTC) — when our pipeline committed this state. Null on every frame of a `reconstruction`-basis page — the recorded sequence carries no per-point clock and none is fabricated. On the match-closing row of a completed match it is the instant the final score was observed.
    - `serve` 1 | 2 | null, nullable: Added 2026-09-12. The serve the point was played on (1 first, 2 second) as an outside source states it, joined onto the row by exact state. Null = not stated for this point; never inferred. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly; nothing is corrected to a second serve (since 2026-09-21). Lands a few seconds after the point's own frame (see the `point_update` frame). Stated on the ATP main tour, ATP Challenger, WTA 1000/500/250/125, UTR and the top Davis Cup tiers; not stated on the ITF World Tennis Tour, nor in any doubles draw. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES, not what we store, and on completed matches the two differ (corrected 2026-09-23; the figures published before that date were counted in our own tables and overstated what a reader could fetch). A completed match whose stored per-point stream does not pass our quality bar is served as a measured-complete RECONSTRUCTION instead, and a reconstruction carries no clock and no tags at all — so per-match tag coverage on completed matches is lower, often much lower, than the share of matches a source tagged. `basis` on this response names which one you were given. Team ties, re-measured 2026-09-23 over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group publishes `serve` on 2 of 22 and World Group I on 1 of 42, while World Group II publishes it on 0 of 38 — against 22 of 22 and 41 of 42 respectively in storage, which is the gap this paragraph is about. Qualifying draws are covered like their own main draw, not excluded, and land in the same place their main draw does: WTA qualifying 18 of 48 against 50 of 88 in the WTA main draw, ATP Challenger qualifying (`tier: challenger_*`) 137 of 197 against 221 of 285, and WTA 125 qualifying (`tier: wta_125`, also carried under `tour: challenger`) 47 of 63 against 117 of 147. Read `enrichment` per match — it is the only per-match answer, none of this is a promise about a match not yet played. Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's serve/outcome coverage exceeds what was captured live — the tags arrive as `point_update` frames and `tagged_at` revisions like any other.
    - `outcome` 'ace' | 'double_fault' | 'winner' | 'forced_error' | 'unforced_error' | 'null', nullable: Added 2026-09-12. How the point ended, as an outside source states it. ATP 250 / 500 / 1000: serve stated, outcome full (ace, double fault, winner, forced error, unforced error) from the tour's own per-point console. Grand Slams and Davis Cup are not on that console, so `outcome` reads null there — but `serve` comes from a different source and IS stated on them, so those matches are not blanket-null; read `enrichment` per match. First measured on tour events from the week of 2026-09-22 — before that, no tour event had been played since the source went live on 2026-09-12. ATP Challenger — `tour: challenger` with a `challenger_*` tier — states all five. `tour: challenger` also carries the WTA 125 events (`tier: wta_125`), and those state ace and double fault only, so read `tier` rather than `tour` to tell the two apart. The ITF World Tennis Tour: ace and double fault. WTA 1000/500/250/125: ace and double fault where the tour's console reports the match — read the page's `enrichment` per match. Grand Slams and UTR: none. ITF World Tennis Tour DOUBLES draws state ace and double fault on a growing minority of matches — the tags first reached a doubles row on 2026-09-23 and the endpoint published them on 104 of 596 completed doubles matches (17%) measured 2026-09-29, so read `enrichment` per match here and do not plan a corpus on the draw. Every other doubles draw (ATP, WTA and Challenger): none, measured 0 of 371 in the same window. Qualifying draws mostly follow their own tour, and the one real gap is on the WTA main tour, not on Challenger. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES rather than what we store (corrected 2026-09-23 — see the note on `serve` above: a completed match served on the `reconstruction` basis carries no tags, so the published share is below the share we hold). Re-measured 2026-09-23 over completed matches carrying a per-point stream since 2026-09-12: ATP qualifying publishes outcome on 16 of 16 and ATP Challenger qualifying on 134 of 197, both the full five; ITF qualifying publishes aces and double faults on 181 of 215 men's and 142 of 176 women's, and WTA 125 qualifying on 30 of 63 — while WTA main-tour qualifying publishes it on 5 of 48, against 47 of 88 in the same events' main draws. Read `enrichment` per match rather than inferring a draw's coverage from its tour. Null = not stated. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — `serve` and `outcome` both null — rather than tagged wrongly (since 2026-09-21). Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's coverage exceeds what was captured live — collect the late tags with `?changed_since=` or the `point_update` frame.
    - `tagged_at` string, date-time, nullable: Added 2026-09-20. When `serve`/`outcome` landed on this row (UTC); null while no tag has — on every `reconstruction`-basis frame, on the match-closing row, and on any row no source tagged. Tags land a few seconds after the row itself, so a row's `tagged_at` is later than its `ts`; `changed_since` selects on either.
    - `tiebreak_final` integer[], nullable: Added 2026-09-23. The breaker's closing score, in our player order, e.g. `[7, 5]`. Present ONLY on the row that closes a tiebreak set — the set roll-up (`tiebreak` false, the set banked 7-6, `score` 0-0), which is the row carrying the breaker's winning point, every row being the state after a point. Absent on every other row, including the breaker rows themselves. Exact when the previous row is the decided score or one point from it (6-5 for the set's winner gives `[7, 5]`); otherwise taken from the match's recorded tiebreak finals, the same finals `GET /history/matches/{matchId}` publishes as `tiebreaks`; null when neither can state it. Never guessed. A page that *starts* on a roll-up row (an `after_seq` landing exactly there) reads the one row before your page, so the value does not depend on where the page was cut (since 2026-09-24; before that such a page used the recorded finals alone, and so read null on a set that closed while the match was still live). The deciding set of a match-tiebreak format is judged under the 10-point rule.
  - `last_seq` integer: The resume cursor — pass as `after_seq` to continue.
  - `has_more` boolean
  - `basis` 'live' | 'reconstruction': Which base served this page. `live` = the persisted live stream rows, served whenever the stream is complete, or tagged and legal end to end (a projection carries no clock and no tag, so the stream is strictly more information); `reconstruction` = the complete recorded point sequence of a finished match, projected into point frames at read time, served only when the stream falls short (see `basis_reason`) — includes the match-closing point, `seq` contiguous 1..N, `ts` null on every frame. The two bases are never interleaved.
  - `basis_reason` 'stream_absent' | 'stream_incomplete' | 'stream_illegal': Added 2026-09-21. Present on the `reconstruction` basis only — why the stored live stream was not served. `stream_absent` = no stream rows; `stream_incomplete` = the stream is legal but does not measure complete (joined mid-match or stopped short) and carries no tags; `stream_illegal` = at least one transition is not attributable to one point (a gap or a torn row). A stream that is complete, or tagged and legal end to end, is always served (basis `live`) and this key is absent.
  - `ends_at_final` boolean: Added 2026-09-20. Whether the sequence served ends on the match-closing point — the state after the match-winning point: `number` 0, score 0-0, `server` null, `winner` the match winner. On a completed match served on the `live` basis that row is appended at read time from the stored stream's last row and the observed final score, and only when the two are one point apart (when the match ends in a tiebreak, the set roll-up after the decisive tiebreak row: `tiebreak` false, the set banked 7-6, the same `winner`); false when the match is completed but the stream stops short of it (a retirement or walkover, a truncated capture, or a closer that cannot be stated as one point, such as a 10-point match tiebreak), and always false on a live match. On the `reconstruction` basis it is judged from the last projected frame.

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