Live Tennis API — Full Reference

Version 1.7.1 · OpenAPI 3.1.0

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 historical results, no market prices, no model fields, no WebSocket.

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 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.

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. 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 (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, 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 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.

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

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

A key in a URL can end up in browser history, server logs and referrer headers, so prefer a header for anything automated or shared. For trying the API out, clicking a link is the fastest route and that trade-off is fine.

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.

Plans — what each tier adds over the one below, its rate limit and price
PlanAddsRate limitPrice
FREEThe 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
BASICHistorical 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
PROMatch 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
ULTRAModel 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:

Historical Data API plans — what each adds
PlanAdds
StarterSingle-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.
ProEverything in Starter, plus bulk monthly package downloads and higher rate limits.
BusinessEverything in Pro, plus year-scale archive exports, top rate limits and priority support.
One-off passes1-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

Official client libraries — install command and source repository
LanguageInstallSource
Pythonpip install livetennisapilivetennisapi-python
JavaScript / TypeScriptnpm install livetennisapilivetennisapi-js
MCP server (LLM agents)npx livetennisapi-mcplivetennisapi-mcp

Conventions

Endpoints

GET /health

Liveness probe (no auth)

Plan required: · operationId: healthCheck

Responses

GET /health — responses
StatusMeaning
200OK

Response fields

GET /health — response fields
FieldTypeDescription
statusstring
versionstring

Example

curl https://api.livetennisapi.com/api/public/v1/health

GET /matches

List matches by lifecycle status (FREE)

Plan required: FREE · operationId: listMatches

status=live and status=upcoming are the FREE current-state picture. status=completed pages historical results and is part of the paid History product — it requires BASIC (the same rule as /history/matches) and returns 403 upgrade_required on a FREE key. The player, country, from/to, tour and draw filters are optional, AND-composed, applied inside the query (before pagination), and work on every status — omitting them returns exactly what the endpoint returned before they existed.

Parameters

GET /matches — parameters
NameInTypeRequiredNotes
statusquerystring (live, upcoming, completed)nolive (default) and upcoming are FREE; completed requires BASIC — paging completed results is the history surface. Default live.
tourquerystring (atp, wta, challenger, itf, juniors)noRestrict 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.
drawquerystring (singles, doubles)noDraw 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.
playerqueryarray of integernoFilter 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.
countryquerystringnoFilter 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.
fromquerystringnoEarliest 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.
toquerystringnoLatest play date, same formats as from (a bare date includes everything played that day). from after to is a 400.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /matches — responses
StatusMeaning
200Matches with latest score
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /matches — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: FREE · operationId: getMatch

Parameters

GET /matches/{matchId} — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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

GET /matches/{matchId} — responses
StatusMeaning
200Match with score; market embed at PRO+, analysis embed at ULTRA
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId} — response fields
FieldTypeDescription
idinteger
tournamentstring
tourstring 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_idstring or nullStable tournament identity — one id per tournament × event type, stable across seasons. Joins GET /tournaments/{tournamentId}. Null on matches ingested before the catalogue covered their tournament. (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.)
surfacestring or null (hard, clay, grass, null)
indoorboolean
formatstring or null (BO3, BO5, null)
roundstring or null
round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)The round in the archive's controlled vocabulary, normalized from the free-text label above (Q = qualifying round the feed does not number). This is the field to branch on; it matches /history/archive/matches?round= exactly. Null when the label is unrecognised — never guessed.
statusstring (upcoming, live, completed, cancelled)
event_statusstring or null (Retired, Cancelled, Walk Over, Postponed, Interrupted, null)How the match ended (or paused) when it did not run its course: retirement, cancellation, walkover, postponement, or an in-play suspension (Interrupted — rain/darkness/medical; the match is paused, not over). NULL means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. Two honest caveats: the value is cleared if a suspended match resumes (no record of the pause survives), and for Retired/Walk Over the withdrawing player is reported in withdrew, where derivable.
event_status_updated_atstring or nullWhen 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_doublesbooleanDoubles 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.
drawstring 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_timestring or null
playersobject
scoreobject or null
winnerinteger or nullCompleted 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.
withdrewinteger or nullCompleted 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.
analysisobjectULTRA only (absent below)
marketobject or nullPRO+ 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)

Plan required: FREE · operationId: getMatchScore

This is a POINT-IN-TIME SNAPSHOT: the single current state, overwritten on every score commit. It carries no history and no accumulated statistics. For the SEQUENCE of states — who served each game, hold/break, every score state in forward order — use /history/matches/{matchId}?sequence=clean, which works on a LIVE match, not only a completed one. For in-play statistics use /matches/{matchId}/statistics (ULTRA); they are deliberately not on this object, because they can be further behind the match than the score and must carry their own as_of.

Parameters

GET /matches/{matchId}/score — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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

GET /matches/{matchId}/score — responses
StatusMeaning
200Current score (ULTRA adds win_probability_p1 + danger)
401Missing, unknown, or disabled credentials
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId}/score — response fields
FieldTypeDescription
setsarray of integer
gamesarray of array of integer[games_p1, games_p2]; each a per-set list
pointsarray of string or nullIn-game points as tennis strings ("0", "15", "40", "AD"). Entries can be NULL — observed live on completed matches, which also carry empty games arrays. Do not decode into non-nullable strings.
serverinteger or null (1, 2, null)
is_tiebreakboolean
win_probability_p1number or null
dangernumber or null
timestampstring or null

Example

curl https://api.livetennisapi.com/api/public/v1/matches/18953/score \
  -H "Authorization: Bearer twjp_..."

GET /matches/{matchId}/events

Match events, newest first (PRO)

Plan required: PRO · operationId: listMatchEvents

Parameters

GET /matches/{matchId}/events — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /matches/{matchId}/events — responses
StatusMeaning
200Events
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /matches/{matchId}/events — response fields
FieldTypeDescription
dataarray of object
metaobject

Example

curl https://api.livetennisapi.com/api/public/v1/matches/18953/events \
  -H "Authorization: Bearer twjp_..."

GET /matches/{matchId}/analysis

Model analysis for a match (ULTRA)

Plan required: ULTRA · operationId: getMatchAnalysis

Parameters

GET /matches/{matchId}/analysis — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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

GET /matches/{matchId}/analysis — responses
StatusMeaning
200Thesis + profile (either may be null)
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId}/analysis — response fields
FieldTypeDescription
thesisobject or null
profileobject 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)

Plan required: ULTRA · operationId: getMatchStatistics

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

GET /matches/{matchId}/statistics — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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

GET /matches/{matchId}/statistics — responses
StatusMeaning
200Statistics with their own coverage and as_of
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId}/statistics — response fields
FieldTypeDescription
match_idinteger
coveragestring (live, final, stale, none, diverged)
as_ofstring or nullWhen the underlying record was last updated (UTC)
age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
games_countedinteger
tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
sets_coveredarray of integer
freshnessobjectPer-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.
detailstringPresent only when coverage is none
playersobject 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)

