The endpoints
Each one below lists its plan tier, every parameter, the response shape and an example. They are the same entries as in the full reference, which carries all 48 operations of the API on one page.
GET /matches
List matches by lifecycle status (FREE)
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.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
status | query | string (live, upcoming, completed, cancelled) | no | live (default) and upcoming are the FREE current-state picture. completed and cancelled are terminal LISTINGS, part of the history product (BASIC, or any History plan on a free key; a FREE key's 20 free history calls each month are served here too). cancelled covers feed-cancelled, walkover-with-no-stated-winner and postponed-never-played matches; a walkover that named its winner is completed. cancelled pages with limit/offset (optionally from/to) and does NOT accept updated_since (400 bad_request). Any other value is a 400 bad_status carrying the accepted list in allowed. Default live. |
tour | query | string (atp, wta, challenger, itf, juniors) | no | Restrict results to one tour. Each value covers its singles and doubles draws, so atp includes ATP doubles and juniors covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so meta.count reflects the filtered set. |
draw | query | string (singles, doubles) | no | Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (?tour=itf&draw=doubles is the ITF doubles slice). Same vocabulary as the draw field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values. |
player | query | array of integer | no | Filter to matches where this player id is EITHER participant. Repeatable (max 50 ids); multiple values return the deduplicated union. An unknown id returns an honest empty list, not an error; a non-integer value is a 400 bad_request. Before 2026-08-03 this parameter was accepted and silently ignored — treat any integration written against that behaviour as unfiltered. |
country | query | string | no | Filter to matches where EITHER participant's player.country equals this lowercase 3-letter code — the same vocabulary the Player object returns (IOC-style codes, e.g. ned, sui, gre; NOT ISO-3166). Players with no recorded country never match, so a country filter excludes unknown-nationality matches rather than guessing. A value that is not 3 letters is a 400 bad_country. |
tournament_id | query | string | no | Filter to one tournament — exact match on the stable numeric id every match row publishes as tournament_id and /tournaments publishes as id, so the value round-trips straight off any response. Works on /matches (every status) and /history/matches, and composes with every other filter: ?tour=atp&draw=singles&tournament_id=1217 is one slam's men's singles, where ?tour= alone still mixes concurrent events during a slam fortnight. There is no separate edition or occurrence id: tournament_id is stable across seasons, so one EDITION is tournament_id plus a from/to window around its dates. Matches whose tournament_id is null (a tournament not yet in the catalogue) never match this filter; a date-window listing without it still returns them. Unknown-but-numeric ids return an empty list, not an error; a non-numeric value is a 400 bad_tournament_id. |
tier | query | string | no | Tier filter (added 2026-09-22): comma-separated exact values from the closed tier vocabulary (?tier=wta_125, ?tier=itf_w35,itf_w50,itf_w75, ?tier=challenger_75,challenger_100). Selects matches whose tier field carries one of the values — the level the tournament was played at in the MATCH'S season, read from the same per-season table, so filter and field cannot disagree. A match whose tier is null matches no value. Composes with every other filter (?tour=challenger&gender=women&tier=wta_125 and ?tier=wta_125 select the same rows; ?tier=challenger_125&draw=doubles is a slice tour= alone cannot make). Available on /matches (every status) and /history/matches. /tournaments takes ?tier= as well, reading the CURRENT season rather than a match's, and states that difference on its own parameter. Unknown value → 400 bad_tier with the offending values in bad and the full vocabulary in allowed. |
has_analysis | query | boolean | no | Return only matches that DO (true) or do NOT (false) have model analysis, matching the has_analysis flag each row carries (filter added 2026-09-14, documented here 2026-09-20). Omit for both. Anything other than true/false is a 400 bad_has_analysis, never a silently ignored filter. These are the matches /matches/{matchId}/analysis can serve; every other id answers 404 no_analysis. The misses are structural rather than transient — analysis is produced only for a match carrying a mapped market — so a caller polling the unanalysed half is asking a question whose answer can never become yes. Filter the slate with this instead. |
has_market | query | boolean | no | Return only matches that DO (true) or do NOT (false) have a mapped match-winner market, matching the has_market flag each row carries (added 2026-09-15). Omit for both. Anything other than true/false is a 400 bad_has_market, never a silently ignored filter. These are the matches /history/matches/{id}/prices and /matches/{id}/prices can serve; every other id answers 404 no_market. Measured 2026-09-15: 3,718 of the 9,741 matches completed since 2026-08-16 (38%) had a price tape. |
is_qualifying | query | boolean | no | Return only matches in the QUALIFYING draw (true) or only those the source states are not (false), matching the is_qualifying flag each row carries (field added 2026-08-12, filter added 2026-10-02). Omit for both. Anything other than true/false is a 400 bad_is_qualifying, never a silently ignored filter.
THIS IS THE ONLY WAY TO SPLIT THE TWO DRAWS, and the reason is in the is_qualifying field description: the two are routinely carried under ONE tournament name with ONE round vocabulary, so tournament, tournament_id, round and round_code all read main-draw for a qualifying match. Do not filter on the round text.
THREE-VALUED, so this filter is too: a row whose flag is null (no source has ever stated it) matches NEITHER value and is returned only when the filter is omitted. null is an answer, not a false — returning it under is_qualifying=false would assert "main draw" on the strength of nothing. Measured 2026-10-02 over completed matches: the flag is stated on 98.9% of the last 30 days and 98.3% of 2026 outside the UTR circuit, where it is mostly unstated (1,695 of the 1,831 unstated rows in the last 30 days are UTR). |
gender | query | string (men, women) | no | Return only men's or only women's matches, matching the gender field each row carries (added 2026-09-09, documented here 2026-10-02). Omit for both. Anything other than men/women is a 400 bad_gender, never a silently ignored filter.
It exists because ?tour= deliberately collapses: challenger is ATP Challenger AND WTA 125, and itf and juniors are both sexes — so without this there is no way to ask for men's Challenger at all. A row whose gender is null (mixed doubles, a team tie) matches NEITHER value, the same contract as ?draw= and ?is_qualifying=.
bad_points_complete, bad_combination, points_read_disabled, bad_after_seq, bad_has_market, no_market, points_disabled, |
from | query | string | no | Earliest play date, YYYY-MM-DD or an ISO-8601 UTC datetime. A bare date covers that whole day. An unparseable value is a 400, never a silently unfiltered 200. |
to | query | string | no | Latest play date, same formats as from (a bare date includes everything played that day). from after to is a 400. |
updated_since | query | string | no | CHANGE FEED. A UTC date (YYYY-MM-DD) or ISO-8601 datetime. Returns the rows whose updated_at is at or after it, OLDEST FIRST, with meta.next_cursor and meta.watermark. Poll from meta.watermark, not from your own clock — it trails the server by 5s on purpose, because a row committed while your request was being served can carry a timestamp below your now and still not be in your result. Overlap ~60s and dedupe by id; the feed is at-least-once by design. CANNOT be combined with from/to (400 bad_updated_since): those filter when a match was PLAYED and sort by play time, this filters when its row CHANGED, and a change cursor cannot resume a play-time ordering. Not accepted with status=cancelled (400 bad_request). Anything unparseable is a 400, never accepted and ignored. A physically DELETED row can never appear in the feed; cancellations, walkovers and withdrawals are status CHANGES and do appear. |
withdrawn_since | query | string | no | ULTRA. A DIFFERENT LISTING, not a filter on the match page: the match ids with a WITHDRAWN score state at or after this instant, newest first, one row per match as { match_id, withdrawals, last_withdrawn_at } with an exact meta.total and limit / offset paging. A UTC date (YYYY-MM-DD) or an ISO-8601 UTC datetime. This is how a stream consumer that was disconnected finds which matches to ask GET /matches/{matchId}/withdrawals about. It takes no other parameter beside limit and offset — status, updated_since, the date range and every match filter are 400 bad_withdrawn_since, as is an unparseable instant. Records are kept 90 days from the withdrawal. |
cursor | query | string | no | Echo back meta.next_cursor to fetch the next page of a change feed. Opaque — do not parse it, its contents will change. Supplies both the floor and the tie-break position, so it replaces updated_since on follow-up pages rather than accompanying it. |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Matches with latest score |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/matches \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}
Full match detail (FREE; +market PRO, +analysis ULTRA)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
Responses
| Status | Meaning |
|---|---|
200 | Match with score; market embed at PRO+, analysis embed at ULTRA |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
id | integer | |
tournament | string | |
tour | string or null (atp, wta, challenger, itf, juniors, null) | 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 or null | 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 | string or null (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) | 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 | string or null (calendar, name, wikipedia, resolver, null) | 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 | string or null (hard, clay, grass, null) | 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 | string or null (BO3, BO5, null) | |
round | string or null | 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 | string or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null) | 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 or null | 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 | string (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 | string or null (Finished, Retired, Cancelled, Walk Over, Postponed, Interrupted, Unresolved, null) | 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 or null | 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 | string or null (singles, doubles, null) | 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 or null | |
live_at | string or null | 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 or null | 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 | |
score | object or null | |
winner | integer or null | 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 | string or null (completed, retired, walkover, default, abandoned, unresolved, null) | 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 or null | 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). |
analysis | object | ULTRA only (absent below) |
market | object or null | PRO+ only (absent below) |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953 \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/score
Current score only — lowest-latency REST read (FREE)
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.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
sequence | query | integer | no | The sequence YOU HOLD for this match, from the push feed, the WebSocket or an earlier read. verdict is recomputed on every read over the newest state, so on its own it says nothing about a sequence that was withdrawn before the next state landed. With ?sequence=N, and only when the read carries no verdict of its own, verdict answers for N from the durable withdrawal record: kind: withdrawn with the recorded reason when we discarded that state, null when we never did. Only a discarded record is re-asserted this way; a deferred state may since have been corroborated and a withheld read described that moment's scan, so neither is. A verdict the read produces itself always wins, because it describes the state being served. Every other field still describes the served state. A non-integer is 400 bad_sequence, never accepted and ignored. |
Responses
| Status | Meaning |
|---|---|
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. |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
sets | array of integer | |
games | array or null | [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. |
points | array of string | |
server | integer or null (1, 2, null) | |
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 or null | |
danger | number or null | |
win_probability_p1_model | number or null | 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 or null | 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. |
timestamp | string or null | 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 or null | 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 or null | 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 or null | 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 or null | 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 or null | 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 or null | 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 or null | 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 or null | 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 or null | 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. |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/score \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/events
Match events, newest first (PRO)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Events |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/events \
-H "Authorization: Bearer twjp_..."
GET /events
Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)
Added 2026-09-13. The rows of GET /matches/{matchId}/events for EVERY match in one request, so a poller watching the whole live slate spends one request per tick rather than one per match. after_id returns rows with id greater than the one passed, ascending, and meta.next_cursor names the last id served (null on a short page = caught up); since (UTC instant) is the first-call lower bound; with neither the newest page is served, still ascending. type narrows to a comma-separated list of event types or the family name stoppages (stoppage_*, pause_*, medical_timeout_*, trainer_called*, toilet_break_*). Rows carry id and match_id next to the per-match fields. Measured 2026-09-13: a scorer-stated stoppage reaches the feed a median 8 s (p90 13 s) after the scorer's own instant; the WebSocket stoppages signal pushes the same row as it is written.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
type | query | string | no | Comma-separated event types (see Event.type), or stoppages for the whole stoppage family. |
after_id | query | integer | no | Serve rows with id greater than this, ascending. Take it from meta.next_cursor or the last row's id. |
since | query | string | no | First-call lower bound, a UTC instant. Rows stamped after it, ascending. |
limit | query | integer | no | Default 50. |
Responses
| Status | Meaning |
|---|---|
200 | Events across the slate, ascending id |
400 | bad_type, bad_after_id or bad_since |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/events \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/status-history
The per-match status ledger — every status / event_status transition with its UTC instant (BASIC, history)
Added 2026-09-12. Append-only, oldest first: one row per change of status and/or event_status, with the instant we published it, the value before and the effective value after, the derived outcome, and the newest score row at that instant. A correction is a new row, never an edit — a close published as unresolved and later confirmed shows the flip to completed; a completion that reopened shows completed -> live. basis: observed rows exist from 2026-09-11T22:45:48Z; basis: backfill rows (2026-09-12) were reconstructed from the one stamp per kind the match row kept before the ledger existed (last promotion to live from 2026-09-05, completion instant from 2026-08-21, last reopen, last event_status change) — one row per stamp, overwritten intermediate transitions are not recovered. A correction to a PUBLISHED result — status, event_status, winner or the final score changing after the match was first published as completed — is a new row with basis: restatement (from 2026-09-22), never a silent edit; result_restated_at / result_version on the match summarise them and GET /history/matches?restated_since= finds them. History capability (BASIC and the Historical Data plans), like the tape.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Status transitions, oldest first |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/status-history \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/withdrawals
The durable record of every score state we withdrew on this match (ULTRA)
Added 2026-09-25. One row per refused sequence, oldest first, each carrying the score_withdrawn frame exactly as the push feed sent it.
This is the RECOVERY PATH for a missed frame. The verdict object on GET /matches/{matchId}/score is recomputed on every read over the newest state, so it returns null again as soon as the next state is accepted, which in a live match is seconds. The record here is written when the judgement is made, before the frame is delivered, so a consumer that was disconnected can still learn that a sequence it holds was taken back. GET /matches?withdrawn_since= says which matches to ask about; ?sequence=N on the score read answers for one sequence without fetching the list.
ULTRA, the tier that receives the frame on the push feed and the native WebSocket. An empty data is the ordinary answer and means nothing was withdrawn on that match. Records are kept 90 days from the withdrawal.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
Responses
| Status | Meaning |
|---|---|
200 | Withdrawals on this match, oldest first |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
match_id | integer | |
data | array of object |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/withdrawals \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/analysis
Model analysis for a match (ULTRA)
The model's thesis and profile for one match.
COVERAGE IS NOT UNIVERSAL, and a polling client should plan for that. Analysis is produced per match by the model pipeline rather than emitted for every fixture: over the seven days to 2026-08-27, 1,225 of 2,863 matches that went live or completed carried one (42.8%). A match that has none yet returns 404 {"error":"no_analysis"} (since 2026-09-02; before that the body was a bare not_found) — that is the documented absence, not a fault, and it can turn into a 200 later in the same match once the pipeline has run. Never treat this 404 as a reason to retry harder. The body names which absence it is: not_found is an id that does not exist; no_analysis carries match_id and coverage: "none" for a real match with nothing computed.
FILTER THE SLATE FIRST. Every row of GET /matches and the detail carries has_analysis (every tier), the same fact this endpoint answers 404 about — read it there and call only the matches that carry one, instead of spending one 404 per match.
ONE CALL INSTEAD OF THREE. GET /matches/{matchId} carries the same thesis and profile in its analysis key on ULTRA, alongside market and market_price on PRO and above, next to the live score. It answers 200 whether or not analysis and a market exist — the keys are null instead — so a per-match poll built on the detail route replaces the score, analysis and prices calls with one request and never spends a call on a 404.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
Responses
| Status | Meaning |
|---|---|
200 | Thesis + profile (either may be null) |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | error: not_found — no such match id. error: no_analysis (with match_id, coverage: "none", detail) — the match exists and nothing has been computed for it; has_analysis on the match list says so without a probe. |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
thesis | object or null | |
profile | object or null |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/analysis \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/statistics
In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)
In-play statistics for one match, in TWO families that are deliberately not merged.
DERIVED (the top level of players.pN) are rebuilt from the point-by-point record: service and return games played and won, hold and break percentage, break points faced, saved and converted, service and return points.
MEASURED (players.pN.measured) are counted upstream, so they include what no point record can yield — ACES AND DOUBLE FAULTS, the first- and second-serve split, winners and unforced errors. Both families name some of the same quantities, computed two entirely different ways; that is a cross-check, not a duplication to collapse.
Measured coverage is not uniform and every measured field is optional — an absent field is OMITTED, never zero-filled, so read the keys you are given. Aces and double faults are present across every tour. The serve split and break points saved are present on the main tours and absent on ITF singles. Winners and unforced errors historically appeared on a minority of main-tour matches and have not been delivered upstream since 2026-07-12 (measured 2026-08-17).
freshness.derived and freshness.measured each carry their own coverage (live | final | stale | none | diverged; final = the closing figures of a completed match — a finished match cannot be "stale", so its age_seconds is null), as_of, age_seconds and describes — the match state the numbers describe. On diverged the measured VALUES are withheld and freshness.measured_divergence says why; the top-level coverage only summarises the response. none on both returns 200 with null players, not 404 — the match exists and holding nothing for it is the honest answer.
THE TWO AGES USE DIFFERENT CLOCKS AND MUST NOT BE COMPARED. The derived age is measured against the newest SCORE row, because between points there is no new score either and wall-clock age would report staleness that does not exist. The measured age is wall clock, because those are fetched on a fixed cadence.
Tiebreak games are excluded from the DERIVED family and counted separately; the live record collapses a whole tiebreak onto one entry, so most of its points are lost.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
Responses
| Status | Meaning |
|---|---|
200 | Statistics with their own coverage and as_of |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
match_id | integer | |
coverage | string (live, final, stale, none, diverged) | |
as_of | string or null | When the underlying record was last updated (UTC) |
age_seconds | integer or null | Behind the newest SCORE row, not the wall clock |
games_counted | integer | |
tiebreak_games_excluded | integer | Tiebreaks are excluded — the live record collapses a whole tiebreak onto one entry |
inconsistent_games_excluded | integer | Games whose recorded outcome is neither a legal hold nor a legal break |
sets_covered | array of integer | |
freshness | object | Per-family coverage and age. Branch on this rather than on the top-level coverage, which only summarises the response. The two ages use DIFFERENT clocks and must not be compared: derived.age_seconds is relative to the newest score row (between points there is no new score either, so wall-clock age would report staleness that does not exist), while measured.age_seconds is wall clock, because those are fetched on a fixed cadence. |
detail | string | Present only when coverage is none |
players | object or null |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/statistics \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/points
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.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
matchId | path | integer | yes | 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.
|
after_seq | query | integer | no | 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. Default 0. |
changed_since | query | string | no | 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. |
Responses
| Status | Meaning |
|---|---|
200 | The point events page, seq order |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
match_id | integer | |
pbp_coverage | string (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 | string (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. |
covers_from_start | boolean or null | 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 | array of object | |
last_seq | integer | The resume cursor — pass as after_seq to continue. |
has_more | boolean | |
basis | string (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 | string (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. |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/points \
-H "Authorization: Bearer twjp_..."
GET /fixtures
Upcoming scheduled fixtures, earliest first (FREE)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
tour | query | string (atp, wta, challenger, itf, juniors) | no | Restrict results to one tour. Each value covers its singles and doubles draws, so atp includes ATP doubles and juniors covers the boys' and girls' Grand Slam draws. Omit for all tours. An unrecognised value is a 400 rather than a silent pass-through, so a caller never receives a tour it did not ask for. Applied before pagination, so meta.count reflects the filtered set. |
draw | query | string (singles, doubles) | no | Draw filter (added 2026-08-18) — the axis the tour filter deliberately collapses; the two compose (?tour=itf&draw=doubles is the ITF doubles slice). Same vocabulary as the draw field on Match, decided by the same shared definition, so filter and field cannot disagree. A row whose draw is null — a team tie, or no stated event type and no doubles-team participant — matches NEITHER value: null is an answer, not a wildcard. Two honesty notes: on /tournaments the answer comes from the event type alone (a tournament row has no participants to supply the doubles-team evidence matches have), and draw=doubles alone also returns mixed and exhibition doubles that no tour value reaches. An unknown value is a 400 bad_draw with the allowed values. |
is_qualifying | query | boolean | no | Return only matches in the QUALIFYING draw (true) or only those the source states are not (false), matching the is_qualifying flag each row carries (field added 2026-08-12, filter added 2026-10-02). Omit for both. Anything other than true/false is a 400 bad_is_qualifying, never a silently ignored filter.
THIS IS THE ONLY WAY TO SPLIT THE TWO DRAWS, and the reason is in the is_qualifying field description: the two are routinely carried under ONE tournament name with ONE round vocabulary, so tournament, tournament_id, round and round_code all read main-draw for a qualifying match. Do not filter on the round text.
THREE-VALUED, so this filter is too: a row whose flag is null (no source has ever stated it) matches NEITHER value and is returned only when the filter is omitted. null is an answer, not a false — returning it under is_qualifying=false would assert "main draw" on the strength of nothing. Measured 2026-10-02 over completed matches: the flag is stated on 98.9% of the last 30 days and 98.3% of 2026 outside the UTR circuit, where it is mostly unstated (1,695 of the 1,831 unstated rows in the last 30 days are UTR). |
gender | query | string (men, women) | no | Return only men's or only women's matches, matching the gender field each row carries (added 2026-09-09, documented here 2026-10-02). Omit for both. Anything other than men/women is a 400 bad_gender, never a silently ignored filter.
It exists because ?tour= deliberately collapses: challenger is ATP Challenger AND WTA 125, and itf and juniors are both sexes — so without this there is no way to ask for men's Challenger at all. A row whose gender is null (mixed doubles, a team tie) matches NEITHER value, the same contract as ?draw= and ?is_qualifying=.
bad_points_complete, bad_combination, points_read_disabled, bad_after_seq, bad_has_market, no_market, points_disabled, |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Fixtures with start time and player ids where resolved — the nulls are real states, not gaps (names are always present) |
401 | Missing, unknown, or disabled credentials |
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. |
Response fields
| Field | Type | Description |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/fixtures \
-H "Authorization: Bearer twjp_..."