Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)
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.
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
Path parameters
The match id. Every match route shares ONE id space, so the same value works everywhere matchId appears.
Get it from any match list, where it is the id field of each match object: GET /matches?status=live (in progress), GET /matches?status=upcoming or GET /fixtures (scheduled), GET /matches?status=completed or GET /history/matches (finished).
Ids are stable for the life of a match, so one captured before it starts still resolves after it finishes.
Query parameters
Return only points with seq greater than this — the resume cursor. Pass the last_seq of the previous page (or the last seq your WS stream delivered) to continue; 0 or absent reads from the start of the match. A non-integer or negative value is a 400 bad_after_seq.
Added 2026-09-20. Return only rows whose ts OR tagged_at is later than this instant — the revision filter for a reader without a socket, composable with after_seq. ISO-8601 with a Z or an offset (e.g. 2026-09-20T00:35:18Z); a naive value is read as UTC; a date alone is not an instant and is refused. Anything that is not an ISO-8601 timestamp is a 400 bad_changed_since. An empty page on the reconstruction basis, whose rows carry neither a clock nor a tag.
Response
The point events page, seq order
Changes
No recorded changes to this operation across all 1 revision of this API.