Plan required: ULTRA · operationId: getMatchPoints

The live per-point event stream of one match, in seq order. The WebSocket point frames are best-effort with NO replay, so this endpoint is how you join mid-match and how you recover a dropped connection: subscribe the WS first, then GET with after_seq set to the last seq you hold, then dedup everything by seq — it is per-match, monotonic and never skips a value, so it is the whole reconciliation key. READ THE COVERAGE HONESTLY BEFORE YOU BUILD ON IT. A match's stream is per-point ONLY where a point-level feed covers it: pbp_coverage: "point" means this match has a true per-point stream; "game" means only the snapshot score path covers it — points is empty and that is an answer, not an error. Per-point coverage is never promised slate-wide; ITF and qualifying coverage in particular is partial. quality: "revised" means the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited (append-only). Each row is the state AFTER a played point: score/sets/games (tiebreaks carry the running count in score with games frozen at the pre-breaker score), its position (set/game/number), server (of the next point), the derived winner (null when not attributable to a single point — never guessed), and ts — CAPTURE time, when our pipeline committed the state, because no feed asserts a per-point clock and we fabricate none. Up to 500 rows per page; after_seq=last_seq fetches the next page while has_more is true. 404 unknown match; 400 points_disabled while the surface is switched off server-side. COMPLETED MATCHES: live capture is inherently partial — the stream serves what arrived while the match ran, and the match-closing point never streams live. Where a measured-complete recorded point sequence of the finished match exists, this endpoint serves THAT instead — the complete sequence projected into the same point-frame shape, love-love opener through the match-closing point, seq contiguous 1..N. The response field basis says which base served the page: live (the persisted live stream rows — every live match, and any completed match without a measured-complete recorded sequence) or reconstruction (the projected complete sequence; quality is clean, every transition measured legal). Completeness beats the partial live capture wholesale — the two sequences are never interleaved (they share no key, so any merge would fabricate an order). On projected frames ts is null on every row: the recorded sequence carries no per-point clock and we fabricate none. after_seq pagination and seq dedup work identically on either basis, but the two bases are different sequences: after a match completes and flips to reconstruction, re-read from after_seq=0 rather than resuming a live cursor into it.

Parameters

GET /matches/{matchId}/points — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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_seqqueryintegernoReturn only points with seq greater than this — the resume cursor. Pass the last_seq of the previous page (or the last seq your WS stream delivered) to continue; 0 or absent reads from the start of the match. A non-integer or negative value is a 400 bad_after_seq. Default 0.

Responses

GET /matches/{matchId}/points — responses
StatusMeaning
200The point events page, seq order
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId}/points — response fields
FieldTypeDescription
match_idinteger
pbp_coveragestring (point, game)point = this match has a true per-point stream; game = only the snapshot score path covers it (points is empty — an answer, not an error).
qualitystring (clean, revised)revised = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
covers_from_startboolean or nullWhether 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").
pointsarray of object
last_seqintegerThe resume cursor — pass as after_seq to continue.
has_moreboolean
basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows (every live match, and any completed match without a measured-complete recorded sequence); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time — includes the match-closing point, seq contiguous 1..N, ts null on every frame. Completeness beats the partial live capture wholesale; the two bases are never interleaved.

Example

curl https://api.livetennisapi.com/api/public/v1/matches/18953/points \
  -H "Authorization: Bearer twjp_..."

GET /players

Search players by name (FREE)

Plan required: FREE · operationId: searchPlayers

Parameters

GET /players — parameters
NameInTypeRequiredNotes
searchquerystringno
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /players — responses
StatusMeaning
200Players (ranked first; no stats object on the list)
401Missing, unknown, or disabled credentials
429Rate 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

GET /players — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: FREE · operationId: getPlayer

Parameters

GET /players/{playerId} — parameters
NameInTypeRequiredNotes
playerIdpathintegeryes

Responses

GET /players/{playerId} — responses
StatusMeaning
200Player with stats ({ratings, season})
401Missing, unknown, or disabled credentials
404No such resource, or no data yet
429Rate 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

GET /players/{playerId} — response fields
FieldTypeDescription
idinteger
namestring
tourstring or nullThe 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.
countrystring or null
rankinginteger or null
ranking_pointsinteger or null
ranking_movementstring or null (up, down, same, null)
handstring or null (R, L, null)
backhandinteger or null (1, 2, null)
birthdaystring or null
is_doubles_teamboolean
data_completenessobjectHow 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.
statsobjectSingle-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)

Plan required: FREE · operationId: listTournaments

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.

Parameters

GET /tournaments — parameters
NameInTypeRequiredNotes
searchquerystringnoCase-insensitive substring match on the tournament name.
tourquerystring (atp, wta, challenger, itf, juniors)noRestrict 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.
drawquerystring (singles, doubles)noDraw 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /tournaments — responses
StatusMeaning
200Tournaments, name order
400Bad query parameter
401Missing, unknown, or disabled credentials
429Rate 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

GET /tournaments — response fields
FieldTypeDescription
dataarray of object
metaobject

Example

curl https://api.livetennisapi.com/api/public/v1/tournaments \
  -H "Authorization: Bearer twjp_..."

GET /tournaments/{tournamentId}

One tournament by its stable id (FREE)

Plan required: FREE · operationId: getTournament

Parameters

GET /tournaments/{tournamentId} — parameters
NameInTypeRequiredNotes
tournamentIdpathstringyesThe tournament_id carried on match objects.

Responses

GET /tournaments/{tournamentId} — responses
StatusMeaning
200The tournament
401Missing, unknown, or disabled credentials
404No such resource, or no data yet
429Rate 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

GET /tournaments/{tournamentId} — response fields
FieldTypeDescription
idstringThe stable id Match.tournament_id joins.
namestring or null
tourstring or null (atp, wta, challenger, itf, juniors, null)
surfacestring or null (hard, clay, grass, null)
indoorboolean
citystring or nullHost city, from a curated table — null where not curated.
countrystring or nullHost 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.)
categorystring 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.

Example

curl https://api.livetennisapi.com/api/public/v1/tournaments/{tournamentId} \
  -H "Authorization: Bearer twjp_..."

GET /markets

Match-winner market(s) for a match (PRO)

Plan required: PRO · operationId: listMarkets

Parameters

GET /markets — parameters
NameInTypeRequiredNotes
match_idqueryintegeryes

Responses

GET /markets — responses
StatusMeaning
200Markets
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /markets — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: PRO · operationId: getMarketPrices

Parameters

GET /markets/{matchId}/prices — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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.
limitqueryintegerno Default 50.

Responses

