Real-time tennis scores, player data, match-winner market prices, and
model-driven match analysis. Read-only. Coverage spans ATP, WTA,
Challenger, ITF and the junior Grand Slam draws — depth differs by tour
and surface; GET /history/coverage states the measured numbers.
Access is tiered (FREE / BASIC / PRO / ULTRA). Each tier includes everything in the tiers below it; the concrete deltas are:
FREE — self-serve, no card (https://livetennisapi.com/subscribe/free).
Live and upcoming matches, current scores, players, fixtures, the
tournament catalogue (/tournaments), and your own usage stats.
30 requests/minute, 100/day. No market prices, no model fields, no
WebSocket. Historical results are not part of the tier, but a FREE key
may spend 20 calls per calendar month on the history endpoints as a
taste of the product — served exactly like an entitled call. Past that
they answer 403 upgrade_required carrying
free_history_taste: "used".
BASIC — adds historical data: the completed-match listing
(/history/matches, and status=completed on /matches), the
per-match point-by-point tape with the model win-probability on the
rows where the model ran
(/history/matches/{matchId}), the measured completeness rollup
(/history/coverage), and the results archive (1968–2022) —
deep results (/history/archive/matches), archive player bios
(/history/archive/players), career aggregates
(/history/archive/career) and head-to-head (/h2h).
60 requests/minute, 1,000/day.
PRO — adds match events (/matches/{matchId}/events), market prices
(/markets, /markets/{matchId}/prices, /matches/{matchId}/prices),
the pre-built monthly bulk history packages (/history/packages) and the
rank-ordered rankings listing (/rankings?system=).
300 requests/minute, 10,000/day.
ULTRA — adds model analysis (/matches/{matchId}/analysis), the live
model fields (win_probability_p1, danger) on every score object,
in-play match statistics (/matches/{matchId}/statistics), per-player
as-of ranking records (/rankings?player=), the as-of Elo tape
(/rankings?system=elo — both modes, plus kind=elo bulk packages),
rally construction
(/rally/matches, shot-by-shot charted data), career and per-match
charting stats (/charting/players, /charting/matches/{chartingMatchId}),
the reconstructed 2013–2022 archive tape
(/history/archive/matches/{archiveId}/tape — also opened by ANY active
History plan, Starter included), the WebSocket live feed at /ws and the
high-fan-out push feed (/ws-token), and outbound webhooks (direct keys).
600 requests/minute, 500,000/day.
History runs in two continuous halves, deliberately non-overlapping: the point-by-point tape (2023→now) covers January 2023 to now, match by match, point by point; the results archive (1968–2022) covers 1968 through 2022 as winner/loser-shaped RESULTS (final score, seeds, ranks at the time — no point-by-point). The archive ends exactly where the tape begins, so no match is ever served from two datasets.
Archive results played **2013–2022** additionally carry a RECONSTRUCTED
point-by-point tape at /history/archive/matches/{archiveId}/tape — the
score sequence behind the published result, rebuilt from the public record
after the fact. 97,901 matches / 14,340,663 rows, seasons **2013–2022
ONLY**: the archive holds a further 977,903 results from 1968–2012 and NOT
ONE of them has a tape, because there is no public point-by-point record of
those years to rebuild and we do not manufacture one. Write the range as
2013–2022, never as "pre-2023" — the second phrasing reads as 1968 onward
and is wrong by 45 seasons.
Nobody watched those matches, and the data says so: timestamp,
win_probability_p1 and danger are null on EVERY row and cannot be
filled in later — the production table has no timestamp column at all, and
the promotion script refuses to run if one ever appears.
Contrast the 2023→now tape, which is our own recording: the rows we
actually watched carry a real clock, and most of them a model probability.
Coverage of the era is real but partial — 19.3% of archive matches played
2013–2022 and 44.9% of tour-level play; main-draw tour buckets run
91.6–98.7%, ATP Challenger main draws 55.3% and Challenger qualifying
33.6%, slam QUALIFYING only 16.0% (ATP) / 18.1% (WTA), and ITF/futures
effectively nothing (25 of 116,575 ATP futures matches). It is not a
complete record of the era and is not sold as one.
Two different gates, on purpose: the per-match tape needs core ULTRA **or
any active History plan, Starter included**; the per-year bulk files
(/history/packages?kind=archive_tape, 2013–2022, JSONL + CSV) need core
ULTRA **or** a History Pro/Business subscription (an active one-off package
window counts). Core PRO carries NEITHER — it reads the archive RESULT and
is refused the tape.
A call above your tier returns 403 {"error":"upgrade_required"} — never
a silent empty result.
CORS is enabled across the REST surface: every response carries
Access-Control-Allow-Origin: * (GET/OPTIONS, no credentials mode — there
is no cookie or session, and a wildcard origin is incompatible with
credentials by design). Putting a FREE key in browser code is acceptable —
it is capped and revocable; a paid key belongs server-side only.
The /history/* endpoints are also sold standalone as the **Historical
Data API** (no live-API subscription required): **Starter** — single-match
point-by-point tape reads via the API (tape plus the model win-probability
per point), all tours (ATP/WTA/Challenger/ITF/juniors), one match per
request, no bulk downloads; **Pro** — everything in Starter plus bulk
monthly package
downloads and higher rate limits; **Business** — everything in Pro plus
year-scale archive exports, top rate limits and priority support. One-off
1-month and 1-year access passes are available without a subscription.
The results archive (1968–2022) endpoints (/history/archive/*, /h2h)
ride with the same entitlement — any active History plan, Starter
included, opens them alongside the tape endpoints, and that includes the
reconstructed 2013–2022 archive tape. The per-year archive_tape bulk
files do not: those need Pro, Business or an active one-off package pass,
because a Starter grant reads tapes one at a time and does not download
years of them.
Plans and prices: https://livetennisapi.com/historical-tennis-data-api
All timestamps are UTC ISO 8601 with a Z suffix. List endpoints return
{data, meta}; single resources return the object directly. Ignore
unknown fields — additive changes land within v1.
A native WebSocket live feed (ULTRA) exists at /ws under the same base
URL. Subscribe with one JSON frame whose keys are topics and
(optionally) signals: {"topics":["live-scores"]} — topics may also
name "match:<id>". The server acks with a subscribed frame, then
pushes score frames on every change plus a ping heartbeat roughly
every 15s. Score frames carry the ULTRA model fields
(win_probability_p1, danger) live; a null there means the model had
no output for that point, not that the field is REST-only. Opt into extra
signals with {"topics":["live-scores"],"signals":["break_point"]} to
also receive break_point and break_point_result frames — and
signals:["stoppages"] (2026-09-12) for the stoppage family: medical
timeouts, trainer calls, toilet breaks, whole-match stops and clock-inferred
pauses, each an Event object plus match_id — (schemas
BreakPoint / BreakPointResult). Without signals, score frames only.
signals may also name points — the live per-point event stream: one
point frame (schema PointFrame) per persisted point of your
subscribed matches. On the live basis seq is ARRIVAL order, not match
order: use it to page, dedup and resume, and sort by
(set, game, number) to replay in playing order — a tuple that may
repeat or carry a null number, so it orders points without identifying
them (see GET /matches/{matchId}/points). The signal is
config-gated and ships OFF by default; the subscribed ack echoes the
signals actually active, so points present in the ack means point
frames will flow and missing means they will not. Frames arrive only for
matches with pbp_coverage: "point" — a game-coverage match sends
none, honestly. Best-effort with NO replay: on reconnect (or to join
mid-match) catch up via GET /matches/{matchId}/points?after_seq= and
dedup by seq.
Max 2 concurrent connections per key. For high fan-out, GET /ws-token
mints a token for the separate push feed.
CLOSE CODES. Every refusal sends its error frame **and then closes with a
code that says what to do next**, so a reconnect loop or a supervisor keyed
on the close code alone behaves correctly without parsing the frame. The
close *reason* repeats the frame's error string, so (code, reason) is a
complete diagnosis even if the frame was missed.
1013 Try Again Later — transient, the request was fine: connection_limit
(reason connection_limit:per_key or connection_limit:server) and
service_unavailable. Back off and retry; for per_key release a
connection first, or move to the push feed, which has no shared ceiling.
1008 Policy Violation — the request as sent will never be accepted:
unauthorized, upgrade_required, email_unverified, client_blocked,
bad_json, no_topics, and any mid-stream loss of access. Fix the request
or the credentials; do not retry unchanged.
1012 Service Restart — reconnect with backoff and re-subscribe.
1000 Normal Closure — you closed it, or the stream ended normally.
Changed 2026-09-18: refusals raised during the *handshake* previously closed
1000 with an empty reason, indistinguishable from an orderly shutdown, so
a client awaiting its subscribed ack saw only a normal close. The error
frame was, and still is, delivered before the close; only the close code and
reason changed.
Getting a match id: it is the id field on any match object returned by
GET /matches, GET /fixtures or GET /history/matches, and the same value
works on every route that takes matchId.
Quickstart — no code required
Paste this into a browser, with your key on the end. That's the whole setup: no install, no headers, works on a phone.
https://api.livetennisapi.com/api/public/v1/matches?status=live&token=YOUR_KEY
You'll get every live match. Here is one, and how to read it:
- "players": { "p1": { "name": "Chase Ferguson" }, "p2": { "name": "Scott Jones" } }
- who is playing
- "sets": [1, 0]
- p1 leads one set to nil
- "games": [[6, 3], [4, 4]]
- first list is p1, second is p2 — so 6-4 in the first set, 3-4 in the second
- "points": ["0", "0"]
- the game in progress
- "server": 1
- p1 is serving (2 = p2)
Every score array is player-major: the first list belongs to player 1, the second to player 2. Once that clicks, the rest of the API reads the same way.
Two more you can click, swapping 21131 for any id from the list above:
https://api.livetennisapi.com/api/public/v1/matches/21131?token=YOUR_KEY
https://api.livetennisapi.com/api/public/v1/matches/21131/score?token=YOUR_KEY
The same first call from a terminal — copy, paste, replace YOUR_KEY.
In code the key travels in the X-API-Key header rather than the URL:
curl -H "X-API-Key: YOUR_KEY" \
"https://api.livetennisapi.com/api/public/v1/matches?status=live"
Your key is on https://livetennisapi.com/account behind Show my key.
Base URL
https://api.livetennisapi.com/api/public/v1
Authentication
Three ways to present your key — all equivalent. Use the header in code; use
?token= when you just want to click a link or test from a browser or phone.
The /health endpoint needs no key.
Authorization: Bearer twjp_...
X-API-Key: twjp_...
?token=twjp_... in the URL — browser-friendly
Plans
Every plan includes everything in the plans below it. The table shows the delta — exactly what each upgrade adds, and the request budget you get.
| Plan | Adds | Rate limit | Price |
|---|---|---|---|
| FREE | The current state of the game: live & upcoming matches, current scores, players, fixtures, your usage stats. No history, no market prices, no model fields, no WebSocket. | 30/min · 100/day | $0 — no card |
| BASIC | Historical data, in two continuous halves: the point-by-point tape (2023→now) — the completed-match listing (/history/matches, status=completed) and the full per-match tape with the model win-probability on the rows where the model ran (/history/matches/{matchId}) and the measured completeness rollup per tour × draw bucket (/history/coverage) — and the results archive (1968–2022): deep results (/history/archive/matches), archive player bios, career aggregates and head-to-head (/h2h). | 60/min · 1,000/day | $9.99/mo |
| PRO | Match events, market prices (/markets), the pre-built bulk history packages (/history/packages, JSONL/CSV), and the rank-ordered rankings listing (/rankings?system=). | 300/min · 10,000/day | $29.99/mo |
| ULTRA | Model analysis, live win_probability_p1 + danger on every score, in-play match statistics, live per-point events (/matches/{matchId}/points + the WebSocket point frames, where a point-level feed covers the match), per-player as-of ranking records, the as-of Elo tape (/rankings?system=elo), rally construction (shot-by-shot charted data), the WebSocket push feed, outbound webhooks. | 600/min · 500,000/day | $99.99/mo |
The table above scrolls sideways.
Calling an endpoint above your plan returns 403 {"error":"upgrade_required"} —
never a silent empty result. See pricing.
Historical Data API — standalone plans
The /history endpoints are also sold on their own, without a live-API
subscription:
| Plan | Adds |
|---|---|
| Starter | Single-match point-by-point tape reads via the API — the tape plus the model win-probability where computed — for all tours (ATP, WTA, Challenger, ITF), one match per request. No bulk downloads. |
| Pro | Everything in Starter, plus bulk monthly package downloads and higher rate limits. |
| Business | Everything in Pro, plus year-scale archive exports, top rate limits and priority support. |
| One-off passes | 1-month and 1-year access passes, no subscription. |
The table above scrolls sideways.
Plans and prices: https://livetennisapi.com/historical-tennis-data-api.
Break-point Alerts — hosted alerts, no code
A hosted companion product that pushes break-point alerts to your channels
(the same signal the ULTRA WebSocket break_point frame carries, without
running a client). Free: high-swing break points only (probability swing
≥ 0.15), one delivery channel. Pro ($9.99/mo): every break point — no
swing floor — to unlimited channels: Telegram, Discord, email, SMS, WhatsApp.
Details: livetennisapi.com.
Official client libraries
| Language | Install | Source |
|---|---|---|
| Python | pip install livetennisapi | livetennisapi-python |
| JavaScript / TypeScript | npm install livetennisapi | livetennisapi-js |
| MCP server (LLM agents) | npx livetennisapi-mcp | livetennisapi-mcp |
Conventions
- Timestamps are UTC ISO 8601 with a
Zsuffix. - List endpoints return
{data, meta}; single resources return the object directly. limitdefaults to 50; the API rejects anything above 200. Paginate withoffset.- Ignore unknown fields. Additive changes ship within
v1, so a client that rejects unrecognised fields will break. Every official SDK parses permissively. - Score shape:
setsis[sets_p1, sets_p2].gamesis[games_p1, games_p2]where each side is a per-set list — so[[6,3,2],[4,6,1]]reads 6-4, 3-6, 2-1. It is player-major, not set-major.
Endpoints
GET /health— Liveness probe (no auth)GET /matches— List matches by lifecycle status (FREE)GET /matches/{matchId}— Full match detail (FREE; +market PRO, +analysis ULTRA)GET /matches/{matchId}/score— Current score only — lowest-latency REST read (FREE)GET /matches/{matchId}/events— Match events, newest first (PRO)GET /events— Slate-wide events feed — every match's events in one call, oldest first, cursor by id (PRO)GET /players/{playerId}/stoppages— One player's in-match stoppages and did-not-finish outcomes, newest first over a window (PRO)GET /players/{playerId}/injuries— Alias of /players/{playerId}/stoppages — the identical response (PRO)GET /matches/{matchId}/status-history— The per-match status ledger — every status / event_status transition with its UTC instant (BASIC, history)GET /matches/{matchId}/analysis— Model analysis for a match (ULTRA)GET /matches/{matchId}/statistics— In-play statistics — aces, double faults, serve split, hold/break %, break points, service & return points (ULTRA)GET /matches/{matchId}/points— Live per-point events in seq order — the REST catch-up for the WebSocket point stream (ULTRA)GET /players— Search players by name (FREE)GET /players/{playerId}— One player's bio + ranking + cached stats (FREE)GET /tournaments— Tournament catalogue — the id space `Match.tournament_id` joins (FREE)GET /tournaments/{tournamentId}— One tournament by its stable id (FREE)GET /markets— The match's match-winner market (PRO)GET /markets/{matchId}/prices— Market + recent price ticks per side, newest first (PRO)GET /matches/{matchId}/prices— Bare price ticks of the mapped match-winner market, newest first (PRO)GET /history/matches/{matchId}/prices— Per-point price history — the match-winner quote in force at every played point (PRO)GET /history/matches— Completed matches, newest first, with derived winner and tape coverage (BASIC)GET /history/coverage— Measured completeness rollup per tour × draw bucket (BASIC)GET /history/matches/{matchId}— Per-match tape — point-by-point score + per-point model probabilities (BASIC)GET /history/archive/matches— Results archive (1968–2022) — deep historical results (BASIC)GET /history/archive/matches/{archiveId}— One archive result, with serve statistics where recorded (BASIC)GET /history/archive/matches/{archiveId}/tape— Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)GET /history/archive/players— Archive player bios — hand, DOB, country, height, career-high (BASIC)GET /history/archive/career— Career aggregates over the results archive, 1968–2022 (BASIC)GET /h2h— Head-to-head across the results archive (1968–2022) and our own completed matches (2023→now) (BASIC)GET /history/packages— List the pre-built monthly bulk history packages (PRO)GET /history/packages/{period}— One monthly package — manifest, or the bulk file itself (PRO)GET /fixtures— Upcoming scheduled fixtures, earliest first (FREE)GET /usage— Your own usage vs quota (FREE — any tier)GET /rankings— Rankings and Elo — rank-ordered listing (PRO) or per-player as-of records (ULTRA); the as-of Elo tape is ULTRA in both modesGET /rally/matches— Charted matches with shot-by-shot data (ULTRA)GET /rally/matches/{rallyMatchId}— Rally construction for one charted match (ULTRA)GET /history/matches/{matchId}/rally— Rally construction by OUR match id (ULTRA)GET /charting/players— Career shot-level charting aggregate for one player (ULTRA)GET /charting/matches/{chartingMatchId}— One charted match, every stat family for both players (ULTRA)POST /webhooks— Register an outbound webhook (ULTRA, direct keys only)GET /webhooks— List your webhooks (ULTRA, direct keys only; never includes the secret)DELETE /webhooks/{webhookId}— Remove one of your webhooks (ULTRA, direct keys only)GET /ws-token— Mint a connection token for the high-fan-out push feed (ULTRA)
GET /health
Liveness probe (no auth)
Responses
| Status | Meaning |
|---|---|
200 | OK |
Response fields
| Field | Type | Description |
|---|---|---|
status | string | |
version | string |
Example
curl https://api.livetennisapi.com/api/public/v1/health
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. 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.
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. |
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. 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.
|
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; absent on history-tape rows; 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; absent on history-tape rows, which are already served in order. |
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. |
detail | string | Present only when something is withheld — plain-language reason, e.g. a null games array. |
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 /players/{playerId}/stoppages
One player's in-match stoppages and did-not-finish outcomes, newest first over a window (PRO)
Added 2026-09-16. Medical timeouts and trainer calls on the player's matches (from the stoppage family of GET /matches/{matchId}/events), plus retirements and walkovers where THIS player is the non-winner, merged and sorted by at descending. Window: since/until (ISO date or UTC instant; default the last 180 days; at most 366 days, else 400 window_too_long). kind filters the seven row kinds (default medical_timeout,trainer_called,retirement,walkover); before pages by meta.next_cursor. meta.latest_medical_timeout and previous_medical_timeout are the two newest medical timeouts in the window whatever the page or the kind filter. meta.record_starts states how far back each family goes: stoppage rows exist from 2026-09-12 only; outcome rows from the oldest match with a stated winner. In-match stoppages and match outcomes only — no off-court injury record exists here. Same PRO capability as /events. GET /players/{playerId}/injuries serves the identical response.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
playerId | path | integer | yes | |
since | query | string | no | Window start — an ISO date (start of that day) or a UTC instant. Default until minus 180 days. |
until | query | string | no | Window end — an ISO date (the whole of that day) or a UTC instant. Default now. |
kind | query | string | no | Comma-separated row kinds from medical_timeout, trainer_called, toilet_break, pause, stoppage, retirement, walkover. Default medical_timeout,trainer_called,retirement,walkover. |
before | query | string | no | The meta.next_cursor of the previous page (opaque <kind>:<id>), valid for the same window and kinds. |
limit | query | integer | no | Default 50. |
Responses
| Status | Meaning |
|---|---|
200 | The player's stoppage and outcome rows, newest first |
400 | window_too_long, bad_window, bad_since, bad_until, bad_kind or bad_cursor |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No roster player holds this id. Carries the archive signpost (detail + see) when the id is a corpus person id. |
410 | The player id existed and was merged into another player record, or was retired with no successor. The body is PlayerMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404. An id that was NEVER issued keeps its 404 — a 410 would assert it once existed. |
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/players/1104/stoppages \
-H "Authorization: Bearer twjp_..."
GET /players/{playerId}/injuries
Alias of /players/{playerId}/stoppages — the identical response (PRO)
Added 2026-09-16. The same handler, parameters, rows and meta as /players/{playerId}/stoppages, under the word the request used. The honest name is stoppages: nothing here is a diagnosis, only what the scorer, umpire or result stated.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
playerId | path | integer | yes | |
since | query | string | no | |
until | query | string | no | |
kind | query | string | no | |
before | query | string | no | |
limit | query | integer | no | Default 50. |
Responses
| Status | Meaning |
|---|---|
200 | Identical to /players/{playerId}/stoppages |
400 | window_too_long, bad_window, bad_since, bad_until, bad_kind or bad_cursor |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No roster player holds this id. Carries the archive signpost (detail + see) when the id is a corpus person id. |
410 | The player id existed and was merged into another player record, or was retired with no successor. The body is PlayerMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404. An id that was NEVER issued keeps its 404 — a 410 would assert it once existed. |
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/players/1104/injuries \
-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}/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 /players
Search players by name (FREE)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
search | query | string | no | |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Players (ranked first; no stats object on the list) |
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/players \
-H "Authorization: Bearer twjp_..."
GET /players/{playerId}
One player's bio + ranking + cached stats (FREE)
playerId is a ROSTER id — the live player registry, the id space /players, /matches and /rankings all speak.
It is NOT the archive corpus person id. The results archive (1968–2022) keeps its own person registry, and archive match rows publish those ids as winner.player_id / loser.player_id. The two spaces are disjoint: no corpus id resolves here, and since 2026-09-20 a 404 for one says so and points at /history/archive/players?id={playerId}, which is where that id is read. A 404 with no see field is simply an id we do not hold.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
playerId | path | integer | yes |
Responses
| Status | Meaning |
|---|---|
200 | Player with stats ({ratings, season}) |
401 | Missing, unknown, or disabled credentials |
404 | No roster player holds this id. When the id IS a corpus person id the body adds detail and see (the archive lookup); otherwise it is the plain Error body. |
410 | The player id existed and was merged into another player record, or was retired with no successor. The body is PlayerMerged, not Error: it carries a forwarding address, which is the whole point of answering 410 rather than 404. An id that was NEVER issued keeps its 404 — a 410 would assert it once existed. |
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 | |
name | string | |
tour | string or null | The record's OWN tour, which is NOT the tour filter vocabulary. It is granular (juniors_boys, juniors_girls, challenger_men) where the filter is grouped (juniors, challenger), and a doubles team reports it UPPERCASE (ATP) where an individual reports lowercase (atp). Treat it as an opaque string; do not parse it into the filter enum. |
country | string or null | |
ranking | integer or null | Official singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date. |
ranking_points | integer or null | Points on the same official table, refreshed with ranking. |
ranking_movement | string or null (up, down, same, null) | |
hand | string or null (R, L, null) | |
backhand | integer or null (1, 2, null) | |
birthday | string or null | |
is_doubles_team | boolean | |
data_completeness | object | How much biographical detail is known for this player, so a consumer can distinguish "not in the feed" from "not yet fetched" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour. |
stats | object | Single-player endpoint only |
Example
curl https://api.livetennisapi.com/api/public/v1/players/1104 \
-H "Authorization: Bearer twjp_..."
GET /tournaments
Tournament catalogue — the id space Match.tournament_id joins (FREE)
Stable tournament identity, one row per tournament × event type, stable across seasons. city/country come from a curated table and category only where our catalogues agree unambiguously on an exact-name join — each is null otherwise, never derived from the tournament name. Each row also carries the CURRENT season's tier / tier_source (added 2026-09-22) — the level in a closed vocabulary (atp_500, challenger_125, wta_125, itf_w35 … | null); the tier is per season, so a match carries its own.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
search | query | string | no | Case-insensitive substring match on the tournament name. |
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 | Tournaments, name order |
400 | Bad query parameter |
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/tournaments \
-H "Authorization: Bearer twjp_..."
GET /tournaments/{tournamentId}
One tournament by its stable id (FREE)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
tournamentId | path | string | yes | The tournament_id carried on match objects. |
Responses
| Status | Meaning |
|---|---|
200 | The tournament |
401 | Missing, unknown, or disabled credentials |
404 | No such resource, or no data yet |
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 | string | The stable id Match.tournament_id joins. |
name | string or null | |
tour | string or null (atp, wta, challenger, itf, juniors, null) | |
surface | string or null (hard, clay, grass, null) | Court surface, null where the catalogue holds none — 61 of 10,287 tournaments on 2026-09-20. 35 of those are team competitions (Davis Cup, Billie Jean King Cup, Laver Cup), which have no tournament-level surface to hold: each tie is played on the host nation's choice, and that surface is published on each MATCH of the tie instead. The upstream field for those rows carries the DRAW TIER rather than a surface ("- Preliminary", "- Play Offs", "- Promotion"), and a value that is not one of hard/clay/grass is rejected rather than published. 3 are the exhibition catch-all and 23 are ordinary events the upstream catalogue simply never stated a surface for. |
indoor | boolean | |
city | string or null | Host city, from a curated table — null where not curated. |
country | string or null | Host country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.) |
category | string or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null) | Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level. |
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 this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed. |
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. |
Example
curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
-H "Authorization: Bearer twjp_..."
GET /markets
The match's match-winner market (PRO)
The match-winner market mapped to this match — data holds at most one object, and meta.count is 0 when nothing is mapped.
MATCH-WINNER ONLY. No handicaps or game spreads, no totals, no set-winner books, and no per-game or per-set derivative of any kind. A quote here is the probability that a player WINS THE MATCH. Venues do list tennis derivatives beside the match-winner book — game spreads such as "A (-3.5) vs B (+3.5)" among them — and this API deliberately publishes none of them: a derivative is refused at the mapping step so it can never be served as, or beside, a match-winner price (2026-09-22).
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
match_id | query | integer | yes |
Responses
| Status | Meaning |
|---|---|
200 | Markets |
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/markets \
-H "Authorization: Bearer twjp_..."
GET /markets/{matchId}/prices
Market + recent price ticks per side, newest first (PRO)
The mapped match-winner market for one match, with its recent price ticks per side, newest first.
404 with error: no_market (since 2026-09-02; match_id and coverage: "none" alongside) when no match-winner market is mapped to the match — a market is mapped only where one is published for that fixture, so a client walking every live match will meet this. A bare not_found means the match id itself does not exist. Every row of GET /matches carries has_market, so a slate can be filtered before any call here. GET /matches/{matchId} answers 200 for the same match with market and market_price set to null instead, and carries the live score in the same response, so a per-match poll built on the detail route never spends a call on a 404.
Ticks are kept for 30 days and then deleted: a market older than that answers with an empty prices array while staying mapped — retention, not a fault. For paging past 500 ticks use /matches/{matchId}/prices with its cursor.
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. |
Responses
| Status | Meaning |
|---|---|
200 | Market with prices |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | error: not_found — no such match id. error: no_market (with match_id, coverage: "none", detail) — the match exists and no match-winner market is mapped to it; has_market 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 |
|---|---|---|
id | integer | |
question | string or null | |
status | string or null (active, resolved, closed, null) | |
volume | number or null | |
liquidity | number or null | |
end_date | string or null | |
prices | array of object | Prices endpoint / match detail only; newest first |
Example
curl https://api.livetennisapi.com/api/public/v1/markets/18953/prices \
-H "Authorization: Bearer twjp_..."
GET /matches/{matchId}/prices
Bare price ticks of the mapped match-winner market, newest first (PRO)
Recent ticks only (no market wrapper). limit caps at 500; minutes bounds the lookback window; cursor pages an older match. 404 no_market when the match exists but has no mapped market (404 not_found for an unknown id). Retention (since 2026-09-15): in-play ticks of a match with a mapped market — from live to completion, plus a five-minute pad — are kept at full resolution and not deleted; pre-match / idle ticks and unmapped markets keep the tiers (every tick for an hour, ~1/minute to 24h, ~1/5 minutes after, deleted after 30 days). Ticks stamped before 2026-09-15 were already thinned under those tiers.
Ticks are kept for 30 days and then deleted, so a match older than that answers an empty data while its market stays mapped — that is retention, not a fault. Past 500 ticks, page with the cursor: read a page, and while meta.has_more is true pass meta.next_cursor back as ?cursor= for the next (older) page. Pages never overlap or skip a tick (added 2026-09-09).
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 100. |
minutes | query | integer | no | |
cursor | query | string | no | Keyset cursor for the next (older) page — the meta.next_cursor of the previous page (added 2026-09-09). Opaque; anything else is 400 bad_cursor. |
Responses
| Status | Meaning |
|---|---|
200 | Price ticks |
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 |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/matches/18953/prices \
-H "Authorization: Bearer twjp_..."
GET /history/matches/{matchId}/prices
Per-point price history — the match-winner quote in force at every played point (PRO)
One row per played point of a match with a mapped market, ascending by seq (added 2026-09-15). Each row carries the score state the BASIC tape publishes and, per neutral side ("1" = players.p1's outcome, "2" = p2's), the newest quote observed at or before the point's capture time — or, when none precedes it, the first quote after it within five minutes (negative lag). A side with no usable quote is null, never invented. resolution is an honest label of the join: tick (|lag| <= 15 s), minute (<= 90 s), coarse (older); matches played from 2026-09-15 align against the full-resolution in-play tape, earlier ones against the thinned tape and read minute/coarse. The ULTRA-only per-point fields (server, winner, serve, outcome) are not on this surface. Works on a live match too. 404 no_market when the match has no mapped market — enumerate with GET /history/matches?has_market=true.
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 | Resume cursor — return points with seq greater than this (meta.last_seq of the previous page). Anything but a non-negative integer is a 400 bad_after_seq. Default 0. |
limit | query | integer | no | Default 100. |
Responses
| Status | Meaning |
|---|---|
200 | Per-point price rows, ascending by seq |
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 |
|---|---|---|
data | array of object | |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953/prices \
-H "Authorization: Bearer twjp_..."
GET /history/matches
Completed matches, newest first, with derived winner and tape coverage (BASIC)
Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see https://livetennisapi.com/historical-tennis-data-api). 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". All tours, January 2023 → now (deeper results live in the results archive, 1968–2022, at /history/archive/matches). Filter to a date range with from/to, and by tour, draw (singles/doubles), player (either participant) and country — same vocabulary as /matches. Each item carries a tape object saying what point-by-point data we hold for that match, so a whole page can be qualified in one call instead of one request per match. NOTE ?coverage= is applied AFTER the page is cut, so a filtered page is routinely shorter than limit (and may be empty) while later pages still hold matching matches — a short filtered page is not an end-of-data signal; ?points_complete= filters the same way. ?tier= (added 2026-09-22) selects by the level the tournament was played at in the match's season, and ?restated_since= (added 2026-09-22) keeps only the matches whose published result was corrected after an instant — the poll to run after each settlement pass.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
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.
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. |
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. |
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. Unknown value → 400 bad_tier with the offending values in bad and the full vocabulary in allowed. |
coverage | query | string (from_start, partial, reconstructed, reconstructed_partial, none) | no | Keep only matches whose tape has this coverage. An unknown value is a 400 bad_coverage listing the accepted values in allowed. |
points_complete | query | string (true, false) | no | Keep only matches whose measured point-completeness ledger verdict is this value — best-basis (the served tape OR an on-disk reconstruction measured point-complete; fetch the latter with ?points=complete on the per-match tape). The ledger is a per-match cache reconverged nightly. A match not yet measured matches NEITHER value; anything but true/false is a 400 bad_points_complete. Applied AFTER the page is cut, exactly like ?coverage=. |
restated_since | query | string | no | Keep only matches whose published result was CORRECTED after this instant (added 2026-09-22) — a restatement row on their status history later than it. The poll to run after recording results: anything it returns has a result_restated_at past your cursor and a result_version above the one you hold, and its status history says what changed. An ISO-8601 instant with a Z or an offset (a naive value is read as UTC); a bare date is a 400 bad_restated_since — a day is not an instant, exactly as for changed_since. Composes with every other filter here. A match whose correction was a reopen is live again and reappears here once re-completed, with a further restatement row. Rows with this basis exist from 2026-09-22; earlier corrections were not signalled and nothing is reconstructed for them. |
Responses
| Status | Meaning |
|---|---|
200 | Completed matches (winner = 1|2|null, from final sets), each with its tape coverage |
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/history/matches \
-H "Authorization: Bearer twjp_..."
GET /history/coverage
Measured completeness rollup per tour × draw bucket (BASIC)
Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see https://livetennisapi.com/historical-tennis-data-api). 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 numbers to read BEFORE choosing what to backtest, in one call instead of paging the archive. A PREBUILT snapshot rebuilt nightly right after the completeness ledger reconverges — never computed at read time — so as_of (= built_at) dates every number, and ledger_max_computed_at is the newest underlying per-match measurement. Buckets are atp/wta/challenger/itf/juniors × singles/doubles plus other (team ties, mixed, exhibitions, and matches with no stated event type — counted, never dropped, so the totals cannot lie), derived from the same registries as the tour and draw fields. method states the full measurement rule in one paragraph, so every number carries its own definition. As of 2026-08-18 the headline spread it exposes: 51.1% of ITF singles matches are point-complete on the best basis against 3.5% of ITF doubles — do not extrapolate a completeness rate across a tour group.
Responses
| Status | Meaning |
|---|---|
200 | The rollup artifact, dated by its own as_of |
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. |
503 | coverage_unavailable — the artifact has not been built yet (or is unreadable). Honest and temporary; retry after the nightly build. The rollup is never computed inline. |
Response fields
| Field | Type | Description |
|---|---|---|
as_of | string | Equal to built_at, verbatim — the artifact's own clock, and the date to quote with every number in this response. |
built_at | string | When the rollup was built (UTC). |
ledger_max_computed_at | string or null | The newest underlying per-match measurement in the completeness ledger. |
method | string | The full measurement rule for point_complete, in one paragraph — every number carries its own definition. |
buckets | object | One CoverageBucket per tour × draw bucket (atp_singles … juniors_doubles, plus other). A bucket with zero completed matches is OMITTED rather than emitted as zeros — read a missing key as "nothing to count", not an error. |
totals | object | The five verifiable numbers for one bucket. |
Example
curl https://api.livetennisapi.com/api/public/v1/history/coverage \
-H "Authorization: Bearer twjp_..."
GET /history/matches/{matchId}
Per-match tape — point-by-point score + per-point model probabilities (BASIC)
The tape is the point-by-point score sequence we hold for this match — every recorded score row including the model fields win_probability_p1 and danger at that point — plus match metadata and the model profiles produced during the match. The model fields here are part of the paid History product by design, distinct from the ULTRA-gated LIVE model fields. One match per request. Requires BASIC on the live API, or the Historical Data API Starter plan and up.
The tape is NOT guaranteed to cover the whole match — check meta.coverage and meta.point_source before backtesting. Rows expanded after the fact from a finished-match point-by-point record carry a null timestamp and null model fields; nothing is ever synthesised.
WORKS ON A LIVE MATCH, not only a completed one. The tape is assembled from whatever has been committed so far, so it is how you read the point-by-point history of a match in progress — including games played before you started watching, where we were already watching them. The LIST endpoint is completed-only; get live ids from /matches?status=live. /matches/{matchId}/score is one state; this is the sequence of states.
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 | string (raw, clean) | no | raw (default) is every row we committed, in commit order — deliberately non-monotonic, since independent sources race and a higher-trust one may correct a lower-trust one backwards. clean returns one row per distinct score state, keeping the last assertion of each, ordered by MATCH PROGRESS (the same ranking our arbiter uses), not by wall clock — so array order is the authoritative point order, and timestamp values may locally disagree with it by a few seconds where a stale re-assertion carried a later clock. Within a game, deuce states legitimately cycle (40-40 ↔ AD) and rank equally, so consecutive clean rows are not guaranteed point-monotonic there; point_winner is only set where the transition is a single attributable point. winner (since 2026-09-22) rides every row of EITHER sequence with the same verdict: on the raw sequence it is judged against the previous raw row, so a re-send reads null there and collapses away here. An unknown value is a 400 bad_sequence. Default raw. |
points | query | string (default, complete) | no | default serves observed rows first — what our own pipeline committed, a SAMPLED record of the match. complete is the explicit opt-out of that precedence for consumers who want every point: where a whole-match reconstruction exists it is served WHOLE, in its own point order, with point_winner (and winner, the same verdict) on every row and null timestamps/model fields per the reconstruction contract. Where none exists, the response is exactly the default read plus meta.points (whose available_complete tells the cases apart) — no error. Cannot combine with sequence=clean (400 bad_combination — the state-key collapse would delete the repeated deuce states a complete point sequence contains). An unknown value is a 400 bad_points; where not yet enabled, complete answers 400 points_read_disabled rather than silently serving the default. coverage and meta.points are orthogonal axes: coverage says how the rows were OBTAINED, points says how COMPLETE the sequence is — completeness is only ever claimed per match, as measured. Default default. |
Responses
| Status | Meaning |
|---|---|
200 | The full tape (match + tape + profiles + coverage meta) |
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 | object | |
tape | array of object | Chronological score sequence — see HistoryTapeRow |
tiebreaks | array or null | Per-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final. |
profiles | array of object | Model profiles, oldest first (Analysis profile shape) |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953 \
-H "Authorization: Bearer twjp_..."
GET /history/archive/matches
Results archive (1968–2022) — deep historical results (BASIC)
Completed-match RESULTS from a licensed historical corpus — ATP and WTA main draws, qualifying/challengers and futures tiers, 1968 through 2022. Winner/loser-shaped records with final score, round, seeds, the players' ranks AT THE TIME, and per-match serve statistics where the era recorded them. Requires BASIC on the live API, or any Historical Data API plan (Starter and up).
A SEPARATE id space from /matches — archive people are identified by the corpus person id and by name, never by roster player ids — and the archive ends where our own point-by-point coverage begins (2023-01), so no match is ever served from two datasets. event_date is the TOURNAMENT START date, the only date records of this era carry.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
tour | query | string (atp, wta) | no | The archive covers the ATP and WTA corpora only. |
name | query | string | no | Case-insensitive substring match on EITHER player's name (min 3 chars). |
from | query | string | no | Earliest tournament start date (YYYY-MM-DD). |
to | query | string | no | Latest tournament start date (YYYY-MM-DD). |
round | query | string (F, SF, QF, R16, R32, R64, R128, RR, BR, Q1, Q2, Q3, Q4, ER) | no | The archive's controlled round vocabulary. |
level | query | string | no | Source tier code: G=grand slam, M=masters, A=tour, F=finals, D=davis cup, C=challenger, O=olympics; the futures tiers carry their category codes (e.g. 15, 25) as published. |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Archive results, newest tournament first |
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/history/archive/matches \
-H "Authorization: Bearer twjp_..."
GET /history/archive/matches/{archiveId}
One archive result, with serve statistics where recorded (BASIC)
Same entitlement as the archive listing. stats is null for the (mostly pre-1991) rows the source never recorded statistics for — never synthesised. A result played 2013–2022 may also have a reconstructed point-by-point tape at /history/archive/matches/{archiveId}/tape; the RESULT stays on BASIC either way.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
archiveId | path | integer | yes |
Responses
| Status | Meaning |
|---|---|
200 | The archive record, stats included where the era recorded them |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No such resource, or no data yet |
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 | |
source_id | string | |
tour | string (atp, wta) | |
level | string or null | Source tier code (G/M/A/F/D/C/O, or a futures category code as published). |
tournament | string or null | |
surface | string or null | |
draw_size | integer or null | |
event_date | string or null | Tournament START date — per-match dates do not exist in this era's records, and we do not invent them. |
round | string or null | |
best_of | integer or null | |
minutes | integer or null | |
winner | object | One participant of an archive result, as the corpus records them. |
loser | object | One participant of an archive result, as the corpus records them. |
score | string or null | The final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O". |
outcome | string or null (completed, retired, walkover, default, abandoned, null) | Parsed from the score's own vocabulary; null when unparseable — never guessed. |
stats | object or null | Detail endpoint only. {"winner":{...}, "loser":{...}} with aces, double_faults, serve_points, first_in, first_won, second_won, serve_games, bp_saved, bp_faced where the source recorded them; null otherwise (most rows before 1991) — never synthesised. |
Example
curl https://api.livetennisapi.com/api/public/v1/history/archive/matches/{archiveId} \
-H "Authorization: Bearer twjp_..."
GET /history/archive/matches/{archiveId}/tape
Reconstructed 2013–2022 point-by-point tape for one archive result (ULTRA, or any History plan)
The score sequence behind a 2013–2022 archive result, REBUILT FROM THE PUBLIC RECORD. The result tells you what happened; this tells you how.
NOBODY WATCHED THESE MATCHES, and every field that would imply otherwise is null. timestamp is null on every row — not by convention, but because the production table has no timestamp column at all, so no repair, backfill or re-archive can put a wall clock on a 2016 row, and the promotion script refuses to run if such a column ever appears. win_probability_p1 and danger are null on every row: the model was not running in 2016 and nothing has been back-computed and served as if it had been (meta.model_rows and meta.observed_rows are both 0, always). profiles is always []. The rule we publish for the 2023+ tape — a non-null timestamp means we watched that point live — holds across both halves of the product.
YEARS: 2013–2022 ONLY. 97,901 matches and 14,340,663 rows. The results archive holds a further 977,903 matches from 1968–2012 and NONE of them has a tape — there is no public point-by-point record of those years to rebuild and we do not manufacture one. A match with no tape answers 404, which is also the answer when a tape exists and failed the proof below; the two are deliberately not distinguished.
COVERAGE OF THE ERA, thin spots beside the strong ones: 19.3% of archive matches played 2013–2022 overall, 44.9% of tour-level play. Main-draw tour buckets run 91.6–98.7% (ATP Masters 98.7%, ATP slam 98.0%, WTA Premier Mandatory 97.9%, WTA slam 97.4%, ATP 250–500 95.4%, WTA Premier 94.1%, WTA International 91.6%); ATP Challenger main draws 55.3% and Challenger qualifying 33.6%; slam QUALIFYING is thin at 16.0% (ATP) and 18.1% (WTA); ITF and futures are effectively absent — 25 of 116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25 — and this product does not cover them. If your work is ITF, this is the wrong dataset and we would rather you knew now. 31.0% of the corpus is qualifying-draw play, which is unusual at this scale and is the part you are least likely to hold already.
HOW A TAPE IS BOUND TO ITS MATCH. A tape attached to the wrong match is silent, permanent and poisons anything trained on it, so each one proves five things before publication: the EDITION binds by majority vote onto one archive tournament; the NAMES resolve to exactly one archive row with an unambiguous orientation; the ROUND agrees through an explicit map (this is what separates the qualifying and main-draw meetings of the same pair inside one edition); the SET SPINE derived from the rows equals the published final score exactly (a retirement matches on a one-directional prefix rule — a tape may stop short, never run past the printed score); and the vendor DATE lies inside the tournament's window. The tape is then walked step by step against 23 interior invariants — games and sets never regress or jump, a closed set holds a score a set can actually finish on, a tiebreak is flagged only where one can be played, points never run backwards inside a game, seq is contiguous. A tape that fails any clause or any invariant is REFUSED WHOLESALE rather than published against a guess, which is why this endpoint answers 404 instead of serving a probable match.
archiveId is an archive_matches.id (the id on a /history/archive/matches row), NOT a match id — the two are separate, overlapping integer spaces, so passing one to the other's routes resolves a different, real record without erroring. The response names it meta.archive_match_id for exactly that reason. Rows are WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, server: 1 means the winner was serving), unlike the p1/p2-shaped 2023+ tape — a live match has no winner while it is being played.
This operation takes NO query parameters: sequence and points are ignored rather than refused. meta.sequence is always raw — a reconstructed tape has one source and no corrections, so there is nothing for a clean collapse to remove.
Tier: core ULTRA, or any active History plan (Starter included, which opens it on a FREE core key). The archive RESULT itself (GET /history/archive/matches/{archiveId}) stays on BASIC — core BASIC and core PRO read the result and are refused the tape, and the refusal is 403 upgrade_required carrying capability: archive_tape. The per-year BULK files (?kind=archive_tape) are a separate gate: core ULTRA, or a History Pro/Business subscription, or an active one-off package window.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
archiveId | path | integer | yes | An archive_matches.id — the id on a /history/archive/matches row, never a match id. |
Responses
| Status | Meaning |
|---|---|
200 | The reconstructed tape (match + tape + tiebreaks + an always-empty profiles + provenance meta) |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No tape for this archive result — it is 1968–2012, it is 2013–2022 and was never reconstructed, or a reconstruction exists and did not prove its binding. The three are deliberately not distinguished: "we hold something we will not stand behind" is not a state this product reports. |
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 | object | One result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will. |
tape | array of object | The score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game. |
tiebreaks | array or null | Per-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere. |
profiles | array of object | Always empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match. |
meta | object |
Example
curl https://api.livetennisapi.com/api/public/v1/history/archive/matches/{archiveId}/tape \
-H "Authorization: Bearer twjp_..."
GET /history/archive/players
Archive player bios — hand, DOB, country, height, career-high (BASIC)
People of the results archive (1968–2022), in their own id space — id is the corpus person id that archive match rows carry as winner.player_id / loser.player_id, scoped per tour; never a roster id. Career-high rank and the earliest week it was reached are computed offline from the corpus's own weekly ranking tables. Null fields are the era's silence, never guessed. Requires BASIC, or any Historical Data API plan.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | query | integer | no | Look a person up BY the corpus person id — the value archive match rows publish as winner.player_id / loser.player_id. This is the only place that reads the PERSON by that id; the one other surface taking it, /rankings?archive_player=, is ULTRA, requires system=elo, and answers with ratings rather than a bio. Corpus ids and roster ids are disjoint id spaces, so the same value on /players/{playerId} returns 404 (that 404 now points here). Returns every person wearing the id — the tours number independently and some ids are live in both — so add tour to narrow it to exactly one, the same rule archive_player= applies. |
player_id | query | integer | no | Alias of id, under the name the same value carries on an archive match row. Sending both with different values is a 400. |
name | query | string | no | Case-insensitive substring filter (min 3 chars). |
tour | query | string (atp, wta) | no | |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Archive people, ordered by name |
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/history/archive/players \
-H "Authorization: Bearer twjp_..."
GET /history/archive/career
Career aggregates over the results archive, 1968–2022 (BASIC)
One player's whole archive career in one response: W-L record (overall, by surface, by level, by year), titles, and the summed serve-stat block with derived ratios. Everything is a sum or a ratio of sums over rows you can fetch individually from /history/archive/matches — nothing is modelled. serve.matches_with_stats states the coverage honestly: the corpus records per-match serve statistics from 1991 only, so a 1970s career has a full W-L record and an empty serve block. Ambiguous name fragments are refused with candidates (same rule as /h2h); an unknown name is a 404. Requires BASIC, or any Historical Data API plan.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
name | query | string | yes | Player name (fragment, min 3 chars — must resolve to one person). |
Responses
| Status | Meaning |
|---|---|
200 | The career aggregate body |
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 |
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 |
|---|---|---|
player | object | |
span | object | |
record | object | |
by_year | array of object | |
serve | object | Summed serve statistics + derived ratios; null ratios where the denominator is zero. |
Example
curl https://api.livetennisapi.com/api/public/v1/history/archive/career \
-H "Authorization: Bearer twjp_..."
GET /h2h
Head-to-head across the results archive (1968–2022) and our own completed matches (2023→now) (BASIC)
The record between two players, assembled from BOTH halves of the product: the results archive, where the winner is a stored column, and our own completed matches, where the winner is derived from the final recorded state. Names are the keys — archive people have no roster ids. A fragment matching more than one player is refused with the candidate list (400 ambiguous_name), because two people summed into one record is a wrong answer, not a convenience. Totals count meetings with a KNOWN winner; undecided counts the rest. Walkovers and retirements are part of the record, and each meeting carries outcome so you can exclude them. Requires BASIC, or any Historical Data API plan. On ULTRA, a per-player stats block adds serve/return/break-point aggregates over the pairing: archive_serve (serve-side, from 1991) and current (2023+, adding return and break-point conversion, aces and winners), each with meetings_with_stats.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
p1 | query | string | yes | First player name (fragment, min 3 chars). |
p2 | query | string | yes | Second player name (fragment, min 3 chars). |
Responses
| Status | Meaning |
|---|---|
200 | The head-to-head record; empty totals when no player matches the names |
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 |
|---|---|---|
players | object or null | The resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no player matches the fragments. |
totals | object | |
by_surface | object | Per-surface win split of the decided meetings; keys are surface names plus 'unknown'. |
meetings | array of object | Newest first, capped at 200. era says which half served the row — 'archive' rows carry archive_match_id/level/score; 'current' rows carry match_id/round_code and read their score from the match endpoints. winner is 1|2 OF THIS H2H (p1/p2 as requested), null when underivable. |
stats | object or null | ULTRA only — per-player serve/return/break-point aggregates over the pairing, keyed p1/p2. Each side carries archive_serve (serve-side figures, meetings from 1991) and current (2023+, adding return and break-point conversion, aces and winners), each with its own meetings_with_stats sample size. Absent below ULTRA. |
Example
curl https://api.livetennisapi.com/api/public/v1/h2h \
-H "Authorization: Bearer twjp_..."
GET /history/packages
List the pre-built monthly bulk history packages (PRO)
Bulk downloads are a heavier product than single-match tape reads. Requires PRO on the live API, or the Historical Data API Pro plan and up, or a one-off package access pass. A key that can read the tape but is not package-entitled receives 403 upgrade_required.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
kind | query | string (tape, rankings, rally, archive, elo, archive_tape) | no | Package family. tape (default) = monthly point-by-point match tapes; rankings = as-of ranking records (ULTRA); rally = the charted rally corpus (shot-by-shot) as YEARLY exports (ULTRA); archive = the results archive (1968–2022) as YEARLY exports, same entitlement as the tape packages; elo = the as-of Elo tape as YEARLY exports (ULTRA); archive_tape = the RECONSTRUCTED 2013–2022 point-by-point tape as YEARLY exports — ten periods, 2013 through 2022, each with a JSONL and a CSV file, all ready, needing ULTRA or a History Pro/Business subscription or an active one-off package window (a History Starter grant reads a tape per call and does not download years of them). The yearly kinds' period is YYYY, one file per year, because a fixed historical corpus is not an accruing monthly stream. The default means a tape-only client never sees a new kind of row appear. Default tape. |
year | query | string | no | Year archive listing — every published month of the year (History Business, a 1-year package, or ULTRA). |
Responses
| Status | Meaning |
|---|---|
200 | Ready packages, newest period first |
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/history/packages \
-H "Authorization: Bearer twjp_..."
GET /history/packages/{period}
One monthly package — manifest, or the bulk file itself (PRO)
Without format returns the package manifest (file set, counts, sha256). With format=jsonl or format=csv streams that file as an attachment. Same entitlement as /history/packages. 404 when the month has not been built yet — list available months first.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
period | path | string | yes | Calendar month, YYYY-MM — except for the yearly kinds (rally, archive, elo, archive_tape), where it is the bare year YYYY (400 bad_period otherwise). |
kind | query | string (tape, rankings, rally, archive, elo, archive_tape) | no | Package family; rankings, rally, elo and archive_tape require ULTRA, a History Pro/Business subscription, or an active one-off package window (changed 2026-08-19). rally = the yearly charted rally corpus exports; archive = the yearly results archive (1968–2022) exports, same entitlement as the tape packages; elo = the yearly as-of Elo tape exports; archive_tape = the yearly reconstructed 2013–2022 point-by-point tape exports (period 2013 through 2022 — no other year exists). Default tape. |
format | query | string (jsonl, csv, corrections) | no | Omit for the JSON manifest; set to download the file. corrections (added 2026-09-10) streams the package's correction CSV — match_id, field, before, after, tournament_key, source, corrected_at — when stored data was repaired after the package was published; the manifest's files lists it with format: corrections, and a package without one answers 404 no corrections file for that package. First use: the 2023-02 → 2024-12 tape packages carry surface corrections for 19,439 Challenger matches (repaired 2026-09-10; the packages themselves were rebuilt the same day). |
Responses
| Status | Meaning |
|---|---|
200 | The manifest (no format), or the bulk file as an attachment (format=jsonl streams NDJSON, format=csv streams CSV, format=corrections streams the correction CSV). A gzipped file (see the manifest's compression) is served as application/gzip, never with Content-Encoding: gzip — the manifest's sha256 covers the exact bytes you receive. |
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 |
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 |
|---|---|---|
period | string | Calendar month, YYYY-MM — or the bare year YYYY on the yearly rally/archive/elo/archive_tape kinds. |
status | string (ready) | Only built months are listed or served. |
match_count | integer or null | |
row_count | integer or null | |
files | array of object | One entry per downloadable format. |
built_at | string or null | |
schema_version | integer or null (1, 2, null) | Which shape this month's files carry (added 2026-09-22), so you can tell without opening them. 2 = a package row is the API tape row (origin, serve, outcome, winner, and the full meta), and the CSV carries the trailing columns. 1 = the shape every month built before 22 September 2026 carries: tape rows hold the score columns only (no origin, serve, outcome or winner; no meta.enrichment, reconstructed_at or observed_span), and the CSV ends at danger. Months built before 22 September 2026 keep this shape UNTIL THEY ARE REBUILT; the rebuild runs newest month first, then backwards, and a month's schema_version flips to 2 (and its built_at and sha256 values move) when its turn comes. Check the manifest rather than assuming a month is one or the other: a consumer that needs the enriched fields should require schema_version >= 2 and, for a month still at 1, read the per-match endpoint for the matches it needs. null = not versioned: the non-tape kinds (rankings, elo, …). |
kind | string (tape, rankings, rally, archive, elo, archive_tape) | Present only on non-tape packages, so the shape a tape client already parses is unchanged. On a rankings package match_count is the number of players covered and row_count the number of ranking records; on a rally package the counts are charted matches and points; on an archive package the counts are archive results; on an elo package row_count is the number of rating records; on an archive_tape package match_count is the reconstructed tapes in that year and row_count their score rows (97,901 and 14,340,663 across the ten years). |
Example
curl https://api.livetennisapi.com/api/public/v1/history/packages/{period} \
-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_..."
GET /usage
Your own usage vs quota (FREE — any tier)
Durable daily usage for the calling key: tier, limits, today's calls (current to the second) and a 30-day history. The per-minute window is on the X-RateLimit-* headers of every response, not here. Calls to this endpoint are quota-exempt — checking your usage never consumes it.
Responses
| Status | Meaning |
|---|---|
200 | Usage summary |
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 |
|---|---|---|
principal | string | Opaque ref to your own key |
tier | string (free, basic, pro, ultra) | |
base_tier | string | Subscription tier; equals tier unless a temporary grant is active |
tier_expires_at | string or null | When a temporary tier grant reverts, else null |
channel | string | |
limits | object | |
today | object | |
history | array of object | Last 30 days, oldest first |
as_of | string |
Example
curl https://api.livetennisapi.com/api/public/v1/usage \
-H "Authorization: Bearer twjp_..."
GET /rankings
Rankings and Elo — rank-ordered listing (PRO) or per-player as-of records (ULTRA); the as-of Elo tape is ULTRA in both modes
Returns, per ranking system, the newest record effective ON OR BEFORE as_of — never one dated after it. Every other ranking field in this API is the player's CURRENT value joined at read time, so replaying an old match elsewhere shows today's ranks; this endpoint is the point-in-time answer. Systems are never collapsed into a single "rank": ATP/WTA and the ITF circuits carry rank+points, UTR carries a rating with null rank and points because it has neither. meta.coverage.oldest_available gives the earliest date each system can answer for — ITF and UTR observations reach back to 2026-06-01 (append-only per-player history accumulates from 2026-07-29) and nothing earlier can be reconstructed.
TWO MODES — with player ids (ULTRA) — the per-player point-in-time records described above. WITHOUT player (PRO) — the FULL published table in rank order for exactly one system, the newest week at or before as_of; rows carry player_name as published and a null player_id for players outside our roster, so the table has no silent holes. utr has no listing (a rating, not a ranking).
SYSTEM=UTR — observed ratings, honestly bounded. UTR records are observed from UTR's public search: a rating UTR withholds appears as ABSENT, never as 0, and rating is the only populated value — rank and points are always null. Per-player as-of ONLY — there is deliberately no UTR listing, because a table of only the players we happen to track would be a fake leaderboard. Per-player history accumulates from 2026-07-29; scattered earlier single-snapshot observations reach back to 2026-06-01. Coverage is a deliberate bias, not a roster mirror: the 24-hour sweep targets players with no official rank and no Elo rating (so it skews ITF), and among players active in the last 60 days it holds 931 of 5,606 ITF players (16.6%), 197 of 1,903 Challenger (10.4%), 43 of 573 WTA (7.5%) and 15 of 525 ATP (2.9%) — measured 2026-08-17.
SYSTEM=ELO — THE AS-OF ELO TAPE (ULTRA in BOTH modes). Our own computed Elo for 65,622 players on four independent ladders (overall, hard, clay, grass), back to 1877 (ATP) and 1968 (WTA), covering the main tours plus challengers plus the futures tier. It answers what a player was rated BEFORE a given match, which is the only shape a backtest can consume. It is a HISTORICAL TAPE rather than a live leaderboard — the corpus behind it is fixed and no longer receives new results, so meta.coverage.newest_available states the tape's head date on every response (2026-06-15 at publication), and you should read it before treating the table as current. A week's results become effective 14 days after that week begins — strictly after the longest event in tennis — so the failure direction is staleness, never look-ahead. rating is the Elo. rank is LISTING MODE ONLY and is null in per-player mode, because an Elo has no global rank at a past instant until you say which field and which activity window you mean. points is always null. matches is the count on THAT ladder, published so that a rating still near its 1500 cold start is visible rather than inferred. A ladder a player has never played is omitted, never substituted. Ratings are on our own scale, are not comparable with Elo published elsewhere, and do not decay — a surface played only a few weeks a year (grass in particular) moves slowly, so a rising player can sit below an established one for several seasons while beating them. A CURRENT per-player Elo remains free on GET /players/{id}; this is the point-in-time series, the leaderboard and the bulk export. That free rating is sourced differently and sits on a DIFFERENT scale — the two differ by roughly 150 Elo of per-player standard deviation — so never present a rating from one scale against a rating from the other. The Elo listing REQUIRES tour — the ATP and WTA walks are disjoint, so a combined leaderboard would not be comparable — and takes exactly one surface (default overall). elo is never implicit — omitting system returns the official systems only.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
player | query | array of integer | no | Player id — repeatable, max 50 (ULTRA per-player mode). OMIT it for the rank-ordered listing mode (PRO), which then requires exactly one system. |
archive_player | query | array of integer | no | Historical-corpus person id — repeatable, max 50, system=elo only, and REQUIRES tour because that id is only unique within one tour. It is the same id GET /history/archive/players returns as its id. Use it to reach the ~62,000 rated people who have no row in our player roster; player reaches the rest. |
tour | query | string (atp, wta) | no | REQUIRED for an elo listing and for archive_player. The ATP and WTA Elo walks are disjoint universes whose players never meet, so a combined leaderboard would not be comparable. On an OFFICIAL system it is accepted when it agrees with that system's own tour (system=atp&tour=atp) and REFUSED with 400 when it contradicts it. CORRECTION 2026-09-20: until that date system=atp&tour=wta returned 200 and the ATP table — the parameter was accepted and ignored. If you sent a contradicting pair, the rows you received were the table named by system. |
surface | query | array of string (overall, hard, clay, grass) | no | system=elo only. Listing mode takes exactly one (default overall); per-player mode is repeatable and defaults to all four ladders. A ladder the player has never played is omitted, never substituted. REFUSED with 400 when no requested system is elo — there is no clay ATP ranking, so the filter cannot be made to act. CORRECTION 2026-09-20: until that date system=atp&surface=clay returned 200 and the OVERALL ATP table. A mixed system=atp&system=elo&surface=clay still passes, because an elo system is present. |
min_matches | query | integer | no | system=elo listing only. Minimum matches on that ladder. Every ladder starts at 1500, so without a floor the top of the table is players who won a handful of matches from the starting rating. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 20. |
activity_weeks | query | integer | no | system=elo listing only. The ladder must have moved within this many weeks of as_of. Elo does not decay, so without an activity window every leaderboard is topped permanently by players who have stopped playing. Echoed back in meta.coverage.qualified. REFUSED with 400 when no requested system is elo (2026-09-20); it was previously accepted and ignored. Default 52. |
as_of | query | string | no | YYYY-MM-DD. Omit for the latest known record. |
system | query | array of string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo, atp_doubles, wta_doubles) | no | Restrict to one or more systems. Omit for all of the official systems — elo, atp_doubles and wta_doubles are NEVER included implicitly and must be named (a doubles tie prints as repeated ranks, as the publisher prints it), so an existing request's response is unchanged. Naming a system your plan does not cover refuses the whole call with 403 rather than silently returning the part you are entitled to. |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Ranking records in force at as_of |
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/rankings \
-H "Authorization: Bearer twjp_..."
GET /rally/matches
Charted matches with shot-by-shot data (ULTRA)
Charted matches, newest first. RALLY CONSTRUCTION IS THE LAYER BELOW THE TAPE: the tape says what the score became after each point, this says how the point was played. It has its OWN id space. The charted corpus and our own match table are different populations — the corpus reaches back decades and concentrates on the biggest events, while our matches begin when our own collection did. Keying this on our match ids would hide most of it. Ask this endpoint for the authoritative coverage list rather than assuming a match is charted: charting is human work, so coverage is deep, not universal.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
player | query | string | no | Substring match on either player name. |
from | query | string | no | YYYY-MM-DD. |
to | query | string | no | YYYY-MM-DD. |
surface | query | string | no | |
gender | query | string (M, W) | no | |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | Charted matches, with per-match parse-quality counts |
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/rally/matches \
-H "Authorization: Bearer twjp_..."
GET /rally/matches/{rallyMatchId}
Rally construction for one charted match (ULTRA)
One charted match with its points, in play order. Paged with limit/offset; meta.total is the match's full point count.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
rallyMatchId | path | integer | yes | |
limit | query | integer | no | Default 50. |
offset | query | integer | no | Default 0. |
Responses
| Status | Meaning |
|---|---|
200 | The charted match with its rally points |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No such resource, or no data yet |
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 |
|---|---|---|
rally_match_id | integer | The id this product is keyed on. |
source_id | string | |
match_id | integer or null | OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection. |
date | string or null | |
tournament | string or null | |
round | string or null | |
surface | string or null | |
gender | string or null (M, W, null) | |
best_of | integer or null | |
players | array of object | |
points | integer | Charted points in this match. |
points_parsed | integer | How many of them our parser read cleanly — the per-match quality number. |
meta | object | |
rally | array of object |
Example
curl https://api.livetennisapi.com/api/public/v1/rally/matches/{rallyMatchId} \
-H "Authorization: Bearer twjp_..."
GET /history/matches/{matchId}/rally
Rally construction by OUR match id (ULTRA)
Rally construction addressed by OUR match id, resolved through the optional link. Answers 404 {"error":"not_charted"} when we hold the match but nobody charted it — deliberately distinct from "no such match", because most of our matches are not charted and a consumer walking the archive must tell them apart.
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 | The charted match with its rally points |
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 |
|---|---|---|
rally_match_id | integer | The id this product is keyed on. |
source_id | string | |
match_id | integer or null | OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection. |
date | string or null | |
tournament | string or null | |
round | string or null | |
surface | string or null | |
gender | string or null (M, W, null) | |
best_of | integer or null | |
players | array of object | |
points | integer | Charted points in this match. |
points_parsed | integer | How many of them our parser read cleanly — the per-match quality number. |
meta | object | |
rally | array of object |
Example
curl https://api.livetennisapi.com/api/public/v1/history/matches/18953/rally \
-H "Authorization: Bearer twjp_..."
GET /charting/players
Career shot-level charting aggregate for one player (ULTRA)
The deepest serve/return profile we hold, from the Match Charting Project: serve placement (deuce/ad × wide/body/T), return depth and outcomes, net and serve-and-volley conversion, clutch break/game/set-point serving and returning, winners and unforced errors by wing, and rally-length and shot-direction tendencies — summed over the player's charted matches. name (min 3 chars) is the key; a fragment matching more than one charted person is refused with the candidate list, and gender=men|women disambiguates. Every field is a raw SUM over the player's Total rows and matches_charted states the sample. COVERAGE IS CURATED — 11,803 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
name | query | string | yes | Player name (fragment, min 3 chars). |
gender | query | string (men, women) | no | Disambiguates a fragment that matches one charted person per tour side. |
Responses
| Status | Meaning |
|---|---|
200 | The player's summed charting families with the sample size |
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 |
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 |
|---|---|---|
player | object | |
matches_charted | integer | |
coverage | string | |
families | object | Per-family summed numeric columns. |
Example
curl https://api.livetennisapi.com/api/public/v1/charting/players \
-H "Authorization: Bearer twjp_..."
GET /charting/matches/{chartingMatchId}
One charted match, every stat family for both players (ULTRA)
Every Match Charting Project stat family for one charted match, both players, with the per-set split (row/set 1, 2, Total) exactly as charted. chartingMatchId is this product's own id space (1960–2026, mostly matches with no counterpart in the live table).
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
chartingMatchId | path | integer | yes |
Responses
| Status | Meaning |
|---|---|
200 | The charted match's stat families, both players, per set |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No such resource, or no data yet |
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 |
|---|---|---|
charting_match_id | integer | |
mcp_id | string | |
gender | string | |
players | object | |
families | object |
Example
curl https://api.livetennisapi.com/api/public/v1/charting/matches/{chartingMatchId} \
-H "Authorization: Bearer twjp_..."
POST /webhooks
Register an outbound webhook (ULTRA, direct keys only)
We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (409 webhook_limit past that). The response is the ONLY time the signing secret is shown — store it.
Each delivery carries X-LTAPI-Signature (sha256=<hex> — HMAC-SHA256 of the RAW request body with your webhook secret; verify with a constant-time compare), X-LTAPI-Timestamp (Unix seconds at send time — reject stale replays at your edge) and X-LTAPI-Event (the frame type: score, break_point, break_point_result or point).
Delivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every score frame is the complete current score, so a missed delivery self-corrects on the next commit. A point frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with GET /matches/{matchId}/points?after_seq= and dedup by seq. After 25 consecutive failures the webhook is disabled automatically (enabled:false, last_error set — visible in GET /webhooks); delete and re-register to resume.
Responses
| Status | Meaning |
|---|---|
201 | Created — includes secret (shown exactly once) |
400 | Bad query parameter |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
409 | Webhook limit reached (3 per key) — delete an existing webhook first |
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. |
Example
curl https://api.livetennisapi.com/api/public/v1/webhooks \
-H "Authorization: Bearer twjp_..."
GET /webhooks
List your webhooks (ULTRA, direct keys only; never includes the secret)
Responses
| Status | Meaning |
|---|---|
200 | Your webhooks |
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/webhooks \
-H "Authorization: Bearer twjp_..."
DELETE /webhooks/{webhookId}
Remove one of your webhooks (ULTRA, direct keys only)
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
webhookId | path | integer | yes |
Responses
| Status | Meaning |
|---|---|
200 | Deleted |
401 | Missing, unknown, or disabled credentials |
403 | Your tier doesn't unlock this endpoint |
404 | No such resource, or no data yet |
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 |
|---|---|---|
deleted | integer |
Example
curl https://api.livetennisapi.com/api/public/v1/webhooks/{webhookId} \
-H "Authorization: Bearer twjp_..."
GET /ws-token
Mint a connection token for the high-fan-out push feed (ULTRA)
Returns a short-lived signed token plus the push WebSocket URL and the channel vocabulary: match:{match_id} per-match streams and slate:all for every live score frame. Frames are the same allowlist score objects the polling endpoints return. This is a separate surface from the native /ws feed described above — same ULTRA gate, built for high fan-out (no shared connection ceiling), and the recommended home for continuous/production streaming.
The endpoint speaks the **Centrifugo client protocol** (v2, JSON). Easiest path: the official Python (livetennisapi ≥ 1.4.0) and JS (≥ 1.5.0) SDKs ship a built-in PushStream client — no extra dependency. Raw protocol, if you prefer your own client: (1) open a WebSocket to ws_url; (2) send {"connect": {"token": "<token>"}, "id": 1} — the token goes INSIDE this JSON frame, never as a raw first message; (3) subscribe per channel with {"subscribe": {"channel": "slate:all"}, "id": 2}; (4) publications arrive as {"push": {"channel": ..., "pub": {"data": <frame>}}}; (5) the server's heartbeat is an empty JSON object {} — reply with {} promptly or you will be disconnected. Messages may batch several newline-delimited JSON objects. Tokens are short-lived and the connection closes around token expiry: mint a fresh token on EVERY reconnect and re-subscribe.
The channels object lists only channels that will actually deliver for your key right now (a channel name in this response is a promise). Where enabled server-side, additional channel families appear: point:match:{match_id} / point:slate (per-point events), listed — as point_match / point_slate in the vocabulary — only for keys whose plan carries the point surface, and signal:match:{match_id} / signal:slate (derived break_point, break_point_result and divergence events, and since 2026-09-12 the stoppage family: medical_timeout_start/end, trainer_called/_end, toilet_break_start/end, stoppage_start/end, pause_start/end). A family absent from the response will not deliver for your key right now. Deliberately separate channels: a slate:all subscriber asked for score states and never starts receiving events unasked. Point and signal frames are events, not states — a missed point does NOT self-correct on the next frame; recover it via GET /matches/{matchId}/points?after_seq= and dedup by seq.
Responses
| Status | Meaning |
|---|---|
200 | Connection token, push URL and channel vocabulary |
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 |
|---|---|---|
token | string | |
expires_in | integer | |
ws_url | string | The push WebSocket URL to connect to with the token. |
channels | object | Channel vocabulary — match is the per-match pattern (match:{id}), slate is the every-live-score channel (slate:all). A channel listed here will actually deliver for your key; one missing will not. |
Example
curl https://api.livetennisapi.com/api/public/v1/ws-token \
-H "Authorization: Bearer twjp_..."
WebSocket feed (ULTRA)
A native WebSocket live feed is available at https://api.livetennisapi.com/api/public/v1/ws. Subscribe with
{"topics":["live-scores"]} or
{"topics":["match:<id>"]}. The server acknowledges with a
subscribed frame, then pushes score frames on every change plus a
ping heartbeat roughly every 15 seconds.
Opt into extra signals by adding a signals array to the subscribe frame —
{"topics":["live-scores"],"signals":["break_point"]} — to also receive a
break_point frame the instant a break point arises and a
break_point_result frame when it resolves. Their shapes are the
BreakPoint and BreakPointResult schemas below. Without
signals the feed pushes score frames only, exactly as before.
signals may also name points — the live per-point event
stream: one point frame per persisted point of your subscribed matches
(shape PointFrame below), ordered per match by seq. The signal
is config-gated and ships off by default; the subscribed ack echoes the
signals actually active, so points missing from the ack means no point
frames will flow. Frames arrive only for matches with
pbp_coverage: "point" — a game-coverage match sends none,
honestly. Point frames are events, not states, and there is no WS replay: a missed
one does not self-correct on the next frame — on reconnect, or to join mid-match,
catch up via GET /matches/{matchId}/points?after_seq= and dedup by
seq. The push feed carries the same frames on their own channel family
(point:match:{matchId} and point:slate), deliberately separate
from the score channels.
FAQ — plans and data depth
How much data can I access on each plan?
FREE sees the current state of the game only — live and upcoming matches, scores, players and fixtures — at 100 requests/day. BASIC adds every completed match and its full point-by-point tape (with the model win-probability where computed), one match per request, at 1,000/day. PRO adds whole months of history in a single bulk file (JSONL or CSV), plus match events and market prices, at 10,000/day. ULTRA adds model analysis, the live model fields and the WebSocket push feed, at 500,000/day. Coverage is identical on every plan: all tours, ATP through ITF — the plans differ in which data products and volumes they unlock, never in which tournaments you see.
How far back does history go?
1968. History runs in two continuous, non-overlapping halves. The
point-by-point tape (2023→now): /history/matches pages every completed
match from January 2023 on, all tours, newest first — filter a window with
from/to — with the per-match point-by-point tape at
/history/matches/{matchId}. The results archive (1968–2022):
/history/archive/matches serves winner/loser-shaped RESULTS — ATP and WTA,
main draws, qualifying and the ITF/futures tiers, 1968 through 2022 — with
final score, seeds, ranks at the time, and per-match serve statistics where
the era recorded them (from 1991). The archive ends exactly where the tape
begins, so no match is ever served from two datasets. Archive results played
2013–2022 additionally carry a reconstructed point-by-point tape
(next question). Bulk: tape packages are
built per calendar month, archive packages per year
(?kind=archive), the reconstructed archive tape per year
(?kind=archive_tape, 2013–2022);
GET /history/packages lists exactly which periods
exist and is always the authoritative answer. Year-scale exports are part of
the Historical Data API Business plan.
Is there point-by-point data before 2023?
For 2013–2022, yes — reconstructed, not recorded.
GET /history/archive/matches/{archiveId}/tape serves the score sequence
behind a 2013–2022 archive result, rebuilt from the public record after the
fact: 97,901 matches and 14,340,663 rows. The floor is hard. The
archive holds a further 977,903 results from 1968–2012 and not one of
them has a tape — there is no public point-by-point record of those years
to rebuild, and we do not manufacture one.
Nobody watched those matches, and the data says so.
timestamp, win_probability_p1 and danger are
null on every row and cannot be filled in later — the production table has no
timestamp column at all, and the promotion script refuses to run if one ever
appears. This is structural, not a convention. Do not time
anything with this tape: it is true about the score sequence and silent about the
clock. That is the opposite of the 2023→now tape, which is our own recording —
there, the rows we actually watched carry a real clock and most of them a model
probability. meta states the rest per match:
coverage (reconstructed | reconstructed_partial),
granularity (point on 99.4% of the corpus; 556 matches resolve
only to one row per game, 555 of them in 2013), point_source and
rows. reconstructed_partial (3,594 matches) has two causes and
does not say which: 3,038 are matches that genuinely stopped early (3,027
retirements, 11 defaults) and the other 556 carry the label only because their tape
is per-game — read granularity and the match's own score, not the
label.
How much of the era it covers, thin spots included: 19.3% of all archive matches played 2013–2022 and 44.9% of tour-level play. Main-draw tour buckets are close to complete — ATP Masters 98.7%, ATP slam 98.0%, WTA slam 97.4%, WTA Premier Mandatory 97.9%, ATP 250–500 95.4%, WTA Premier 94.1%. Below that it thins fast: ATP Challenger main draws 55.3%, Challenger qualifying 33.6%, slam qualifying only 16.0% (ATP) and 18.1% (WTA), and ITF and futures effectively nothing — 25 of 116,575 ATP futures matches, 68 of 19,162 ITF M15, 48 of 9,380 ITF M25. If your work is ITF, this is the wrong dataset and we would rather you knew now. 31% of the corpus is qualifying-draw play, which is the part you are least likely to hold already.
Each tape is proved to belong to its match before it is published. A five-clause identity proof — edition, names, round, an exact match of the derived set spine against the published final score, and the date window — then a step-by-step walk against 23 interior invariants. A tape that cannot prove its binding is refused rather than published against a guess, which is why a missing tape and an unproven one both answer 404.
Who can read it: core ULTRA, or any active History plan
including Starter (which opens it on a FREE core key). The per-year bulk files
(/history/packages?kind=archive_tape, 2013–2022, JSONL and CSV, all
ready) are a separate gate: core ULTRA, a History Pro/Business subscription, or an
active one-off package window. Core PRO carries neither — it reads the
archive result and is refused the tape.
What's in the point-by-point tape?
One row per recorded point state, chronological: sets, per-set games,
in-game points, the server, the tiebreak flag, and the model's
win_probability_p1 and danger on the rows where the model ran (null
elsewhere — check meta.model_rows); rows we watched live carry a real
timestamp, reconstructed rows a null one.
GET /history/matches/{matchId} returns it per match (shape
HistoryTape: match metadata + tape + the model profiles produced during the
match). Add ?points=complete to opt into a whole-match reconstruction
where one exists — the response's meta.points block reports the measured
point-completeness of exactly the sequence you were served, per match, never as
a blanket claim. Filter the listing by that measured verdict with
?points_complete=true on /history/matches. The monthly bulk
packages' base files carry each match's default read — the same tape the API
serves — and a month may also list the complete-basis addendum files
(tennis_history_points_complete_<period>.jsonl.gz/.csv.gz): the same
tape ?points=complete serves, for exactly the matches whose complete point
sequence exists only as the on-disk reconstruction. Existing base files are
never rewritten by the addendum; their sha256 values do not move.
Measured completeness also differs sharply by draw on some circuits — as of
2026-08-18, 51.1% of ITF singles matches are point-complete on the best basis
against 3.5% of ITF doubles — which is exactly the split the ?draw= filter
and GET /history/coverage (the per-bucket rollup, rebuilt nightly, dated by
its own as_of) exist to expose. Do not extrapolate a completeness rate
across a ?tour= group.
Schemas
PlayerNotFound
The 404 body for /players/{playerId} and its two stoppage aliases. detail and see appear ONLY when the requested id resolves in the results-archive person registry — the signpost across the two id spaces. They are absent for an id we simply do not hold. The person is never named here: the archive is History-gated and this route is not.
| Field | Type | Description |
|---|---|---|
error | string (not_found) | |
detail | string | Present only when the id is a corpus person id. |
see | string | Present only when the id is a corpus person id — the archive lookup that reads it, e.g. /api/public/v1/history/archive/players?id=104925. |
MatchMerged
The 410 GONE body for a match id that was merged into another match record. Distinct from Error because it carries a forwarding address.
| Field | Type | Description |
|---|---|---|
error | string | Always merged. |
merged_into | integer or null | The id that absorbed this one — re-point your records at it. Null means the forwarding target is unknown: nothing to forward you to, drop the id. On a merge chain this is the END of the chain, never an intermediate id that would 410 in turn. |
merged_at | string | When THIS id stopped existing (UTC). On a chain it is the FIRST hop — the moment your copy of this id went stale. |
detail | string | Human-readable explanation; differs for a merge and for a retirement so the two are distinguishable without parsing prose. |
PlayerMerged
The 410 GONE body for a player id that was merged into another player record, or retired outright. Same shape as MatchMerged and the same reasoning: a redirect would rewrite the identity inside your warehouse without you seeing it, so the id is answered rather than forwarded.
| Field | Type | Description |
|---|---|---|
error | string | Always merged. |
merged_into | integer or null | The id that absorbed this one — re-point your records at it. Null means the player was RETIRED with no successor: nothing to forward you to, drop the id. On a merge chain this is the END of the chain, never an intermediate id that would 410 in turn. |
merged_at | string | When THIS id stopped existing (UTC). On a chain it is the FIRST hop — the moment your copy of this id went stale. |
detail | string | Human-readable explanation; differs for a merge and for a retirement so the two are distinguishable without parsing prose. |
Error
| Field | Type | Description |
|---|---|---|
error | string | Stable machine-readable code, e.g. bad_date, bad_coverage, bad_sequence, bad_points, bad_points_complete, bad_combination, points_read_disabled, bad_after_seq, bad_changed_since, bad_restated_since, points_disabled, bad_period, bad_year, bad_format, bad_tour, bad_tier, bad_country, ambiguous_name, not_charted, not_found, no_analysis, no_market, upgrade_required, rate_limited, abuse_throttled. |
detail | string | Human-readable explanation, when one adds anything. |
allowed | array of string | On a rejected enumerated parameter, the values that would have been accepted (e.g. the coverage vocabulary for bad_coverage). |
ListMeta
| Field | Type | Description |
|---|---|---|
limit | integer | |
offset | integer | |
count | integer | |
total | integer or null | Size of the whole filtered set. Null when it cannot be counted cheaply. |
has_more | boolean | More results exist beyond this page. Read this rather than comparing count to limit. For a complete enumeration of a filtered set, page offset forward by limit until this is false; on completed/cancelled listings total is null and this is the only end-of-data signal. |
next_cursor | string or null | Change-feed only (?updated_since= / ?cursor=). Opaque token to pass back as ?cursor=. Null means you are caught up — stop paging and switch to polling from watermark. |
watermark | string or null | Change-feed only. The floor to use on your NEXT poll. Deliberately trails the server clock by 5s so a row committed during this request cannot fall through the gap. |
Score
One state at one instant. ULTRA adds win_probability_p1 + danger. Order and de-duplicate on sequence, never on timestamp.
| 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; absent on history-tape rows; 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; absent on history-tape rows, which are already served in order. |
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. |
detail | string | Present only when something is withheld — plain-language reason, e.g. a null games array. |
PlayerStoppage
One row of GET /players/{playerId}/stoppages — an in-match stoppage on one of the player's matches, or a match the player did not finish. Stoppage rows carry score_at, duration_seconds and reason; outcome rows carry final_score when a score row exists. Never inferred except pause (source_stated false).
| Field | Type | Description |
|---|---|---|
kind | string (medical_timeout, trainer_called, toilet_break, pause, stoppage, retirement, walkover) | |
at | string | UTC instant of the stoppage (the source's own instant) or of the recorded outcome. |
match_id | integer | |
tournament | string or null | |
opponent | object or null | The other side of the match; null when unknown. |
score_at | object | Stoppage rows only — the score at the stoppage. |
final_score | object | Outcome rows only, when a score row exists — sets and games of the newest score row. |
attribution | string (player, match) | player — the source named this player's side (or the result names them as the non-winner); match — the source stated the stoppage for the match without a side, so the row appears in both players' lists. |
duration_seconds | integer or null | Seconds to the matching end row; null when no end row was recorded and on outcome rows. |
source_stated | boolean | True when a scorer, umpire, live-scoring state or result stated it; false only on pause (clock-inferred). |
reason | string | Stoppage rows only — the stated reason; unknown when only a flag reached us. |
PlayerStoppagesMeta
| Field | Type | Description |
|---|---|---|
player_id | integer | |
window | object | The bounds actually applied, UTC. |
kinds | array of string | The kinds this response was filtered to. |
counts | object | Per selected kind, the number of rows over the WHOLE window (not the page). |
latest_medical_timeout | object or null | The newest medical timeout in the window, whatever the page or kind filter; null when none. |
previous_medical_timeout | object or null | The one before it; null when fewer than two. |
record_starts | object | How far back each family is recorded — read before trusting a long window. |
note | string | In-match stoppages and match outcomes only; no off-court injury record. |
limit | integer | |
count | integer | |
has_more | boolean | |
next_cursor | string or null | Pass back as ?before=; null when the window is exhausted. |
Player
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
tour | string or null | The record's OWN tour, which is NOT the tour filter vocabulary. It is granular (juniors_boys, juniors_girls, challenger_men) where the filter is grouped (juniors, challenger), and a doubles team reports it UPPERCASE (ATP) where an individual reports lowercase (atp). Treat it as an opaque string; do not parse it into the filter enum. |
country | string or null | |
ranking | integer or null | Official singles ranking POSITION (the ordinal: 1, 25, 100) — never points, seed, race, UTR, Elo or a doubles rank. Men from the official ATP table, women from the WTA table, chosen by the player (a Challenger or ITF man who holds an ATP ranking carries it here). Refreshed from the standings ahead of each match the player has with us; a player who has not played recently keeps their last refreshed position. null when no ranking is held (doubles teams, most ITF/junior-only players). Always the CURRENT record, even on historical matches — use /rankings?as_of= for the rank in force on a date. |
ranking_points | integer or null | Points on the same official table, refreshed with ranking. |
ranking_movement | string or null (up, down, same, null) | |
hand | string or null (R, L, null) | |
backhand | integer or null (1, 2, null) | |
birthday | string or null | |
is_doubles_team | boolean | |
data_completeness | object | How much biographical detail is known for this player, so a consumer can distinguish "not in the feed" from "not yet fetched" without probing. Present on every player in a match payload. Lower tours carry far less of it than main tour. |
stats | object | Single-player endpoint only |
Match
| 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). |
MatchDetail
| 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) |
Analysis
| Field | Type | Description |
|---|---|---|
thesis | object or null | |
profile | object or null |
Market
| Field | Type | Description |
|---|---|---|
id | integer | |
question | string or null | |
status | string or null (active, resolved, closed, null) | |
volume | number or null | |
liquidity | number or null | |
end_date | string or null | |
prices | array of object | Prices endpoint / match detail only; newest first |
Price
Prediction-market top-of-book quote (probability-like, [0,1]); reflects market trading, not an official line, and can lag live scores. Tagged so a synthesised quote is never mistaken for a live book.
| Field | Type | Description |
|---|---|---|
side | integer or null | 1 = players.p1's outcome, 2 = players.p2's - decided by our name-verified match-market mapping, never by the venue's own display order (the venue lists roughly half of all pairings the other way round). Fixed 2026-09-09: ticks served before that date followed the venue's display order, so on those markets side was reversed; the mapping is stored, so re-reading any tick now returns the correct side. |
bid | number or null | |
ask | number or null | |
mid | number or null | The venue's observed midpoint at the instant we polled or streamed it - never a model estimate. Only mid is always real; see synthetic. |
spread | number or null | |
price_source | string or null | Feed category, e.g. prediction_market |
synthetic | boolean or null | true = bid/ask are estimated from mid (mid +/- 0.005, not a live order book) - mid itself is the venue's observed midpoint, never a model estimate; false = real top-of-book from the live order-book stream; null = unknown (older ticks). The live stream attaches when a match goes live, so pre-match ticks are synthetic: true by design. |
timestamp | string or null | When we observed this quote (UTC) - our observation clock, not a venue publication time. |
PointPrice
One played point with the match-winner quote in force per side when it was captured (added 2026-09-15). The score state is the BASIC tape's; the ULTRA-only per-point fields (server, winner, serve, outcome) are not here. Prices are keyed by neutral side and carry no venue or token identifiers.
| Field | Type | Description |
|---|---|---|
seq | integer | Per-match monotonic position 1..N — the same seq as the live points row. |
at | string | Our capture time of the point (UTC). |
sets | array of integer | |
games | array of array of integer | Per-set games arrays, [p1, p2]. |
score | object | |
tiebreak | boolean | |
prices | object | Keyed by side — "1" = players.p1's outcome, "2" = players.p2's. |
lag_seconds | integer or null | Seconds between the quote and the point, the larger of the two sides (a negative side lag means the first quote after the point was used); null when no side aligned. |
resolution | string or null (tick, minute, coarse, null) | tick |lag| <= 15 s, minute <= 90 s, coarse beyond — the coarser of the two sides; null when no side aligned. An honest label, never upgraded. |
SlateEvent
An Event as served by the slate-wide feed (GET /events) — the per-match row plus its id and match.
| Field | Type | Description |
|---|---|---|
type | string (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end) | The stoppage family (added 2026-09-12). medical_timeout_start/end, trainer_called/_end and toilet_break_start/end are stated by the match scorer or chair umpire, with player = the player concerned. Coverage (2026-09-13): WTA, WTA 125, Challenger and UTR singles — WTA and WTA 125 from the umpire console in two layers: the match state within ~20 s (trainer_called, toilet_break_*, stoppage_*, player null) and the console's event feed, published by the tour with a variable delay (measured 2026-09-13: about one minute to an hour behind play), which adds medical_timeout_start/end with the player and exact instants; Challenger and UTR from the scorer timeline (22 of 70 finished Challenger matches, median 8 s). ITF World Tennis Tour singles from the court's live-scoring state (the state, not the player: player is null on ITF rows; first medical timeout observed and published 2026-09-13). ATP main tour: a public live-score service's match stage is watched for its medical-timeout and interruption stages — present in its vocabulary, not yet observed on a tennis match, so ATP rows are mapped, not yet proven (player null, at = our observation instant). Not doubles. stoppage_start/end is a whole-match stop (reason weather | other when stated, unknown when only a bare suspension flag reached us). pause_start/end is an interruption of play measured from our own point clocks (basis: inferred), never labelled medical. |
player | integer or null (1, 2, null) | |
timestamp | string or null | |
at | string or null | Stoppage family — the UTC instant the stoppage happened (for a pause, its last score change). |
detected_at | string or null | Stoppage family — when we recorded it. |
basis | string or null (observed, inferred, null) | Stoppage family — observed (a source stated it) or inferred (measured from our clocks). |
reason | string or null | Stoppage family — medical_timeout | unspecified | no_treatment | taping | changeover_treatment | evaluating | failed | toilet_break | weather | other | resumed | unknown. |
position | string or null (point, changeover, setbreak, null) | pause rows only — where the last point left the match, which sets the silence threshold. |
score | object or null | Stoppage family — sets, games, points, server at the moment of the stoppage. |
duration_seconds | integer or null | end rows only. |
id | integer | The event's id; the after_id cursor. |
match_id | integer |
Event
| Field | Type | Description |
|---|---|---|
type | string (break, set_won, game_won, momentum_run, stoppage_start, stoppage_end, pause_start, pause_end, medical_timeout_start, medical_timeout_end, trainer_called, trainer_called_end, toilet_break_start, toilet_break_end) | The stoppage family (added 2026-09-12). medical_timeout_start/end, trainer_called/_end and toilet_break_start/end are stated by the match scorer or chair umpire, with player = the player concerned. Coverage (2026-09-13): WTA, WTA 125, Challenger and UTR singles — WTA and WTA 125 from the umpire console in two layers: the match state within ~20 s (trainer_called, toilet_break_*, stoppage_*, player null) and the console's event feed, published by the tour with a variable delay (measured 2026-09-13: about one minute to an hour behind play), which adds medical_timeout_start/end with the player and exact instants; Challenger and UTR from the scorer timeline (22 of 70 finished Challenger matches, median 8 s). ITF World Tennis Tour singles from the court's live-scoring state (the state, not the player: player is null on ITF rows; first medical timeout observed and published 2026-09-13). ATP main tour: a public live-score service's match stage is watched for its medical-timeout and interruption stages — present in its vocabulary, not yet observed on a tennis match, so ATP rows are mapped, not yet proven (player null, at = our observation instant). Not doubles. stoppage_start/end is a whole-match stop (reason weather | other when stated, unknown when only a bare suspension flag reached us). pause_start/end is an interruption of play measured from our own point clocks (basis: inferred), never labelled medical. |
player | integer or null (1, 2, null) | |
timestamp | string or null | |
at | string or null | Stoppage family — the UTC instant the stoppage happened (for a pause, its last score change). |
detected_at | string or null | Stoppage family — when we recorded it. |
basis | string or null (observed, inferred, null) | Stoppage family — observed (a source stated it) or inferred (measured from our clocks). |
reason | string or null | Stoppage family — medical_timeout | unspecified | no_treatment | taping | changeover_treatment | evaluating | failed | toilet_break | weather | other | resumed | unknown. |
position | string or null (point, changeover, setbreak, null) | pause rows only — where the last point left the match, which sets the silence threshold. |
score | object or null | Stoppage family — sets, games, points, server at the moment of the stoppage. |
duration_seconds | integer or null | end rows only. |
StatusChange
One row of the per-match status ledger (added 2026-09-12).
| Field | Type | Description |
|---|---|---|
at | string | The instant we published the change (UTC). |
status | object | |
event_status | object | |
outcome | string or null | Derived from the after-state by the Match.outcome definition. |
basis | string (observed, backfill, restatement) | How the row was made. observed = written at the instant of the change (from 2026-09-11T22:45:48Z); backfill = reconstructed on 2026-09-12 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. restatement (added 2026-09-22) = a CORRECTION to a result that had already been published — the match's status, event_status, winner or final score (sets, games) changed after it was first published as completed (or as a cancelled walkover with a winner). A correction is always a new row, never an edit, written at the instant we made the change; status / event_status carry before and after as usual (the same value twice when only the winner or the score moved) and its score is the result AFTER the correction (null when the match holds no score row, as on a walkover). Every path that can change a result writes one — a source's late final, a second authority's correction, an operator's repair, a completion reopened and re-closed — because it is enforced where the result is written. A re-assertion of the same result, or a change to points alone, is not a correction and writes nothing. result_restated_at and result_version on the Match summarise these rows. Rows with this basis exist from 2026-09-22; earlier corrections were not signalled. |
score | object or null | The newest score row at that instant (sets, games, points, server); null when none existed. |
Fixture
| Field | Type | Description |
|---|---|---|
id | integer | |
event_date | string or null | |
start_time | string or null | Scheduled start (UTC). Null until the order of play assigns a time — a date-only fixture is a real state, and rows written before 2026-08-03 stay null. |
player1_id | integer or null | Our player id, when the participant is in our roster (exact-key resolution, never a name match). Null otherwise — names are always present regardless. |
player2_id | integer or null | As player1_id, for the second participant. |
tour | string or null | The record's OWN tour, which is NOT the tour filter vocabulary. It is granular (juniors_boys, juniors_girls, challenger_men) where the filter is grouped (juniors, challenger), and a doubles team reports it UPPERCASE (ATP) where an individual reports lowercase (atp). Treat it as an opaque string; do not parse it into the filter enum. |
tournament | string or 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) | Normalized round — same vocabulary as Match.round_code. Null when the label is unrecognised, never guessed. |
surface | string or null | |
player1_name | string or null | |
player2_name | string or null | |
status | string or null |
ModelProfile
One model win-probability profile. On the history tape these are the profiles produced during the match, oldest first.
| Field | Type | Description |
|---|---|---|
win_probability_p1 | number or null | |
expected_closeness | number or null | |
volatility_rating | string or null (low, med, high, null) | |
stage | string or null (pregame, live, null) | Did this forecast already see the match? null = UNKNOWN, never pregame. |
model_version | string or null | The prompt-scaffold version this forecast was produced under. Null on profiles written before the stamp existed. |
key_factors | array or null | |
created_at | string or null |
HistoryTapeRow
One row of the score sequence. Rows we watched live carry a real timestamp. Rows expanded after the fact from a finished-match point-by-point record carry a null timestamp AND null model fields, because neither a wall clock nor a model output ever existed for them — nothing is synthesised. origin (since 2026-09-20) says explicitly which kind each row is, read from the row's stored provenance — never inferred from its clock; a null timestamp remains true of every reconstructed row. The model fields alone are not a marker, since they are stamped best-effort and an observed row may lack them. meta.point_source / meta.coverage say which kind of tape you are holding. origin, serve and outcome are present on GET /history/matches/{matchId} rows; the pre-2023 ArchiveTape (every row reconstructed) does not carry them. winner (since 2026-09-22) is present on EVERY row of every sequence, on both — who won the point that produced the row, judged from the score step and from nothing else; server, serve and winner together are the three per-point facts a model needs, and the third was only implicit in the score step before. Reconstructed rows are never repaired or reordered: a game whose vendor point record is internally inconsistent is withheld whole, not fitted or patched into a legal sequence — see meta.points.games_withheld (added 2026-09-20).
| Field | Type | Description |
|---|---|---|
origin | string (observed, reconstructed) | observed — a row our own pipeline committed while the point was being played; reconstructed — a row expanded after the fact from a finished-match point-by-point record (null timestamp, null model fields). From the stored provenance, never inferred from a null timestamp. Added 2026-09-20. |
serve | integer or null (1, 2, null) | The serve the point was played on (1 first, 2 second), joined from this match's per-point stream: paired by EXACT score state — set count, every set's games, in-game points and tiebreak flag all equal — and, where a state recurs (a deuce cycle), by nearest clock within 90 seconds (the tape row's observed clock against the stream point's), each stream point used at most once; otherwise null. Observed rows only: null on every reconstructed row, on a row no stream point matches, and on a recurring state with no clock within the window or two at the same distance. Never inferred from the score or the pattern of play. Added 2026-09-20; clock pairing 2026-09-21. |
outcome | string or null (ace, double_fault, winner, forced_error, unforced_error, null) | How the point ended, as an outside source states it — same join rule and same nulls as serve. meta.enrichment says what landed on this tape. Added 2026-09-20. |
sets | array of integer | |
games | array of array of integer | |
points | array of string | 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. |
server | integer or null (1, 2, null) | Who is serving — null where the feed never asserted it. On ?sequence=clean the server is SETTLED per non-tiebreak game (the server is physically constant within a game, so the game's majority assertion fills nulls and corrects transient disagreements; a tie settles nothing). The raw sequence carries every row exactly as committed, so a transient wrong-server read can appear there and be corrected by a later row. |
is_tiebreak | boolean | |
win_probability_p1 | number or null | The model read stamped on this row while the match was live. The tape carries NO model-regime stamp — there is no model_version or generated_at column here, nor in the bulk tape packages — so segregate a recorded tape corpus by this row's timestamp against the two regime boundaries documented on Score.win_probability_meta: 2026-08-18T08:29:28Z and 2026-09-16T07:59:54Z. |
danger | number or null | |
timestamp | string or null | |
winner | integer or null (1, 2, null) | Who won the point that produced this row — 1 | 2, judged from the score step between this row and the previous served row (the previous raw row on the raw sequence, the previous clean row under ?sequence=clean), by the same rule the completeness ledger counts a legal transition with — and from nothing else: never the serve, the server, the outcome tag or the pattern of play. Null on the first row and wherever the step is not one attributable point: a re-sent row (an identical state — null on raw, collapsed away on clean), a set opener (same games totals, the arrays extended by [0,0]), a backward correction, a multi-game jump. A step whose games total rises by exactly one attributes to the side whose count rose (the game point was theirs) whatever in-game points the step skipped — a missed poll, or a game the strict expander withheld whole; meta.points.games_withheld says when the latter happened. Present on every row of every sequence, observed and reconstructed, and on the pre-2023 ArchiveTape; equal to point_winner wherever that older key is present. Derived once per response, never stored. Added 2026-09-22. |
point_winner | integer or null (1, 2, null) | The same verdict as winner under the older key, kept unchanged for existing readers. Who won the point this row records — PRESENT ONLY on ?sequence=clean and ?points=complete rows, and only where the transition from the previous row of the served sequence is a single attributable point; null on gaps, torn rows and the first row. Never on the plain raw sequence (raw is deliberately non-monotonic: consecutive raw rows are corrections, not points — a points=complete read is the exception, because there the served order IS point order). Derived at read time, never stored or guessed. Since 2026-09-22 winner carries the same verdict on every row of every sequence. |
HistoryTape
| Field | Type | Description |
|---|---|---|
match | object | |
tape | array of object | Chronological score sequence — see HistoryTapeRow |
tiebreaks | array or null | Per-set tiebreak final scores, aligned to the sets of the final scoreline: {"p1", "p2"} for each 7-6 set, null per set otherwise; null when the match has no 7-6 set. Each entry comes from the tape's own terminal breaker state (max >= 7, margin >= 2 — always wins), a source's finished-match summary, or the point-by-point reconstruction — named per set in meta.tiebreaks_source. Nothing is inferred from a partial state: a breaker whose closing point no source states reads null rather than an under-report. Before 2026-09-16 only the tape kind existed and the array was null on ~99% of 7-6 sets. TIMING. Only the tape kind is available the instant a match ends. summary and reconstruction entries are written by a finals pass that runs ONCE A DAY, so a match that finished earlier the same day commonly reads null for its 7-6 sets and carries them from the next pass onward. Measured 2026-09-17: of 7-6 sets in matches completed on the five previous days, 72-100% were populated; of those completed the same day, 8%. Re-read a fresh match the following day before treating a null as final. |
profiles | array of object | Model profiles, oldest first (Analysis profile shape) |
meta | object |
PointsMeta
Measured point-completeness of the sequence ACTUALLY RETURNED, computed at read time — never a stored blanket claim, and only ever a per-match measurement. Always present on ?points=complete responses (with available_complete); on default reads it appears where enabled. Orthogonal to coverage: coverage says how the rows were OBTAINED, this says how COMPLETE the sequence is.
| Field | Type | Description |
|---|---|---|
complete | boolean | The contract bit: a statement that the served sequence holds AT LEAST EVERY POINT THE SCORE REQUIRES — not merely that consecutive rows are consistent with each other. True only when the sequence opens at 0-0, EVERY transition is a legal single-point step, it carries at least as many point transitions as the final scoreline implies the match contained, EVERY completed game holds at least as many point transitions as that game must have contained (games_short is 0 — a whole game the tape skips is a legal 0-0 → 0-0 boundary the transition test alone cannot see, added 2026-09-21), it reaches a finished legal final scoreline at love (or the match ended early — retirement/walkover), it is not known-truncated, and no game was withheld (games_withheld). |
completeness | number or null | Legal single-point transitions over a LOWER-BOUND expected point count derived from the final scoreline. 1.0 for any complete tape, low for a sampled one. Descriptive only — the denominator is a floor, not the true point count — and null when the final scoreline is unreadable. |
rows | integer | Rows measured — always equals meta.rows. |
transitions_legal | integer | Transitions attributable to exactly one side winning one point. |
transitions_total | integer | All row-to-row transitions, excluding re-sent rows (rows − 1 − resent_rows). |
resent_rows | integer | Rows identical to the row before them — the same state re-sent by a source on its timer, which the raw tape carries by design. No point separates them, so they are excluded from the transition counts (transitions_total = rows − 1 − resent_rows) rather than counted as non-point steps. 0 on a clean or recon basis: the collapse removes them, and in a reconstruction one row IS one point so a repeat is a vendor tear and is never dropped. Added 2026-09-20. |
server_rows | integer | Rows carrying a server. |
starts_at_love | boolean | The first row is the 0-0 opening state. |
ends_at_final | boolean | The last row is a complete, legal final scoreline AND shows no game in progress: points at love or null, not a tiebreak in progress. A row carrying a finished spine with a game still underway (e.g. [[6,6],[3,4]] at 15-0) reads false (since 2026-09-21; before that the spine alone decided). |
basis | string (raw, clean, recon) | Which serve was measured: raw | clean (the default read, as returned) or recon (the whole reconstruction, ?points=complete). |
available_complete | boolean or null | ?points=complete responses only. Whether a point-complete reconstruction exists for this match — never guessed. Nightly, refreshed on read when absent: the nightly ledger's verdict when it has one; when the ledger has no entry yet (a reconstruction that landed after the last nightly run) and this read served a reconstruction, it is the live verdict just measured on it (complete); null only when there is no ledger entry and nothing to measure. Added 2026-09-20. |
games_short | integer or null | A completed game for which the tape holds fewer point transitions than that game must have contained; > 0 forces complete to false; nothing is inferred or repaired. Counted by walking the sequence by game boundary and comparing what is held for each completed game with the FEWEST points that game can have contained given the last state it shows (from 0-0 at least 4; from 15-30 at least 5; from 40-40 at least 8; a tiebreak at least 7) — a lower bound, never an estimate. A game the tape skips whole (0-0 → 0-0 with the games counter up by one) counts; so does a game that ended with no further rows after its last stored point. The game's boundary rows are still served exactly as stored. null = nothing measured (an empty sequence). Added 2026-09-21. |
games_withheld | integer or null | Present only when reconstructed rows are in the response. Games whose vendor point record was internally inconsistent (the recorded in-game scores do not form a legal single-point progression from 0-0 — a state repeating or moving backwards) have their in-game points withheld rather than published; the game's opening 0-0 row is still served, so the games spine stays intact. Nothing is repaired, reordered or inferred: a game is served exactly as recorded, or not at all. > 0 means complete is false. null = not measured — the reconstruction predates this check, or was built from the multi-vendor union rather than a single vendor's point record. Added 2026-09-20. |
HistoryPackage
Manifest of one pre-built bulk package (PRO, Historical Data API Pro+, or a one-off package pass). Tape coverage is not a contiguous run of months and is still being extended backwards, so treat the packages listing as the authoritative set of periods that exist. For kind=tape the JSONL file holds ONE LINE PER MATCH (a whole HistoryTape object per line, coverage meta included), not one line per point; the CSV is flattened to one row per point and carries no coverage columns. Under schema_version 2 (since 2026-09-22) each JSONL line is exactly what GET /history/matches/{matchId} returns for that match on its default basis, produced by the same code: the same tape rows with the same fields as the per-match tape (origin, serve, outcome, winner beside the score columns) and the same meta (coverage, point_source, enrichment, reconstructed_at, observed_span, tiebreaks_source, and meta.points whenever the endpoint serves it), so a model that initialises its serve states from server / serve / winner reads them from the bulk file just as it would from the endpoint. The CSV column order is stable and grows only at the end: match_id, row, timestamp, sets_p1, sets_p2, games_p1, games_p2, points_p1, points_p2, server, is_tiebreak, win_probability_p1, danger — the columns every month has ever carried — then, since schema version 2, origin, serve, outcome, winner, with the same values and vocabularies as the JSONL row (a null is an empty cell); read the header, not positions, for anything past danger. A month may also list the complete-basis ADDENDUM files (tennis_history_points_complete_<period>.jsonl.gz / .csv.gz, gzipped and marked by compression, added 2026-08-18). The two bases, plainly: the BASE files carry every match's DEFAULT read — already the complete tape for most point-complete matches — and the ADDENDUM carries, for exactly the matches whose complete point sequence exists only as the on-disk reconstruction, the same tape ?points=complete serves (reconstruction contract: null timestamps and null model fields). Base files are never rewritten by the addendum; their sha256 values do not move. For kind=rally (the charted rally corpus, ULTRA) the JSONL holds one line per charted match with its full point list and the CSV one row per point, one file per YEAR. For kind=archive (the results archive, 1968–2022) the JSONL holds one line per archive result (the ArchiveMatch shape) and the CSV one row per result, one file per YEAR. For kind=archive_tape (the reconstructed 2013–2022 point-by-point tape; ULTRA, History Pro/Business, or an active one-off package window) the JSONL record is byte-for-byte what GET /history/archive/matches/{archiveId}/tape returns — one whole ArchiveTape object per line, meta included — so a parser written against one match runs over a whole year unchanged; the CSV is the flat per-row view keyed on archive_match_id and deliberately carries NO timestamp, win_probability_p1 or danger column, because a column empty in 100% of a file invites the belief that it is sometimes populated. Ten periods, period 2013 through 2022, each with a JSONL and a CSV file, all ready; no earlier year exists, because no tape earlier than 2013 exists. For kind=elo (the as-of Elo tape; ULTRA or History Pro/Business) the JSONL holds one rating record per line — the endpoint's field set plus archive_player_id, the stable person key a bulk join needs — and the CSV one row per record, one file per YEAR; the files are written in primary-key order, so a rebuild of the same data is byte-identical and the published sha256 is a real checksum.
| Field | Type | Description |
|---|---|---|
period | string | Calendar month, YYYY-MM — or the bare year YYYY on the yearly rally/archive/elo/archive_tape kinds. |
status | string (ready) | Only built months are listed or served. |
match_count | integer or null | |
row_count | integer or null | |
files | array of object | One entry per downloadable format. |
built_at | string or null | |
schema_version | integer or null (1, 2, null) | Which shape this month's files carry (added 2026-09-22), so you can tell without opening them. 2 = a package row is the API tape row (origin, serve, outcome, winner, and the full meta), and the CSV carries the trailing columns. 1 = the shape every month built before 22 September 2026 carries: tape rows hold the score columns only (no origin, serve, outcome or winner; no meta.enrichment, reconstructed_at or observed_span), and the CSV ends at danger. Months built before 22 September 2026 keep this shape UNTIL THEY ARE REBUILT; the rebuild runs newest month first, then backwards, and a month's schema_version flips to 2 (and its built_at and sha256 values move) when its turn comes. Check the manifest rather than assuming a month is one or the other: a consumer that needs the enriched fields should require schema_version >= 2 and, for a month still at 1, read the per-match endpoint for the matches it needs. null = not versioned: the non-tape kinds (rankings, elo, …). |
kind | string (tape, rankings, rally, archive, elo, archive_tape) | Present only on non-tape packages, so the shape a tape client already parses is unchanged. On a rankings package match_count is the number of players covered and row_count the number of ranking records; on a rally package the counts are charted matches and points; on an archive package the counts are archive results; on an elo package row_count is the number of rating records; on an archive_tape package match_count is the reconstructed tapes in that year and row_count their score rows (97,901 and 14,340,663 across the ten years). |
CoverageRollup
The measured completeness rollup — what the archive HOLDS, per tour × draw bucket, as of the artifact's own build clock.
| Field | Type | Description |
|---|---|---|
as_of | string | Equal to built_at, verbatim — the artifact's own clock, and the date to quote with every number in this response. |
built_at | string | When the rollup was built (UTC). |
ledger_max_computed_at | string or null | The newest underlying per-match measurement in the completeness ledger. |
method | string | The full measurement rule for point_complete, in one paragraph — every number carries its own definition. |
buckets | object | One CoverageBucket per tour × draw bucket (atp_singles … juniors_doubles, plus other). A bucket with zero completed matches is OMITTED rather than emitted as zeros — read a missing key as "nothing to count", not an error. |
totals | object | The five verifiable numbers for one bucket. |
CoverageBucket
The five verifiable numbers for one bucket.
| Field | Type | Description |
|---|---|---|
completed | integer | Completed matches in the bucket. |
any_tape | integer | Matches with at least one tape row on any basis. |
point_complete | integer | Measured point-complete on the BEST basis — what ?points=complete can serve. |
complete_on_default_read | integer | Point-complete on the default read alone. The difference against point_complete is the set of matches where ?points=complete is load-bearing. |
share | number or null | point_complete / completed. Null on an empty bucket. |
HistoryMatch
| 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). |
tape | object | What point-by-point data we hold for this match. |
Usage
| Field | Type | Description |
|---|---|---|
principal | string | Opaque ref to your own key |
tier | string (free, basic, pro, ultra) | |
base_tier | string | Subscription tier; equals tier unless a temporary grant is active |
tier_expires_at | string or null | When a temporary tier grant reverts, else null |
channel | string | |
limits | object | |
today | object | |
history | array of object | Last 30 days, oldest first |
as_of | string |
MatchStatisticsSide
One player's in-play statistics, in TWO families that are deliberately not merged. The fields at this level are DERIVED from the point-by-point record. measured holds counts taken upstream, including the ones no point record can yield: aces, double faults, the 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 be collapsed.
| Field | Type | Description |
|---|---|---|
measured | object | Measured counting statistics for one player. These are COUNTED upstream, not derived from the point record — which is why they can include aces and double faults, and the derived fields above cannot. They are MATCH TOTALS only — one whole-match rollup per player; there are no per-set measured statistics.
EVERY FIELD IS OPTIONAL AND AN ABSENT FIELD IS OMITTED, never zero-filled — absent means NOT MEASURED, and a present 0 is a real measured zero. Do not assume a fixed shape; read the keys you are given. Measured over 24,552 stored payloads (2026-07-31), coverage falls into three tiers:
TIER 1 — present on 98.6-100% of payloads, on every tour including ITF: aces, double faults, points won, service and return points won, break points won, service games won, longest point and game runs, return points.
TIER 2 — present on essentially all ATP, WTA and Challenger matches and on NO ITF singles match: the first and second serve split, break points saved, tiebreaks won, games won, service games played.
TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. The upstream feed has NOT DELIVERED this group at all since 2026-07-12 (measured 2026-08-17: 0 of 4,513 August payloads carry it, live ATP Masters included), so expect it absent on current matches until the upstream feed resumes it.
A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read from the upstream rounding. |
service_games_played | integer | |
service_games_won | integer | |
hold_pct | integer or null | null when no service game was played — never 0, so a present 0 is a real measured zero |
return_games_played | integer | |
return_games_won | integer | |
break_pct | integer or null | |
break_points_faced | integer | |
break_points_saved | integer | |
break_points_saved_pct | integer or null | |
break_points_played | integer | |
break_points_converted | integer | |
break_points_converted_pct | integer or null | |
service_points_played | integer | |
service_points_won | integer | |
service_points_won_pct | integer or null | |
return_points_played | integer | |
return_points_won | integer | |
return_points_won_pct | integer or null | |
points_played | integer | |
points_won | integer |
MatchStatisticsMeasured
Measured counting statistics for one player. These are COUNTED upstream, not derived from the point record — which is why they can include aces and double faults, and the derived fields above cannot. They are MATCH TOTALS only — one whole-match rollup per player; there are no per-set measured statistics.
EVERY FIELD IS OPTIONAL AND AN ABSENT FIELD IS OMITTED, never zero-filled — absent means NOT MEASURED, and a present 0 is a real measured zero. Do not assume a fixed shape; read the keys you are given. Measured over 24,552 stored payloads (2026-07-31), coverage falls into three tiers:
TIER 1 — present on 98.6-100% of payloads, on every tour including ITF: aces, double faults, points won, service and return points won, break points won, service games won, longest point and game runs, return points.
TIER 2 — present on essentially all ATP, WTA and Challenger matches and on NO ITF singles match: the first and second serve split, break points saved, tiebreaks won, games won, service games played.
TIER 3 — the winners / unforced-errors / forced-errors family, in total and by shot type. Historically delivered on ~43% of ATP singles, ~24% of WTA singles and ~47% of tour doubles, and on NONE of Challenger, ITF or juniors — those zeros are hard, not sampling noise. The upstream feed has NOT DELIVERED this group at all since 2026-07-12 (measured 2026-08-17: 0 of 4,513 August payloads carry it, live ATP Masters included), so expect it absent on current matches until the upstream feed resumes it.
A _of suffix is the denominator of its base field and a _pct suffix is the percentage, recomputed from the two counts rather than read from the upstream rounding.
| Field | Type | Description |
|---|---|---|
aces | integer or null | |
break_points_won | integer or null | |
double_faults | integer or null | |
first_return_points_won | integer or null | |
first_return_points_won_of | integer or null | |
first_return_points_won_pct | integer or null | |
max_games_in_row | integer or null | |
max_points_in_row | integer or null | |
points_won | integer or null | |
return_points_won | integer or null | |
second_return_points_won | integer or null | |
second_return_points_won_of | integer or null | |
second_return_points_won_pct | integer or null | |
service_games_won | integer or null | |
service_points_won | integer or null | |
break_points_saved | integer or null | |
break_points_saved_of | integer or null | |
break_points_saved_pct | integer or null | |
first_serve_points_won | integer or null | |
first_serve_points_won_of | integer or null | |
first_serve_points_won_pct | integer or null | |
first_serves_in | integer or null | |
first_serves_in_of | integer or null | |
first_serves_in_pct | integer or null | |
games_won | integer or null | |
second_serve_points_won | integer or null | |
second_serve_points_won_of | integer or null | |
second_serve_points_won_pct | integer or null | |
second_serves_in | integer or null | |
second_serves_in_of | integer or null | |
second_serves_in_pct | integer or null | |
service_games_played | integer or null | |
tiebreaks_won | integer or null | |
backhand_errors | integer or null | Forced errors off the backhand. |
backhand_unforced_errors | integer or null | Unforced errors off the backhand. |
backhand_winners | integer or null | Winners off the backhand. |
drop_shot_unforced_errors | integer or null | Unforced errors on the drop shot. |
drop_shot_winners | integer or null | Winners on the drop shot. |
errors_total | integer or null | The total of FORCED errors, not of all errors. Verified over 3,766 stored payload sides (June-July 2026): it equals the forehand + backhand + return + overhead error sum in 96.2% of sides, it is SMALLER than unforced_errors_total in 11.7% of sides (impossible for a superset), and per-match points accounting — points won = own winners + opponent forced errors + opponent unforced errors — closes with median residual 0 over 367 matches only under the forced reading. Total errors = errors_total + unforced_errors_total; there is no separate forced-errors field to wait for, because these fields ARE the forced counts. |
forehand_errors | integer or null | Forced errors off the forehand. |
forehand_unforced_errors | integer or null | Unforced errors off the forehand. |
forehand_winners | integer or null | Winners off the forehand. |
groundstroke_errors | integer or null | forehand_errors + backhand_errors — a rollup of the two, not an additive extra. |
groundstroke_unforced_errors | integer or null | Unforced errors off the ground strokes. |
groundstroke_winners | integer or null | Winners off the ground strokes. |
lob_unforced_errors | integer or null | Unforced errors on the lob. |
lob_winners | integer or null | Winners on the lob. |
overhead_errors | integer or null | Forced errors on the overhead — the upstream's overhead-stroke count. |
overhead_winners | integer or null | Winners on the overhead. |
return_errors | integer or null | Forced errors on the return. |
return_winners | integer or null | Winners on the return. |
unforced_errors_total | integer or null | The total of unforced errors. Total errors = errors_total + unforced_errors_total. |
volley_unforced_errors | integer or null | Unforced errors on the volley. |
volley_winners | integer or null | Winners on the volley. |
winners_total | integer or null | Total winners. |
MatchStatisticsFreshness
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.
| Field | Type | Description |
|---|---|---|
measured_divergence | object or null | Null when the families agree. Otherwise why the measured values were withheld, with both match states. |
derived | object | |
measured | object |
MatchStatisticsFamily
| Field | Type | Description |
|---|---|---|
coverage | string (live, final, stale, none, diverged) | final = the closing figures of a completed match. A finished match cannot be "stale", so on final the family's age_seconds is null; as_of still says when the last point/fetch was recorded. |
as_of | string or null | |
age_seconds | integer or null | |
describes | object or null | The match state these statistics describe, per upstream. age_seconds says when we fetched; this says WHAT we fetched. Null when unavailable. |
MatchStatistics
| 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 |
RankingRecord
One ranking record in force at the requested instant. system is always explicit and the systems are never collapsed into a single "rank" — they are not comparable. ATP/WTA and the ITF circuits populate rank+points; UTR populates rating and leaves rank/points null, because UTR is a rating and has no rank.
| Field | Type | Description |
|---|---|---|
player_id | integer or null | Null only on listing rows for players outside our roster — the listing is kept independent of the roster precisely so a top-N has no silent holes. Always present on per-player records for the official systems and UTR. For elo it is also null on rows for the ~62,000 rated people with no roster row — normal, not an error; player_name is always populated, and archive_player fetches that person directly. |
player_name | string or null | The name as the ranking publisher printed it — present on listing rows (where player_id may be null), absent on per-player records. |
system | string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo, atp_doubles, wta_doubles) | |
tour | string or null | |
rank | integer or null | Null for UTR. Null for elo in per-player mode too — an Elo has no global rank at a past instant until you say which field and which activity window you mean, and the per-player read is given neither. In elo listing mode it is the position in the qualified field of THAT call. |
points | integer or null | Null for UTR and for elo — both are ratings, and neither has points. |
surface | string (overall, hard, clay, grass) | elo only, and absent on every other system. Which of the four independent ladders this rating is. A ladder the player has never played is omitted from the response rather than substituted with another. |
matches | integer or null | elo only, and absent on every other system. Cumulative matches on THAT ladder at that date — not the player's career total. Every ladder starts at 1500, so this is what tells you whether a rating is an assessment or a cold start, and it is the number min_matches filters on. |
previous_rank | integer or null | The rank at the immediately preceding snapshot week (ATP/WTA only; null when no prior week is held, and always null for ITF/UTR). |
rank_movement | integer or null | The circuit's own signed weekly movement (ITF systems only; null elsewhere). |
rating | number or null | UTR and elo only; null elsewhere. For elo this is the Elo itself, on our own scale, which is not comparable with Elo published anywhere else. |
effective_date | string or null | The publication week this record took effect. For records ingested live rather than from the official weekly publication this is bucketed to the observed week, so it can sit up to six days later than the moment the value actually took effect; where both exist for one week the official publication is served. |
observed_at | string or null |
RankingListMeta
| Field | Type | Description |
|---|---|---|
limit | integer | |
offset | integer | |
count | integer | |
total | integer or null | Size of the whole filtered set. Null when it cannot be counted cheaply. |
has_more | boolean | More results exist beyond this page. Read this rather than comparing count to limit. For a complete enumeration of a filtered set, page offset forward by limit until this is false; on completed/cancelled listings total is null and this is the only end-of-data signal. |
next_cursor | string or null | Change-feed only (?updated_since= / ?cursor=). Opaque token to pass back as ?cursor=. Null means you are caught up — stop paging and switch to polling from watermark. |
watermark | string or null | Change-feed only. The floor to use on your NEXT poll. Deliberately trails the server clock by 5s so a row committed during this request cannot fall through the gap. |
coverage | object | What resolved against what was asked. Read it before trusting an empty result — ITF and UTR observations reach back to 2026-06-01 (append-only per-player history accumulates from 2026-07-29); nothing earlier can be reconstructed, so a request before oldest_available correctly returns nothing for those systems. |
Webhook
| Field | Type | Description |
|---|---|---|
id | integer | |
url | string | |
events | array of string (score, break_point) | |
enabled | boolean | Set to false automatically after 25 consecutive delivery failures — delete and re-register to resume. |
created_at | string or null | |
last_delivery_at | string or null | |
consecutive_failures | integer | |
last_error | string or null | |
secret | string | Present ONLY on the 201 registration response — shown exactly once |
secret_note | string |
BreakPoint
WebSocket break_point frame (ULTRA). Pushed the instant a break point arises, but only when the subscription named break_point in its signals. Fields sit inline (no nested score object); prob_swing mirrors the REST score's danger. server is the player serving, returner the one holding the break point(s).
| Field | Type | Description |
|---|---|---|
type | string | |
match_id | integer | |
server | integer or null (1, 2, null) | |
returner | integer or null (1, 2, null) | |
break_points | integer or null | How many break points are live at once (1-3). |
set | integer or null | |
game | integer or null | |
point | string or null | |
win_probability_p1 | number or null | |
prob_swing | number or null | |
server_side_favoured | boolean or null | |
ts | string or null |
BreakPointResult
WebSocket break_point_result frame (ULTRA): a break point just resolved. outcome is held (server saved it) or broken (returner converted); win_probability_p1_after is p1's win probability once the game closed.
| Field | Type | Description |
|---|---|---|
type | string | |
match_id | integer | |
server | integer or null (1, 2, null) | |
outcome | string (held, broken) | |
win_probability_p1_after | number or null | |
ts | string or null |
LivePoint
One PLAYED point of a live match — the expanded score state AFTER the point. seq is per-match, monotonic and never skips a value: the ordering, dedup and resume key across the WS frames, the webhook deliveries and the REST catch-up read. ts is CAPTURE time (when our pipeline committed the state), never a per-point clock — no feed asserts one and we fabricate none.
| Field | Type | Description |
|---|---|---|
seq | integer | Per-match monotonic position 1..N — dedup and resume on this. |
set | integer or null | |
game | integer or null | Game within the current set — 13 for a 6-6 tiebreak. |
number | integer or null | Points already recorded in this game at this state — 0 for a game's opening state, and **null when we joined the game already in progress**: how many points it holds is not derivable from the score (the deuce zone maps many ordinals onto one score) and is never guessed. (set, game, number) is NOT a unique key — a game re-expanded by a second source legitimately re-asserts ordinals it already holds, so the same tuple can name more than one row. seq is the only unique, stable per-row key. |
tiebreak | boolean | |
server | integer or null (1, 2, null) | Server of the point played FROM this state; null when the feed omits it. |
winner | integer or null (1, 2, null) | Who won the point that produced this state — null when not attributable to a single point, never guessed. |
score | object | In-game score AFTER the point ('0'/'15'/'30'/'40'/'AD'; the running tiebreak count as integer strings when tiebreak is true). |
sets | array of integer or null | |
games | array of array of integer | [games_p1, games_p2]; each a per-set list, frozen at the pre-breaker score during a tiebreak. |
ts | string or null | Capture time (UTC) — when our pipeline committed this state. Null on every frame of a reconstruction-basis page — the recorded sequence carries no per-point clock and none is fabricated. On the match-closing row of a completed match it is the instant the final score was observed. |
serve | integer or null (1, 2, null) | Added 2026-09-12. The serve the point was played on (1 first, 2 second) as an outside source states it, joined onto the row by exact state. Null = not stated for this point; never inferred. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — serve and outcome both null — rather than tagged wrongly; nothing is corrected to a second serve (since 2026-09-21). Lands a few seconds after the point's own frame (see the point_update frame). Stated on the ATP main tour, ATP Challenger, WTA 1000/500/250/125, UTR and the top Davis Cup tiers; not stated on the ITF World Tennis Tour, nor in any doubles draw. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES, not what we store, and on completed matches the two differ (corrected 2026-09-23; the figures published before that date were counted in our own tables and overstated what a reader could fetch). A completed match whose stored per-point stream does not pass our quality bar is served as a measured-complete RECONSTRUCTION instead, and a reconstruction carries no clock and no tags at all — so per-match tag coverage on completed matches is lower, often much lower, than the share of matches a source tagged. basis on this response names which one you were given. Team ties, re-measured 2026-09-23 over completed matches with a per-point stream since 2026-09-12: Davis Cup World Group publishes serve on 2 of 22 and World Group I on 1 of 42, while World Group II publishes it on 0 of 38 — against 22 of 22 and 41 of 42 respectively in storage, which is the gap this paragraph is about. Qualifying draws are covered like their own main draw, not excluded, and land in the same place their main draw does: WTA qualifying 18 of 48 against 50 of 88 in the WTA main draw, ATP Challenger qualifying (tier: challenger_*) 137 of 197 against 221 of 285, and WTA 125 qualifying (tier: wta_125, also carried under tour: challenger) 47 of 63 against 117 of 147. Read enrichment per match — it is the only per-match answer, none of this is a promise about a match not yet played. Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's serve/outcome coverage exceeds what was captured live — the tags arrive as point_update frames and tagged_at revisions like any other. |
outcome | string or null (ace, double_fault, winner, forced_error, unforced_error, null) | Added 2026-09-12. How the point ended, as an outside source states it. ATP 250 / 500 / 1000: serve stated, outcome full (ace, double fault, winner, forced error, unforced error) from the tour's own per-point console. Grand Slams and Davis Cup are not on that console, so outcome reads null there — but serve comes from a different source and IS stated on them, so those matches are not blanket-null; read enrichment per match. First measured on tour events from the week of 2026-09-22 — before that, no tour event had been played since the source went live on 2026-09-12. ATP Challenger — tour: challenger with a challenger_* tier — states all five. tour: challenger also carries the WTA 125 events (tier: wta_125), and those state ace and double fault only, so read tier rather than tour to tell the two apart. The ITF World Tennis Tour: ace and double fault. WTA 1000/500/250/125: ace and double fault where the tour's console reports the match — read the page's enrichment per match. Grand Slams and UTR: none. Doubles draws: none. Qualifying draws mostly follow their own tour, and the one real gap is on the WTA main tour, not on Challenger. EVERY COUNT BELOW IS WHAT THIS ENDPOINT PUBLISHES rather than what we store (corrected 2026-09-23 — see the note on serve above: a completed match served on the reconstruction basis carries no tags, so the published share is below the share we hold). Re-measured 2026-09-23 over completed matches carrying a per-point stream since 2026-09-12: ATP qualifying publishes outcome on 16 of 16 and ATP Challenger qualifying on 134 of 197, both the full five; ITF qualifying publishes aces and double faults on 181 of 215 men's and 142 of 176 women's, and WTA 125 qualifying on 30 of 63 — while WTA main-tour qualifying publishes it on 5 of 48, against 47 of 88 in the same events' main draws. Read enrichment per match rather than inferring a draw's coverage from its tour. Null = not stated. A source point whose serve number and outcome contradict each other (a double fault stated on a first serve) is served untagged — serve and outcome both null — rather than tagged wrongly (since 2026-09-21). Since 2026-09-22, on the tours whose own point-by-point console we read (ATP 250/500/1000, ATP Challenger, WTA 1000/500/250/125), a COMPLETED match is re-joined after the match against the console's complete sequence by the same exact-state rule, so a completed match's coverage exceeds what was captured live — collect the late tags with ?changed_since= or the point_update frame. |
tagged_at | string or null | Added 2026-09-20. When serve/outcome landed on this row (UTC); null while no tag has — on every reconstruction-basis frame, on the match-closing row, and on any row no source tagged. Tags land a few seconds after the row itself, so a row's tagged_at is later than its ts; changed_since selects on either. |
PointUpdate
Added 2026-09-12. Pushed on the point opt-in (signals:["points"], channels point:match:{id} / point:slate) when a point's serve / outcome land after its point frame went out. Apply to the row with that seq; nothing else on the point changes.
| Field | Type | Description |
|---|---|---|
type | string (point_update) | |
match_id | integer | |
seq | integer | |
serve | integer or null (1, 2, null) | |
outcome | string or null (ace, double_fault, winner, forced_error, unforced_error, null) |
MatchPoints
One page of a match's per-point stream, seq order. basis says which base served it: the persisted live stream rows, or — for a completed match where a measured-complete recorded sequence exists — that complete sequence projected into point frames.
| 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. |
PointFrame
WebSocket point frame (ULTRA, opt-in via signals: ["points"]; also the webhook point event body): one PLAYED point of a subscribed match. seq is arrival order, not match order — dedup/resume on it, and sort by (set, game, number) to replay in playing order (that tuple may repeat or carry a null number; it orders points, it does not identify them). Best-effort, NO replay — catch up via GET /matches/{matchId}/points?after_seq=.
| Field | Type | Description |
|---|---|---|
type | string | |
published_at | string | Added 2026-09-12, on EVERY data frame of the native WebSocket and the push feed (score, point, point_update, status, the signal family): the UTC instant (ms) the frame left our process for the socket or the fan-out sidecar — the third clock next to the state's timestamp (arbiter commit) and a point's ts (capture). |
match_id | integer | |
point | object | One PLAYED point of a live match — the expanded score state AFTER the point. seq is per-match, monotonic and never skips a value: the ordering, dedup and resume key across the WS frames, the webhook deliveries and the REST catch-up read. ts is CAPTURE time (when our pipeline committed the state), never a per-point clock — no feed asserts one and we fabricate none. |
pbp_coverage | string (point, game) | |
quality | string (clean, revised) |
Tournament
One tournament of the catalogue — the stable id space Match.tournament_id joins, one row per tournament × event type, stable across seasons.
| Field | Type | Description |
|---|---|---|
id | string | The stable id Match.tournament_id joins. |
name | string or null | |
tour | string or null (atp, wta, challenger, itf, juniors, null) | |
surface | string or null (hard, clay, grass, null) | Court surface, null where the catalogue holds none — 61 of 10,287 tournaments on 2026-09-20. 35 of those are team competitions (Davis Cup, Billie Jean King Cup, Laver Cup), which have no tournament-level surface to hold: each tie is played on the host nation's choice, and that surface is published on each MATCH of the tie instead. The upstream field for those rows carries the DRAW TIER rather than a surface ("- Preliminary", "- Play Offs", "- Promotion"), and a value that is not one of hard/clay/grass is rejected rather than published. 3 are the exhibition catch-all and 23 are ordinary events the upstream catalogue simply never stated a surface for. |
indoor | boolean | |
city | string or null | Host city, from a curated table — null where not curated. |
country | string or null | Host country, ISO-3166 alpha-2 — null where not curated. (NOTE this differs from player.country and the ?country= filter, which use IOC-style lowercase 3-letter codes.) |
category | string or null (grand_slam, masters_1000, tour_finals, atp_500, atp_250, wta_1000, wta_500, wta_250, wta_125, challenger, itf, juniors, null) | Tournament category where our catalogues agree unambiguously on an exact-name join — null otherwise, never derived from the name. Unchanged by tier (2026-09-22) and remains the coarse class; read tier for the level. |
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 this tournament is played at in the CURRENT season (added 2026-09-22) — the same closed vocabulary as tier on a match (grand_slam, atp_1000 … atp_250, wta_1000 … wta_125, challenger_175 … challenger_50, itf_m15 / itf_m25, itf_w15 … itf_w100, the team events, juniors, utr, exhibition). A catalogue row has no season of its own; a match does: the tier is PER SEASON and it moves under one id — Dallas was atp_250 in 2023 and 2024 and atp_500 from 2025; Estoril atp_250, then challenger_175 in 2025, then atp_250 again in 2026; Eastbourne wta_500 through 2024 and wta_250 from 2025 — so read the tier off each MATCH for its season, never copy this value onto a match. Null when this season's calendar does not list the event — never guessed. |
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. |
ArchiveMatch
One result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will.
| Field | Type | Description |
|---|---|---|
id | integer | |
source_id | string | |
tour | string (atp, wta) | |
level | string or null | Source tier code (G/M/A/F/D/C/O, or a futures category code as published). |
tournament | string or null | |
surface | string or null | |
draw_size | integer or null | |
event_date | string or null | Tournament START date — per-match dates do not exist in this era's records, and we do not invent them. |
round | string or null | |
best_of | integer or null | |
minutes | integer or null | |
winner | object | One participant of an archive result, as the corpus records them. |
loser | object | One participant of an archive result, as the corpus records them. |
score | string or null | The final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O". |
outcome | string or null (completed, retired, walkover, default, abandoned, null) | Parsed from the score's own vocabulary; null when unparseable — never guessed. |
stats | object or null | Detail endpoint only. {"winner":{...}, "loser":{...}} with aces, double_faults, serve_points, first_in, first_won, second_won, serve_games, bp_saved, bp_faced where the source recorded them; null otherwise (most rows before 1991) — never synthesised. |
ArchivePlayer
One participant of an archive result, as the corpus records them.
| Field | Type | Description |
|---|---|---|
name | string or null | |
hand | string or null | |
country | string or null | 3-letter code, same vocabulary as player.country. |
rank | integer or null | The player's rank AT THE TIME of the match, as published. |
seed | integer or null | |
player_id | integer or null | The corpus person id — joins /history/archive/players within the same tour. NOT a roster player id. |
height_cm | integer or null | |
age | number or null | Age at the time of the match, as the corpus records it. |
entry | string or null | Draw entry where recorded (WC, Q, LL, PR, SE, ...) — null for direct acceptances. |
ArchivePlayerBio
One archive person — own id space (the corpus person id that archive match rows carry as winner.player_id / loser.player_id, scoped per tour), never a roster id. Career-high is computed offline from the corpus's own weekly ranking tables at ingest, never modelled. Null fields are the era's silence.
| Field | Type | Description |
|---|---|---|
id | integer | |
tour | string (atp, wta) | |
name | string or null | |
hand | string or null | |
dob | string or null | |
country | string or null | |
height_cm | integer or null | |
career_high_rank | integer or null | |
career_high_date | string or null | The earliest week the career-high rank was reached. |
ArchiveCareer
Career aggregates over the results archive (1968–2022) — sums and ratios of sums only, nothing modelled. Serve stats exist in the corpus from 1991; serve.matches_with_stats states the coverage.
| Field | Type | Description |
|---|---|---|
player | object | |
span | object | |
record | object | |
by_year | array of object | |
serve | object | Summed serve statistics + derived ratios; null ratios where the denominator is zero. |
ArchiveTape
The reconstructed 2013-2022 point-by-point tape for one archive result. Same envelope as HistoryTape so one parser reads both halves of the tape product, with the differences that are TRUE: match is the archive result row (winner/loser-shaped), profiles is always empty, and every row carries a null timestamp, a null age_seconds and null model fields because nobody watched these matches and no model scored them. meta has no match_id — an archive id is not a match id.
| Field | Type | Description |
|---|---|---|
match | object | One result of the results archive (1968–2022). Winner/loser-shaped — results data is recorded that way at the source, so the winner is a field, never an inference. Its own id space; source_id is the stable corpus key. A result played 2013–2022 may additionally carry a reconstructed point-by-point tape (ArchiveTape); results from 1968–2012 have none and never will. |
tape | array of object | The score sequence in seq order, WINNER-FIRST (sets[0] / games[0] / points[0] are the winner's, and a server of 1 means the winner was serving) — see HistoryTapeRow. point_winner and winner (since 2026-09-22) are present; on a per-GAME tape, where consecutive rows differ by a whole game, each reads the side whose game count rose (the game point was theirs) and says nothing about the other points of that game. |
tiebreaks | array or null | Per-set tiebreak final scores, same rule and shape as on HistoryTape. Usually PRESENT even on a per-game tape — the pre-2023 record expands points inside tiebreaks even where it does not elsewhere. |
profiles | array of object | Always empty. The key is kept so the envelope matches HistoryTape; no model analysis exists, or could exist, for a pre-2023 match. |
meta | object |
HeadToHead
The record between two players across both halves of the product — the results archive (1968–2022) and our own completed matches (2023→now). Totals count meetings with a KNOWN winner; undecided counts the rest.
| Field | Type | Description |
|---|---|---|
players | object or null | The resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no player matches the fragments. |
totals | object | |
by_surface | object | Per-surface win split of the decided meetings; keys are surface names plus 'unknown'. |
meetings | array of object | Newest first, capped at 200. era says which half served the row — 'archive' rows carry archive_match_id/level/score; 'current' rows carry match_id/round_code and read their score from the match endpoints. winner is 1|2 OF THIS H2H (p1/p2 as requested), null when underivable. |
stats | object or null | ULTRA only — per-player serve/return/break-point aggregates over the pairing, keyed p1/p2. Each side carries archive_serve (serve-side figures, meetings from 1991) and current (2023+, adding return and break-point conversion, aces and winners), each with its own meetings_with_stats sample size. Absent below ULTRA. |
RallyMatch
One charted match of the rally-construction corpus — its own id space, keyed on rally_match_id.
| Field | Type | Description |
|---|---|---|
rally_match_id | integer | The id this product is keyed on. |
source_id | string | |
match_id | integer or null | OUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection. |
date | string or null | |
tournament | string or null | |
round | string or null | |
surface | string or null | |
gender | string or null (M, W, null) | |
best_of | integer or null | |
players | array of object | |
points | integer | Charted points in this match. |
points_parsed | integer | How many of them our parser read cleanly — the per-match quality number. |
RallyPoint
One charted point. raw is the charter's own string, verbatim, and is ALWAYS present; the parsed fields are our reading of it. parsed is false when the notation contained something we could not read cleanly — the recognised part is still returned. A consumer who wants only unambiguous rows filters on parsed.
| Field | Type | Description |
|---|---|---|
point | integer | |
set | array of integer or null | |
games | array of integer or null | |
score | string or null | e.g. '30-40'. |
game | integer or null | |
is_tiebreak | boolean | |
server | integer or null (1, 2, null) | |
point_winner | integer or null (1, 2, null) | |
raw | string or null | The charter's shot string; both serves joined by ';' when the first was a fault. |
parsed | boolean | |
serve_number | integer or null (1, 2, null) | |
serve_direction | string or null (wide, body, down_the_t, null) | |
rally_length | integer or null | Strokes including the serve. An ace is 1, a double fault 0. |
outcome | string or null (winner, forced_error, unforced_error, error, other, null) | error = the charter recorded a miss without saying whether it was forced. Never guessed. |
error_location | string or null (net, wide, deep, wide_and_deep, null) | |
ending_stroke | string or null | |
ending_wing | string or null | |
is_ace | boolean | |
is_double_fault | boolean | |
is_serve_and_volley | boolean | |
is_unreturned | boolean | Derived (added 2026-09-05). The server won the point without the returner putting the ball in play. A double fault is never unreturned. |
unreturned_kind | string or null (ace, serve_winner, serve_forced_error, return_forced_error, return_unforced_error, return_error, null) | How the serve went unreturned. serve_winner is a serve winner charted without a direction code (so is_ace is unset) — count it with the aces. return_error = the return missed and the charter did not say whether it was forced. Null when the return was in play or the parse could not say. |
shots | array of object |
RallyShot
One stroke. Shots are numbered from the serve: serve 1, return 2, the server's next ball 3.
| Field | Type | Description |
|---|---|---|
number | integer | |
code | string | The charter's raw code, e.g. 'f'. |
stroke | string or null (serve, groundstroke, slice, volley, half_volley, swinging_volley, overhead, drop_shot, lob, trick, unknown, null) | |
wing | string or null (forehand, backhand, null) | The side it was struck FROM. |
direction | string or null (forehand_side, middle, backhand_side, null) | Where the ball was sent. |
depth | string or null (shallow, mid, deep, null) | |
position | string or null (approaching, at_net, baseline, null) |