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) and returns 403 upgrade_required on a FREE key. The player, country, from/to, tour and draw 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.
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). 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. |
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. |
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.) |
surface | string or null (hard, clay, grass, null) | |
indoor | boolean | |
format | string or null (BO3, BO5, null) | |
round | string or null | |
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). This is the field to branch on; it matches /history/archive/matches?round= exactly. Null when the label is unrecognised — never guessed. |
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 (Retired, Cancelled, Walk Over, Postponed, Interrupted, null) | How the match ended (or paused) when it 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 means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. 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. |
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.
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 | Current score (ULTRA adds win_probability_p1 + danger) |
401 | Missing, unknown, or disabled credentials |
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 |
|---|---|---|
sets | array of integer | |
games | array of array of integer | [games_p1, games_p2]; each a per-set list |
points | array of string or null | In-game points as tennis strings ("0", "15", "30", "40", "AD") — EXCEPT during a tiebreak, where they are the running tiebreak count as plain integer strings ("0", "1", "2", …); is_tiebreak tells the two notations apart. Entries can be NULL — observed live on completed matches, which also carry empty games arrays. Do not decode into non-nullable strings. |
server | integer or null (1, 2, null) | |
is_tiebreak | boolean | |
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; 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, the UTC instant the two probabilities were generated, and whether the anchor moved win_probability_p1. Null on rows stamped before it existed. |
timestamp | string or null |
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. 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}/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.
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 this match has a true per-point stream; "game" means only the snapshot score path covers it — points is empty and that is an answer, not an error. 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: live capture is inherently partial — the stream serves what arrived while the match ran, and the match-closing point never streams live. Where a measured-complete recorded point sequence of the finished match exists, this endpoint serves 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 — every live match, and any completed match without a measured-complete recorded sequence) or reconstruction (the projected complete sequence; quality is clean, every transition measured legal). Completeness beats the partial live capture 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: after a match completes and flips to reconstruction, re-read from after_seq=0 rather than resuming a live cursor into it.
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. |
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 = this match has a true per-point stream; game = only the snapshot score path covers it (points is empty — an answer, not an error). |
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 (every live match, and any completed match without a measured-complete recorded sequence); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time — includes the match-closing point, seq contiguous 1..N, ts null on every frame. Completeness beats the partial live capture wholesale; the two bases are never interleaved. |
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. |
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_..."