GET /markets/{matchId}/prices — responses
StatusMeaning
200Market with prices
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /markets/{matchId}/prices — response fields
FieldTypeDescription
idinteger
questionstring or null
statusstring or null (active, resolved, closed, null)
volumenumber or null
liquiditynumber or null
end_datestring or null
pricesarray of objectPrices 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)

Plan required: PRO · operationId: listMatchPrices

Recent ticks only (no market wrapper). limit caps at 500; minutes bounds the lookback window. 404 when the match has no mapped market.

Parameters

GET /matches/{matchId}/prices — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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.
limitqueryintegerno Default 100.
minutesqueryintegerno

Responses

GET /matches/{matchId}/prices — responses
StatusMeaning
200Price ticks
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /matches/{matchId}/prices — response fields
FieldTypeDescription
dataarray of object
metaobject

Example

curl https://api.livetennisapi.com/api/public/v1/matches/18953/prices \
  -H "Authorization: Bearer twjp_..."

GET /history/matches

Completed matches, newest first, with derived winner and tape coverage (BASIC)

Plan required: BASIC · operationId: listCompletedMatches

Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see https://livetennisapi.com/historical-tennis-data-api). 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.

Parameters

GET /history/matches — parameters
NameInTypeRequiredNotes
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.
fromquerystringnoEarliest 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.
toquerystringnoLatest play date, same formats as from (a bare date includes everything played that day). from after to is a 400.
tourquerystring (atp, wta, challenger, itf, juniors)noRestrict 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.
drawquerystring (singles, doubles)noDraw 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.
playerqueryarray of integernoFilter 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.
countryquerystringnoFilter 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.
coveragequerystring (from_start, partial, reconstructed, reconstructed_partial, none)noKeep only matches whose tape has this coverage. An unknown value is a 400 bad_coverage listing the accepted values in allowed.
points_completequerystring (true, false)noKeep 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=.

Responses

GET /history/matches — responses
StatusMeaning
200Completed matches (winner = 1|2|null, from final sets), each with its tape coverage
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /history/matches — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: BASIC · operationId: getHistoryCoverage

Requires BASIC on the live API, or any Historical Data API plan (Starter and up — see https://livetennisapi.com/historical-tennis-data-api). 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

GET /history/coverage — responses
StatusMeaning
200The rollup artifact, dated by its own as_of
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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.
503coverage_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

GET /history/coverage — response fields
FieldTypeDescription
as_ofstringEqual to built_at, verbatim — the artifact's own clock, and the date to quote with every number in this response.
built_atstringWhen the rollup was built (UTC).
ledger_max_computed_atstring or nullThe newest underlying per-match measurement in the completeness ledger.
methodstringThe full measurement rule for point_complete, in one paragraph — every number carries its own definition.
bucketsobjectOne CoverageBucket per tour × draw bucket (atp_singlesjuniors_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.
totalsobjectThe 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)

Plan required: BASIC · operationId: getMatchTape

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

GET /history/matches/{matchId} — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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.
sequencequerystring (raw, clean)noraw (default) is every row we committed — 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. An unknown value is a 400 bad_sequence. Default raw.
pointsquerystring (default, complete)nodefault 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 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

GET /history/matches/{matchId} — responses
StatusMeaning
200The full tape (match + tape + profiles + coverage meta)
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /history/matches/{matchId} — response fields
FieldTypeDescription
matchobjectMatch metadata with the final score embedded.
tapearray of objectChronological score sequence — see HistoryTapeRow.
tiebreaksarray or nullPer-set tiebreak final scores from OBSERVED states only, aligned to the sets of the final scoreline: {"p1", "p2"} for a 7-6 set whose observed maximum tiebreak state is a valid terminal shape (max >= 7, margin >= 2), null per set otherwise — a breaker whose closing point the feed skipped reads null rather than an under-report. Null when the match has no 7-6 set. Present on raw and clean alike.
profilesarray of objectModel profiles produced during the match, oldest first.
metaobject

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)

Plan required: BASIC · operationId: listArchiveMatches

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

GET /history/archive/matches — parameters
NameInTypeRequiredNotes
tourquerystring (atp, wta)noThe archive covers the ATP and WTA corpora only.
namequerystringnoCase-insensitive substring match on EITHER player's name (min 3 chars).
fromquerystringnoEarliest tournament start date (YYYY-MM-DD).
toquerystringnoLatest tournament start date (YYYY-MM-DD).
roundquerystring (F, SF, QF, R16, R32, R64, R128, RR, BR, Q1, Q2, Q3, Q4, ER)noThe archive's controlled round vocabulary.
levelquerystringnoSource 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /history/archive/matches — responses
StatusMeaning
200Archive results, newest tournament first
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /history/archive/matches — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: BASIC · operationId: getArchiveMatch

Same entitlement as the archive listing. stats is null for the (mostly pre-1991) rows the source never recorded statistics for — never synthesised.

Parameters

GET /history/archive/matches/{archiveId} — parameters
NameInTypeRequiredNotes
archiveIdpathintegeryes

Responses

GET /history/archive/matches/{archiveId} — responses
StatusMeaning
200The archive record, stats included where the era recorded them
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /history/archive/matches/{archiveId} — response fields
FieldTypeDescription
idinteger
source_idstring
tourstring (atp, wta)
levelstring or nullSource tier code (G/M/A/F/D/C/O, or a futures category code as published).
tournamentstring or null
surfacestring or null
draw_sizeinteger or null
event_datestring or nullTournament START date — per-match dates do not exist in this era's records, and we do not invent them.
roundstring or null
best_ofinteger or null
minutesinteger or null
winnerobjectOne participant of an archive result, as the corpus records them.
loserobjectOne participant of an archive result, as the corpus records them.
scorestring or nullThe final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O".
outcomestring or null (completed, retired, walkover, default, abandoned, null)Parsed from the score's own vocabulary; null when unparseable — never guessed.
statsobject or nullDetail 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/players

Archive player bios — hand, DOB, country, height, career-high (BASIC)

Plan required: BASIC · operationId: listArchivePlayers

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

GET /history/archive/players — parameters
NameInTypeRequiredNotes
namequerystringnoCase-insensitive substring filter (min 3 chars).
tourquerystring (atp, wta)no
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /history/archive/players — responses
StatusMeaning
200Archive people, ordered by name
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /history/archive/players — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: BASIC · operationId: getArchiveCareer

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

GET /history/archive/career — parameters
NameInTypeRequiredNotes
namequerystringyesPlayer name (fragment, min 3 chars — must resolve to one person).

Responses

GET /history/archive/career — responses
StatusMeaning
200The career aggregate body
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /history/archive/career — response fields
FieldTypeDescription
playerobject
spanobject
recordobject
by_yeararray of object
serveobjectSummed 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)

Plan required: BASIC · operationId: getHeadToHead

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

GET /h2h — parameters
NameInTypeRequiredNotes
p1querystringyesFirst player name (fragment, min 3 chars).
p2querystringyesSecond player name (fragment, min 3 chars).

Responses

GET /h2h — responses
StatusMeaning
200The head-to-head record; empty totals when no player matches the names
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /h2h — response fields
FieldTypeDescription
playersobject or nullThe resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no player matches the fragments.
totalsobject
by_surfaceobjectPer-surface win split of the decided meetings; keys are surface names plus 'unknown'.
meetingsarray of objectNewest 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.
statsobject or nullULTRA 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)

Plan required: PRO · operationId: listHistoryPackages

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

GET /history/packages — parameters
NameInTypeRequiredNotes
kindquerystring (tape, rankings, rally, archive, elo)noPackage 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). 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.
yearquerystringnoYear archive listing — every published month of the year (History Business, a 1-year package, or ULTRA).

Responses

GET /history/packages — responses
StatusMeaning
200Ready packages, newest period first
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /history/packages — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: PRO · operationId: getHistoryPackage

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

GET /history/packages/{period} — parameters
NameInTypeRequiredNotes
periodpathstringyesCalendar month, YYYY-MM — except for the yearly kinds (rally, archive, elo), where it is the bare year YYYY (400 bad_period otherwise).
kindquerystring (tape, rankings, rally, archive, elo)noPackage family; rankings, rally and elo require ULTRA or a History Pro/Business subscription (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. Default tape.
formatquerystring (jsonl, csv)noOmit for the JSON manifest; set to download the file.

Responses

GET /history/packages/{period} — responses
StatusMeaning
200The manifest (no format), or the bulk file as an attachment (format=jsonl streams NDJSON, format=csv streams 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.
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /history/packages/{period} — response fields
FieldTypeDescription
periodstringCalendar month, YYYY-MM — or the bare year YYYY on the yearly rally/archive/elo kinds.
statusstring (ready)Only built months are listed or served.
match_countinteger or null
row_countinteger or null
filesarray of objectOne entry per downloadable format.
built_atstring or null
kindstring (tape, rankings, rally, archive, elo)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.

Example

curl https://api.livetennisapi.com/api/public/v1/history/packages/{period} \
  -H "Authorization: Bearer twjp_..."

GET /fixtures

Upcoming scheduled fixtures, earliest first (FREE)

Plan required: FREE · operationId: listFixtures

Parameters

GET /fixtures — parameters
NameInTypeRequiredNotes
tourquerystring (atp, wta, challenger, itf, juniors)noRestrict 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.
drawquerystring (singles, doubles)noDraw 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /fixtures — responses
StatusMeaning
200Fixtures with start time and player ids where resolved — the nulls are real states, not gaps (names are always present)
401Missing, unknown, or disabled credentials
429Rate 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

GET /fixtures — response fields
FieldTypeDescription
dataarray of object
metaobject

Example

curl https://api.livetennisapi.com/api/public/v1/fixtures \
  -H "Authorization: Bearer twjp_..."

GET /usage

Your own usage vs quota (FREE — any tier)

Plan required: FREE · operationId: getUsage

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

GET /usage — responses
StatusMeaning
200Usage summary
401Missing, unknown, or disabled credentials
429Rate 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

GET /usage — response fields
FieldTypeDescription
principalstringOpaque ref to your own key
tierstring (free, basic, pro, ultra)
base_tierstringSubscription tier; equals tier unless a temporary grant is active
tier_expires_atstring or nullWhen a temporary tier grant reverts, else null
channelstring
limitsobject
todayobject
historyarray of objectLast 30 days, oldest first
as_ofstring

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

Plan required: PRO · operationId: listRankings

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

GET /rankings — parameters
NameInTypeRequiredNotes
playerqueryarray of integernoPlayer id — repeatable, max 50 (ULTRA per-player mode). OMIT it for the rank-ordered listing mode (PRO), which then requires exactly one system.
archive_playerqueryarray of integernoHistorical-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.
tourquerystring (atp, wta)noREQUIRED 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.
surfacequeryarray of string (overall, hard, clay, grass)nosystem=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.
min_matchesqueryintegernosystem=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. Default 20.
activity_weeksqueryintegernosystem=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. Default 52.
as_ofquerystringnoYYYY-MM-DD. Omit for the latest known record.
systemqueryarray of string (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo)noRestrict to one or more systems. Omit for all of the official systems — elo is NEVER included implicitly and must be named, 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /rankings — responses
StatusMeaning
200Ranking records in force at as_of
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /rankings — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: ULTRA · operationId: listRallyMatches

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

GET /rally/matches — parameters
NameInTypeRequiredNotes
playerquerystringnoSubstring match on either player name.
fromquerystringnoYYYY-MM-DD.
toquerystringnoYYYY-MM-DD.
surfacequerystringno
genderquerystring (M, W)no
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /rally/matches — responses
StatusMeaning
200Charted matches, with per-match parse-quality counts
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /rally/matches — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: ULTRA · operationId: getRallyMatch

One charted match with its points, in play order. Paged with limit/offset; meta.total is the match's full point count.

Parameters

GET /rally/matches/{rallyMatchId} — parameters
NameInTypeRequiredNotes
rallyMatchIdpathintegeryes
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /rally/matches/{rallyMatchId} — responses
StatusMeaning
200The charted match with its rally points
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /rally/matches/{rallyMatchId} — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaobject
rallyarray 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)

Plan required: ULTRA · operationId: getMatchRally

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

GET /history/matches/{matchId}/rally — parameters
NameInTypeRequiredNotes
matchIdpathintegeryesThe 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.
limitqueryintegerno Default 50.
offsetqueryintegerno Default 0.

Responses

GET /history/matches/{matchId}/rally — responses
StatusMeaning
200The charted match with its rally points
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /history/matches/{matchId}/rally — response fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
pointsintegerCharted points in this match.
points_parsedintegerHow many of them our parser read cleanly — the per-match quality number.
metaobject
rallyarray 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)

Plan required: ULTRA · operationId: getChartingPlayer

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,646 charted matches across both tours back to the 1960s, concentrated on the majors, NOT full-slate coverage.

Parameters

GET /charting/players — parameters
NameInTypeRequiredNotes
namequerystringyesPlayer name (fragment, min 3 chars).
genderquerystring (men, women)noDisambiguates a fragment that matches one charted person per tour side.

Responses

GET /charting/players — responses
StatusMeaning
200The player's summed charting families with the sample size
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /charting/players — response fields
FieldTypeDescription
playerobject
matches_chartedinteger
coveragestring
familiesobjectPer-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)

Plan required: ULTRA · operationId: getChartingMatch

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

GET /charting/matches/{chartingMatchId} — parameters
NameInTypeRequiredNotes
chartingMatchIdpathintegeryes

Responses

GET /charting/matches/{chartingMatchId} — responses
StatusMeaning
200The charted match's stat families, both players, per set
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

GET /charting/matches/{chartingMatchId} — response fields
FieldTypeDescription
charting_match_idinteger
mcp_idstring
genderstring
playersobject
familiesobject

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)

Plan required: ULTRA · operationId: createWebhook

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

POST /webhooks — responses
StatusMeaning
201Created — includes secret (shown exactly once)
400Bad query parameter
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
409Webhook limit reached (3 per key) — delete an existing webhook first
429Rate 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)

Plan required: ULTRA · operationId: listWebhooks

Responses

GET /webhooks — responses
StatusMeaning
200Your webhooks
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /webhooks — response fields
FieldTypeDescription
dataarray of object
metaobject

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)

Plan required: ULTRA · operationId: deleteWebhook

Parameters

DELETE /webhooks/{webhookId} — parameters
NameInTypeRequiredNotes
webhookIdpathintegeryes

Responses

DELETE /webhooks/{webhookId} — responses
StatusMeaning
200Deleted
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
404No such resource, or no data yet
429Rate 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

DELETE /webhooks/{webhookId} — response fields
FieldTypeDescription
deletedinteger

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)

Plan required: ULTRA · operationId: createWsToken

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

GET /ws-token — responses
StatusMeaning
200Connection token, push URL and channel vocabulary
401Missing, unknown, or disabled credentials
403Your tier doesn't unlock this endpoint
429Rate 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

GET /ws-token — response fields
FieldTypeDescription
tokenstring
expires_ininteger
ws_urlstringThe push WebSocket URL to connect to with the token.
channelsobjectChannel 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. Bulk: tape packages are built per calendar month, archive packages per year (?kind=archive); 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.

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

Error

Error schema — fields
FieldTypeDescription
errorstringStable machine-readable code, e.g. bad_date, bad_coverage, bad_sequence, bad_points, bad_points_complete, bad_combination, points_read_disabled, bad_after_seq, points_disabled, bad_period, bad_year, bad_format, bad_tour, bad_country, ambiguous_name, not_charted, not_found, upgrade_required, rate_limited, abuse_throttled.
detailstringHuman-readable explanation, when one adds anything.
allowedarray of stringOn a rejected enumerated parameter, the values that would have been accepted (e.g. the coverage vocabulary for bad_coverage).

ListMeta

ListMeta schema — fields
FieldTypeDescription
limitinteger
offsetinteger
countinteger
totalinteger or nullSize of the whole filtered set. Null when it cannot be counted cheaply.
has_morebooleanMore results exist beyond this page. Read this rather than comparing count to limit.

Score

ULTRA adds win_probability_p1 + danger.

Score schema — fields
FieldTypeDescription
setsarray of integer
gamesarray of array of integer[games_p1, games_p2]; each a per-set list
pointsarray of string or nullIn-game points as tennis strings ("0", "15", "40", "AD"). Entries can be NULL — observed live on completed matches, which also carry empty games arrays. Do not decode into non-nullable strings.
serverinteger or null (1, 2, null)
is_tiebreakboolean
win_probability_p1number or null
dangernumber or null
timestampstring or null

Player

Player schema — fields
FieldTypeDescription
idinteger
namestring
tourstring or nullThe 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.
countrystring or null
rankinginteger or null
ranking_pointsinteger or null
ranking_movementstring or null (up, down, same, null)
handstring or null (R, L, null)
backhandinteger or null (1, 2, null)
birthdaystring or null
is_doubles_teamboolean
data_completenessobjectHow 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.
statsobjectSingle-player endpoint only

Match

Match schema — fields
FieldTypeDescription
idinteger
tournamentstring
tourstring 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_idstring or nullStable tournament identity — one id per tournament × event type, stable across seasons. Joins GET /tournaments/{tournamentId}. Null on matches ingested before the catalogue covered their tournament. (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.)
surfacestring or null (hard, clay, grass, null)
indoorboolean
formatstring or null (BO3, BO5, null)
roundstring or null
round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)The round in the archive's controlled vocabulary, normalized from the free-text label above (Q = qualifying round the feed does not number). This is the field to branch on; it matches /history/archive/matches?round= exactly. Null when the label is unrecognised — never guessed.
statusstring (upcoming, live, completed, cancelled)
event_statusstring or null (Retired, Cancelled, Walk Over, Postponed, Interrupted, null)How the match ended (or paused) when it did not run its course: retirement, cancellation, walkover, postponement, or an in-play suspension (Interrupted — rain/darkness/medical; the match is paused, not over). NULL means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. Two honest caveats: the value is cleared if a suspended match resumes (no record of the pause survives), and for Retired/Walk Over the withdrawing player is reported in withdrew, where derivable.
event_status_updated_atstring or nullWhen 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_doublesbooleanDoubles 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.
drawstring 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_timestring or null
playersobject
scoreobject or null
winnerinteger or nullCompleted 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.
withdrewinteger or nullCompleted 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.

MatchDetail

MatchDetail schema — fields
FieldTypeDescription
idinteger
tournamentstring
tourstring 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_idstring or nullStable tournament identity — one id per tournament × event type, stable across seasons. Joins GET /tournaments/{tournamentId}. Null on matches ingested before the catalogue covered their tournament. (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.)
surfacestring or null (hard, clay, grass, null)
indoorboolean
formatstring or null (BO3, BO5, null)
roundstring or null
round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)The round in the archive's controlled vocabulary, normalized from the free-text label above (Q = qualifying round the feed does not number). This is the field to branch on; it matches /history/archive/matches?round= exactly. Null when the label is unrecognised — never guessed.
statusstring (upcoming, live, completed, cancelled)
event_statusstring or null (Retired, Cancelled, Walk Over, Postponed, Interrupted, null)How the match ended (or paused) when it did not run its course: retirement, cancellation, walkover, postponement, or an in-play suspension (Interrupted — rain/darkness/medical; the match is paused, not over). NULL means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. Two honest caveats: the value is cleared if a suspended match resumes (no record of the pause survives), and for Retired/Walk Over the withdrawing player is reported in withdrew, where derivable.
event_status_updated_atstring or nullWhen 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_doublesbooleanDoubles 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.
drawstring 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_timestring or null
playersobject
scoreobject or null
winnerinteger or nullCompleted 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.
withdrewinteger or nullCompleted 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.
analysisobjectULTRA only (absent below)
marketobject or nullPRO+ only (absent below)

Analysis

Analysis schema — fields
FieldTypeDescription
thesisobject or null
profileobject or null

Market

Market schema — fields
FieldTypeDescription
idinteger
questionstring or null
statusstring or null (active, resolved, closed, null)
volumenumber or null
liquiditynumber or null
end_datestring or null
pricesarray of objectPrices 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.

Price schema — fields
FieldTypeDescription
sideinteger or null1 = p1's outcome, 2 = p2's
bidnumber or null
asknumber or null
midnumber or null
spreadnumber or null
price_sourcestring or nullFeed category, e.g. prediction_market
syntheticboolean or nulltrue = bid/ask estimated from mid (not a live order book); false = real top-of-book; null = unknown (older ticks)
timestampstring or null

Event

Event schema — fields
FieldTypeDescription
typestring (break, set_won, game_won, momentum_run)
playerinteger or null (1, 2, null)
timestampstring or null

Fixture

Fixture schema — fields
FieldTypeDescription
idinteger
event_datestring or null
start_timestring or nullScheduled 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_idinteger or nullOur player id, when the participant is in our roster (exact-key resolution, never a name match). Null otherwise — names are always present regardless.
player2_idinteger or nullAs player1_id, for the second participant.
tourstring or nullThe 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.
tournamentstring or null
roundstring or null
round_codestring 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.
surfacestring or null
player1_namestring or null
player2_namestring or null
statusstring or null

ModelProfile

One model win-probability profile. On the history tape these are the profiles produced during the match, oldest first.

ModelProfile schema — fields
FieldTypeDescription
win_probability_p1number or null
expected_closenessnumber or null
volatility_ratingstring or null (low, med, high, null)
stagestring or null (pregame, live, null)Did this forecast already see the match? null = UNKNOWN, never pregame.
model_versionstring or nullThe prompt-scaffold version this forecast was produced under. Null on profiles written before the stamp existed.
key_factorsarray or null
created_atstring 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. A null timestamp is the reliable row-level marker of a reconstructed row; the model fields alone are not, 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.

HistoryTapeRow schema — fields
FieldTypeDescription
setsarray of integer
gamesarray of array of integer
pointsarray of string
serverinteger or null (1, 2, null)
is_tiebreakboolean
win_probability_p1number or null
dangernumber or null
timestampstring or null
point_winnerinteger or null (1, 2, null)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.

HistoryTape

The full per-match history tape (BASIC, or Historical Data API Starter+). tape rows INCLUDE the model fields win_probability_p1 and danger per point — on this product they are part of the tape itself, not an ULTRA upsell.

HistoryTape schema — fields
FieldTypeDescription
matchobjectMatch metadata with the final score embedded.
tapearray of objectChronological score sequence — see HistoryTapeRow.
tiebreaksarray or nullPer-set tiebreak final scores from OBSERVED states only, aligned to the sets of the final scoreline: {"p1", "p2"} for a 7-6 set whose observed maximum tiebreak state is a valid terminal shape (max >= 7, margin >= 2), null per set otherwise — a breaker whose closing point the feed skipped reads null rather than an under-report. Null when the match has no 7-6 set. Present on raw and clean alike.
profilesarray of objectModel profiles produced during the match, oldest first.
metaobject

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.

PointsMeta schema — fields
FieldTypeDescription
completebooleanThe contract bit: 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, it reaches a finished legal final scoreline (or the match ended early — retirement/walkover), and it is not known-truncated.
completenessnumber or nullLegal 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.
rowsintegerRows measured — always equals meta.rows.
transitions_legalintegerTransitions attributable to exactly one side winning one point.
transitions_totalintegerAll row-to-row transitions (rows − 1).
server_rowsintegerRows carrying a server.
starts_at_lovebooleanThe first row is the 0-0 opening state.
ends_at_finalbooleanThe last row is a complete, legal final scoreline.
basisstring (raw, clean, recon)Which serve was measured: raw | clean (the default read, as returned) or recon (the whole reconstruction, ?points=complete).
available_completeboolean or null?points=complete responses only. The nightly ledger's verdict on whether a point-complete reconstruction exists for this match: true/false when measured, null when not yet measured — never guessed.

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. 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=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.

HistoryPackage schema — fields
FieldTypeDescription
periodstringCalendar month, YYYY-MM — or the bare year YYYY on the yearly rally/archive/elo kinds.
statusstring (ready)Only built months are listed or served.
match_countinteger or null
row_countinteger or null
filesarray of objectOne entry per downloadable format.
built_atstring or null
kindstring (tape, rankings, rally, archive, elo)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.

CoverageRollup

The measured completeness rollup — what the archive HOLDS, per tour × draw bucket, as of the artifact's own build clock.

CoverageRollup schema — fields
FieldTypeDescription
as_ofstringEqual to built_at, verbatim — the artifact's own clock, and the date to quote with every number in this response.
built_atstringWhen the rollup was built (UTC).
ledger_max_computed_atstring or nullThe newest underlying per-match measurement in the completeness ledger.
methodstringThe full measurement rule for point_complete, in one paragraph — every number carries its own definition.
bucketsobjectOne CoverageBucket per tour × draw bucket (atp_singlesjuniors_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.
totalsobjectThe five verifiable numbers for one bucket.

CoverageBucket

The five verifiable numbers for one bucket.

CoverageBucket schema — fields
FieldTypeDescription
completedintegerCompleted matches in the bucket.
any_tapeintegerMatches with at least one tape row on any basis.
point_completeintegerMeasured point-complete on the BEST basis — what ?points=complete can serve.
complete_on_default_readintegerPoint-complete on the default read alone. The difference against point_complete is the set of matches where ?points=complete is load-bearing.
sharenumber or nullpoint_complete / completed. Null on an empty bucket.

HistoryMatch

HistoryMatch schema — fields
FieldTypeDescription
idinteger
tournamentstring
tourstring 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_idstring or nullStable tournament identity — one id per tournament × event type, stable across seasons. Joins GET /tournaments/{tournamentId}. Null on matches ingested before the catalogue covered their tournament. (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.)
surfacestring or null (hard, clay, grass, null)
indoorboolean
formatstring or null (BO3, BO5, null)
roundstring or null
round_codestring or null (F, SF, QF, R16, R32, R64, R128, RR, BR, Q, Q1, Q2, Q3, Q4, ER, null)The round in the archive's controlled vocabulary, normalized from the free-text label above (Q = qualifying round the feed does not number). This is the field to branch on; it matches /history/archive/matches?round= exactly. Null when the label is unrecognised — never guessed.
statusstring (upcoming, live, completed, cancelled)
event_statusstring or null (Retired, Cancelled, Walk Over, Postponed, Interrupted, null)How the match ended (or paused) when it did not run its course: retirement, cancellation, walkover, postponement, or an in-play suspension (Interrupted — rain/darkness/medical; the match is paused, not over). NULL means the match completed normally OR the outcome was never resolved — the feed does not distinguish those. Two honest caveats: the value is cleared if a suspended match resumes (no record of the pause survives), and for Retired/Walk Over the withdrawing player is reported in withdrew, where derivable.
event_status_updated_atstring or nullWhen 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_doublesbooleanDoubles 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.
drawstring 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_timestring or null
playersobject
scoreobject or null
winnerinteger or nullCompleted 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.
withdrewinteger or nullCompleted 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.
tapeobjectWhat point-by-point data we hold for this match.

Usage

Usage schema — fields
FieldTypeDescription
principalstringOpaque ref to your own key
tierstring (free, basic, pro, ultra)
base_tierstringSubscription tier; equals tier unless a temporary grant is active
tier_expires_atstring or nullWhen a temporary tier grant reverts, else null
channelstring
limitsobject
todayobject
historyarray of objectLast 30 days, oldest first
as_ofstring

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.

MatchStatisticsSide schema — fields
FieldTypeDescription
measuredobjectMeasured 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_playedinteger
service_games_woninteger
hold_pctinteger or nullnull when no service game was played — never 0, so a present 0 is a real measured zero
return_games_playedinteger
return_games_woninteger
break_pctinteger or null
break_points_facedinteger
break_points_savedinteger
break_points_saved_pctinteger or null
break_points_playedinteger
break_points_convertedinteger
break_points_converted_pctinteger or null
service_points_playedinteger
service_points_woninteger
service_points_won_pctinteger or null
return_points_playedinteger
return_points_woninteger
return_points_won_pctinteger or null
points_playedinteger
points_woninteger

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.

MatchStatisticsMeasured schema — fields
FieldTypeDescription
acesinteger or null
break_points_woninteger or null
double_faultsinteger or null
first_return_points_woninteger or null
first_return_points_won_ofinteger or null
first_return_points_won_pctinteger or null
max_games_in_rowinteger or null
max_points_in_rowinteger or null
points_woninteger or null
return_points_woninteger or null
second_return_points_woninteger or null
second_return_points_won_ofinteger or null
second_return_points_won_pctinteger or null
service_games_woninteger or null
service_points_woninteger or null
break_points_savedinteger or null
break_points_saved_ofinteger or null
break_points_saved_pctinteger or null
first_serve_points_woninteger or null
first_serve_points_won_ofinteger or null
first_serve_points_won_pctinteger or null
first_serves_ininteger or null
first_serves_in_ofinteger or null
first_serves_in_pctinteger or null
games_woninteger or null
second_serve_points_woninteger or null
second_serve_points_won_ofinteger or null
second_serve_points_won_pctinteger or null
second_serves_ininteger or null
second_serves_in_ofinteger or null
second_serves_in_pctinteger or null
service_games_playedinteger or null
tiebreaks_woninteger or null
backhand_errorsinteger or nullForced errors off the backhand.
backhand_unforced_errorsinteger or nullUnforced errors off the backhand.
backhand_winnersinteger or nullWinners off the backhand.
drop_shot_unforced_errorsinteger or nullUnforced errors on the drop shot.
drop_shot_winnersinteger or nullWinners on the drop shot.
errors_totalinteger or nullThe 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_errorsinteger or nullForced errors off the forehand.
forehand_unforced_errorsinteger or nullUnforced errors off the forehand.
forehand_winnersinteger or nullWinners off the forehand.
groundstroke_errorsinteger or nullforehand_errors + backhand_errors — a rollup of the two, not an additive extra.
groundstroke_unforced_errorsinteger or nullUnforced errors off the ground strokes.
groundstroke_winnersinteger or nullWinners off the ground strokes.
lob_unforced_errorsinteger or nullUnforced errors on the lob.
lob_winnersinteger or nullWinners on the lob.
overhead_errorsinteger or nullForced errors on the overhead — the upstream's overhead-stroke count.
overhead_winnersinteger or nullWinners on the overhead.
return_errorsinteger or nullForced errors on the return.
return_winnersinteger or nullWinners on the return.
unforced_errors_totalinteger or nullThe total of unforced errors. Total errors = errors_total + unforced_errors_total.
volley_unforced_errorsinteger or nullUnforced errors on the volley.
volley_winnersinteger or nullWinners on the volley.
winners_totalinteger or nullTotal 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.

MatchStatisticsFreshness schema — fields
FieldTypeDescription
measured_divergenceobject or nullNull when the families agree. Otherwise why the measured values were withheld, with both match states.
derivedobject
measuredobject

MatchStatisticsFamily

MatchStatisticsFamily schema — fields
FieldTypeDescription
coveragestring (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_ofstring or null
age_secondsinteger or null
describesobject or nullThe match state these statistics describe, per upstream. age_seconds says when we fetched; this says WHAT we fetched. Null when unavailable.

MatchStatistics

MatchStatistics schema — fields
FieldTypeDescription
match_idinteger
coveragestring (live, final, stale, none, diverged)
as_ofstring or nullWhen the underlying record was last updated (UTC)
age_secondsinteger or nullBehind the newest SCORE row, not the wall clock
games_countedinteger
tiebreak_games_excludedintegerTiebreaks are excluded — the live record collapses a whole tiebreak onto one entry
inconsistent_games_excludedintegerGames whose recorded outcome is neither a legal hold nor a legal break
sets_coveredarray of integer
freshnessobjectPer-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.
detailstringPresent only when coverage is none
playersobject 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.

RankingRecord schema — fields
FieldTypeDescription
player_idinteger or nullNull 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_namestring or nullThe name as the ranking publisher printed it — present on listing rows (where player_id may be null), absent on per-player records.
systemstring (atp, wta, itf_jt, itf_mt, itf_wt, utr, elo)
tourstring or null
rankinteger or nullNull 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.
pointsinteger or nullNull for UTR and for elo — both are ratings, and neither has points.
surfacestring (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.
matchesinteger or nullelo 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_rankinteger or nullThe rank at the immediately preceding snapshot week (ATP/WTA only; null when no prior week is held, and always null for ITF/UTR).
rank_movementinteger or nullThe circuit's own signed weekly movement (ITF systems only; null elsewhere).
ratingnumber or nullUTR 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_datestring or nullThe 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_atstring or null

RankingListMeta

RankingListMeta schema — fields
FieldTypeDescription
limitinteger
offsetinteger
countinteger
totalinteger or nullSize of the whole filtered set. Null when it cannot be counted cheaply.
has_morebooleanMore results exist beyond this page. Read this rather than comparing count to limit.
coverageobjectWhat 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

Webhook schema — fields
FieldTypeDescription
idinteger
urlstring
eventsarray of string (score, break_point)
enabledbooleanSet to false automatically after 25 consecutive delivery failures — delete and re-register to resume.
created_atstring or null
last_delivery_atstring or null
consecutive_failuresinteger
last_errorstring or null
secretstringPresent ONLY on the 201 registration response — shown exactly once
secret_notestring

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

BreakPoint schema — fields
FieldTypeDescription
typestring
match_idinteger
serverinteger or null (1, 2, null)
returnerinteger or null (1, 2, null)
break_pointsinteger or nullHow many break points are live at once (1-3).
setinteger or null
gameinteger or null
pointstring or null
win_probability_p1number or null
prob_swingnumber or null
server_side_favouredboolean or null
tsstring 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.

BreakPointResult schema — fields
FieldTypeDescription
typestring
match_idinteger
serverinteger or null (1, 2, null)
outcomestring (held, broken)
win_probability_p1_afternumber or null
tsstring 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.

LivePoint schema — fields
FieldTypeDescription
seqintegerPer-match monotonic position 1..N — dedup and resume on this.
setinteger or null
gameinteger or nullGame within the current set — 13 for a 6-6 tiebreak.
numberinteger or nullPoints already recorded in this game at this state — 0 for a game's opening state.
tiebreakboolean
serverinteger or null (1, 2, null)Server of the point played FROM this state; null when the feed omits it.
winnerinteger or null (1, 2, null)Who won the point that produced this state — null when not attributable to a single point, never guessed.
scoreobjectIn-game score AFTER the point ('0'/'15'/'30'/'40'/'AD'; the running tiebreak count as integer strings when tiebreak is true).
setsarray of integer or null
gamesarray of array of integer[games_p1, games_p2]; each a per-set list, frozen at the pre-breaker score during a tiebreak.
tsstring or nullCapture 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.

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.

MatchPoints schema — fields
FieldTypeDescription
match_idinteger
pbp_coveragestring (point, game)point = this match has a true per-point stream; game = only the snapshot score path covers it (points is empty — an answer, not an error).
qualitystring (clean, revised)revised = the upstream feed rewrote an already-served prefix at least once during this match; served rows are never edited.
covers_from_startboolean or nullWhether 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").
pointsarray of object
last_seqintegerThe resume cursor — pass as after_seq to continue.
has_moreboolean
basisstring (live, reconstruction)Which base served this page. live = the persisted live stream rows (every live match, and any completed match without a measured-complete recorded sequence); reconstruction = the complete recorded point sequence of a finished match, projected into point frames at read time — includes the match-closing point, seq contiguous 1..N, ts null on every frame. Completeness beats the partial live capture wholesale; the two bases are never interleaved.

PointFrame

WebSocket point frame (ULTRA, opt-in via signals: ["points"]; also the webhook point event body): one PLAYED point of a subscribed match. Ordered per match by seq; dedup/resume on seq. Best-effort, NO replay — catch up via GET /matches/{matchId}/points?after_seq=.

PointFrame schema — fields
FieldTypeDescription
typestring
match_idinteger
pointobjectOne 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_coveragestring (point, game)
qualitystring (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.

Tournament schema — fields
FieldTypeDescription
idstringThe stable id Match.tournament_id joins.
namestring or null
tourstring or null (atp, wta, challenger, itf, juniors, null)
surfacestring or null (hard, clay, grass, null)
indoorboolean
citystring or nullHost city, from a curated table — null where not curated.
countrystring or nullHost 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.)
categorystring 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.

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.

ArchiveMatch schema — fields
FieldTypeDescription
idinteger
source_idstring
tourstring (atp, wta)
levelstring or nullSource tier code (G/M/A/F/D/C/O, or a futures category code as published).
tournamentstring or null
surfacestring or null
draw_sizeinteger or null
event_datestring or nullTournament START date — per-match dates do not exist in this era's records, and we do not invent them.
roundstring or null
best_ofinteger or null
minutesinteger or null
winnerobjectOne participant of an archive result, as the corpus records them.
loserobjectOne participant of an archive result, as the corpus records them.
scorestring or nullThe final score as published, e.g. "6-4 7-6(5)", "6-3 RET", "W/O".
outcomestring or null (completed, retired, walkover, default, abandoned, null)Parsed from the score's own vocabulary; null when unparseable — never guessed.
statsobject or nullDetail 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.

ArchivePlayer schema — fields
FieldTypeDescription
namestring or null
handstring or null
countrystring or null3-letter code, same vocabulary as player.country.
rankinteger or nullThe player's rank AT THE TIME of the match, as published.
seedinteger or null
player_idinteger or nullThe corpus person id — joins /history/archive/players within the same tour. NOT a roster player id.
height_cminteger or null
agenumber or nullAge at the time of the match, as the corpus records it.
entrystring or nullDraw 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.

ArchivePlayerBio schema — fields
FieldTypeDescription
idinteger
tourstring (atp, wta)
namestring or null
handstring or null
dobstring or null
countrystring or null
height_cminteger or null
career_high_rankinteger or null
career_high_datestring or nullThe 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.

ArchiveCareer schema — fields
FieldTypeDescription
playerobject
spanobject
recordobject
by_yeararray of object
serveobjectSummed serve statistics + derived ratios; null ratios where the denominator is zero.

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.

HeadToHead schema — fields
FieldTypeDescription
playersobject or nullThe resolved names ({"p1":{"name"}, "p2":{"name"}}); null when no player matches the fragments.
totalsobject
by_surfaceobjectPer-surface win split of the decided meetings; keys are surface names plus 'unknown'.
meetingsarray of objectNewest 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.
statsobject or nullULTRA 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.

RallyMatch schema — fields
FieldTypeDescription
rally_match_idintegerThe id this product is keyed on.
source_idstring
match_idinteger or nullOUR match id, when the charted match is also one we hold. Null otherwise — most charted matches predate our own collection.
datestring or null
tournamentstring or null
roundstring or null
surfacestring or null
genderstring or null (M, W, null)
best_ofinteger or null
playersarray of object
pointsintegerCharted points in this match.
points_parsedintegerHow 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.

RallyPoint schema — fields
FieldTypeDescription
pointinteger
setarray of integer or null
gamesarray of integer or null
scorestring or nulle.g. '30-40'.
gameinteger or null
is_tiebreakboolean
serverinteger or null (1, 2, null)
point_winnerinteger or null (1, 2, null)
rawstring or nullThe charter's shot string; both serves joined by ';' when the first was a fault.
parsedboolean
serve_numberinteger or null (1, 2, null)
serve_directionstring or null (wide, body, down_the_t, null)
rally_lengthinteger or nullStrokes including the serve. An ace is 1, a double fault 0.
outcomestring 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_locationstring or null (net, wide, deep, wide_and_deep, null)
ending_strokestring or null
ending_wingstring or null
is_aceboolean
is_double_faultboolean
is_serve_and_volleyboolean
shotsarray of object

RallyShot

One stroke. Shots are numbered from the serve: serve 1, return 2, the server's next ball 3.

RallyShot schema — fields
FieldTypeDescription
numberinteger
codestringThe charter's raw code, e.g. 'f'.
strokestring or null (serve, groundstroke, slice, volley, half_volley, swinging_volley, overhead, drop_shot, lob, trick, unknown, null)
wingstring or null (forehand, backhand, null)The side it was struck FROM.
directionstring or null (forehand_side, middle, backhand_side, null)Where the ball was sent.
depthstring or null (shallow, mid, deep, null)
positionstring or null (approaching, at_net, baseline, null)