livetennisapi
August 18, 2026 · View on GitHub
livetennisapi
Official JavaScript / TypeScript client for the Live Tennis API.
Real-time tennis scores, players, rankings, match-winner market prices and model win-probability — for ATP, WTA, Challenger, ITF and juniors, over REST and WebSocket.
Install
npm install livetennisapi
Zero runtime dependencies. Uses the platform fetch and WebSocket, so it runs
unchanged on Node 18+, Deno, Bun, Cloudflare Workers and the browser.
CORS is enabled on the API (Access-Control-Allow-Origin: *), so browser calls
work directly. Caveat: a FREE key in browser code is acceptable; a paid key never
is — anyone can read it from the page. Keep paid keys server-side.
Use
import { LiveTennisAPI } from 'livetennisapi';
const client = new LiveTennisAPI({ apiKey: 'twjp_…' }); // or $LIVETENNISAPI_KEY
const { data } = await client.listMatches({ status: 'live' });
for (const match of data) {
console.log(match.tournament, match.players?.p1?.name, 'vs', match.players?.p2?.name);
}
Fully typed — every response, every option, every error.
Command line
No install needed:
$ npx livetennisapi live
live matches (3)
ID Tournament Rd Players Score
18953 ATP Wimbledon R16 *Alcaraz / Sinner 6-4 3-6 2-1 (40-30)
$ npx livetennisapi match 18953
$ npx livetennisapi players djokovic
$ npx livetennisapi watch --match 18953
Live score feed (ULTRA)
Two streamers, same score data, different transports:
PushStream— the high-fan-out push feed, recommended for continuous / production streaming: no shared connection ceiling, built for scale. Score frames — plus, withpoints: true, the live point feed with built-in REST resume, and, withsignals: true, the signal events (break points and model/market divergence).LiveScoreStream— the native/wsfeed. One plain WebSocket, but it rides shared, capacity-capped infrastructure — keep it for short-lived tooling and preferPushStreamfor anything long-running.
Push streamer (PushStream) — start here
The push feed rides a high-fan-out push endpoint (Centrifugo). The tiny protocol subset is built into this package, so no extra dependency and the same ergonomics as the native streamer:
import { PushStream } from 'livetennisapi';
const stream = new PushStream({ apiKey: 'twjp_…' }); // every live match
// …or follow specific matches: new PushStream({ apiKey, matches: [18953] })
for await (const update of stream) {
if (update.type === 'score') console.log(update.match_id, update.score?.sets);
}
Score frames nest their payload under .score — the same object the REST
score reads return: { type: 'score', match_id, score: { sets, games, points, server, is_tiebreak, timestamp, win_probability_p1, danger } }. A null
model field means the model had no output for that state, not a missing
feature. Score frames are complete-state and best-effort: a missed frame
self-corrects on the next one, so there is nothing to replay. The stream
mints a short-lived token via getWsToken() before every connection (a fresh
one on every reconnect), reconnects with exponential backoff, and
re-subscribes — and throws the SDK's normal errors instead of retrying a bad
key, an insufficient tier (UpgradeRequired, the feed is ULTRA) or a
disabled feed. An invalid connect token (the server closes the socket with
code 3500/3501, never a reply error) surfaces as Unauthorized — not an
endless reconnect — and a silently-dead connection is torn down and
re-established when the server's advertised ping cadence (~25s) goes quiet
for ~2 intervals.
Frames are dispatched by their type, so a new frame kind published on a
subscribed channel arrives without a client update. New channel families do
need naming: the point feed lives on its own channels (point:match:{id} /
point:slate) — pass points: true (the point-feed section below) — and the
signal families on theirs (signal:match:{id} / signal:slate) — pass
signals: true (the "Signal events" section below). Both are subscribed from the /ws-token
mint's own advertised vocabulary, never guessed; the channels: […]
option remains the verbatim escape hatch for families newer than this SDK.
Bringing your own Centrifugo-protocol client instead? Mint the raw token yourself:
const { token, ws_url, channels } = await client.getWsToken();
// channels.slate === 'slate:all', channels.match === 'match:{match_id}'
// Mint a fresh token on every reconnect — never reuse one.
Native streamer (LiveScoreStream)
The native /ws feed carries the same score frames over one plain WebSocket —
but it is shared, capacity-capped infrastructure (a concurrent-connection
ceiling per key and per server), so keep it for short-lived tooling:
import { LiveScoreStream } from 'livetennisapi';
const stream = new LiveScoreStream({ apiKey: 'twjp_…' });
for await (const update of stream) {
if (update.type === 'score') console.log(update.match_id, update.score?.sets);
}
Reconnects with exponential backoff and re-subscribes automatically. Heartbeats are consumed internally, so you only see real score changes. It deliberately does not reconnect on a bad key or insufficient tier — those throw immediately instead of retrying forever.
On Node 22+ the global
WebSocketis used. On Node 18–20,npm install ws.
Signal events: break points and divergence
Signals are derived events, delivered the moment they occur. On the native
feed, opt in with signals: ['break_point']: the stream yields a BreakPoint
the moment a break point arises and a BreakPointResult when it resolves,
alongside the usual ScoreUpdate — narrow on frame.type:
import { LiveScoreStream } from 'livetennisapi';
const stream = new LiveScoreStream({ apiKey: 'twjp_…', signals: ['break_point'] });
for await (const frame of stream) {
if (frame.type === 'break_point') {
console.log(`BREAK POINT on ${frame.match_id}: p${frame.returner} has ${frame.break_points}`);
} else if (frame.type === 'break_point_result') {
console.log(` -> ${frame.outcome} (p1 win prob now ${frame.win_probability_p1_after})`);
} else if (frame.type === 'score') {
console.log(frame.match_id, frame.score?.sets);
}
}
On the push feed, pass signals: true: the stream subscribes the signal
channels (signal:match:{id} per requested match, or the signal slate) from
the mint's own advertised vocabulary — an unadvertised vocabulary means the
server's signal feed is off and throws ServiceUnavailable, never a silent
empty feed. The frames are the same events, verbatim: break_point,
break_point_result, and — where the server's divergence flag is on —
divergence (a Divergence: the model and the match-winner market
disagreeing beyond the server's threshold, direction naming the side the
model rates above the market).
const stream = new PushStream({ apiKey: 'twjp_…', signals: true });
for await (const frame of stream) {
if (frame.type === 'break_point') console.log('break point on', frame.match_id);
else if (frame.type === 'divergence') console.log(frame.match_id, frame.gap, frame.direction);
}
Signals are events with no replay and no seq: a subscriber that joins
mid-break-point does not receive the onset (a fresh native connection
does re-announce in-progress break points; the push channels do not), and
there is no resume machinery for them — unlike points, a missed signal is
not recoverable over REST.
With no signals either stream behaves exactly as before — score frames only.
Both the feeds and their fields are ULTRA-only. A runnable example lives in
livetennisapi-starter-node.
The live point feed (ULTRA, server-gated)
One frame per committed point: { type: 'point', match_id, point, pbp_coverage, quality } — the point nested under .point, exactly as score frames nest
theirs under .score. Its spine is point.seq: per-match, monotonic and
gapless (1..N), which makes it the dedup key and the resume cursor in one
field. Unlike score frames, point frames are not complete-state — each one
is a distinct event, so a missed frame is a missed point. The gapless seq is
what lets you detect that, and what the SDK's resume machinery repairs.
The feed is server-gated on top of ULTRA: points are served only where the
server's point gate is on and the plan includes points. The REST endpoint
answers 400 points_disabled and the /ws-token mint simply does not
advertise the point channels — both are the server's honest refusal, and this
SDK surfaces them as such (BadRequest with errorCode: 'points_disabled',
ServiceUnavailable from PushStream) instead of retrying a closed door.
Over REST — one page of committed points (at most 500), or the iterator that follows the server's own cursor across pages:
const page = await client.getMatchPoints(18953); // PointsPage
// resume exactly after a point you already hold:
const delta = await client.getMatchPoints(18953, { after_seq: 120 });
for await (const point of client.iterateMatchPoints(18953)) { // LivePoint
console.log(point.seq, point.winner, point.score);
}
On the native feed, opt in with signals: ['points'] and narrow on
frame.type === 'point', alongside score (and break-point) frames.
On the push feed, pass points: true: the stream subscribes the point
channels from the mint's own advertised vocabulary (point:match:{id} per
requested match, or the point slate), and adds what an event feed needs —
pointsResume (default on) keeps a per-match seq cursor that survives
reconnects, catches up over REST on every (re)connect (replayed points arrive
before any live frame), drops duplicates, and repairs a mid-stream gap over
REST before yielding the frame that revealed it (onGap(matchId, expectedSeq, gotSeq) observes the repair). Set pointsResume: false to take
the raw frames with no REST traffic.
const stream = new PushStream({
apiKey: 'twjp_…',
matches: [18953],
points: true,
onGap: (id, expected, got) => console.warn(`gap on ${id}: ${expected}…${got - 1}`),
});
for await (const frame of stream) {
if (frame.type === 'point') console.log(frame.point?.seq, frame.point?.winner);
}
Slate caveat: cursors exist per match, so on the point slate a reconnect
catches up only matches the stream had already seen before the drop; a match
that went live entirely inside the outage back-fills (from seq 1) when its
first live frame arrives. Follow specific matches when that matters.
Read the page honestly: pbp_coverage: 'game' rows are game-grain commits —
the feed's floor where per-point data never existed upstream — and an absent
covers_from_start means "not stated" (an older server), never "no".
Tiers
| FREE | BASIC | PRO | ULTRA | |
|---|---|---|---|---|
listMatches getMatch getMatchScore | ✅ | ✅ | ✅ | ✅ |
searchPlayers getPlayer listFixtures | ✅ | ✅ | ✅ | ✅ |
listTournaments getTournament | ✅ | ✅ | ✅ | ✅ |
listCompletedMatches getMatchTape (history) getHistoryCoverage | — | ✅¹ | ✅ | ✅ |
listArchiveMatches getArchiveMatch listArchivePlayers getArchiveCareer getH2H (results archive · head-to-head) | — | ✅¹ | ✅ | ✅ |
listMatchEvents listMarkets getMarketPrices | — | — | ✅ | ✅ |
listRankings (rank-ordered listing) | — | — | ✅ | ✅ |
listHistoryPackages getHistoryPackage (bulk downloads)² | — | — | ✅ | ✅ |
listRankings (per-player as-of records) | — | — | — | ✅ |
getMatchStatistics (in-play statistics) | — | — | — | ✅ |
listRallyMatches getRallyMatch getMatchRally getChartingPlayer getChartingMatch (shot-by-shot) | — | — | — | ✅ |
getMatchAnalysis, win_probability_p1 / danger, LiveScoreStream PushStream getWsToken (streaming) | — | — | — | ✅ |
getMatchPoints iterateMatchPoints, the points signal / option (live point feed) | — | — | — | ✅³ |
¹ Also unlocked by any History plan, which works on top of a FREE key.
² kind: 'rally' | 'rankings' packages and the year archive listing need ULTRA.
³ Server-gated on top of ULTRA: served only where the point gate is on and the
plan includes points — the refusal is points_disabled / an unadvertised
point-channel vocabulary, and this SDK never retries it.
Quotas
| Tier | Requests/min | Requests/day | Price |
|---|---|---|---|
| FREE | 30 | 100 | $0 |
| BASIC | 60 | 1,000 | $9.99/mo |
| PRO | 300 | 10,000 | $29.99/mo |
| ULTRA | 600 | 500,000 | $99.99/mo |
At 100/day, a free key polling faster than every ~15 minutes will spend its
allowance before the day ends — an always-on dashboard belongs on BASIC. Every
response carries X-RateLimit-Limit / -Remaining / -Reset headers; a 429
carries Retry-After, and the client retries those for you (per-minute 429s
only — see below).
Calling above your tier throws UpgradeRequired, which tells you which tier you need:
import { UpgradeRequired } from 'livetennisapi';
try {
await client.getMatchAnalysis(18953);
} catch (err) {
if (err instanceof UpgradeRequired) console.log(err.requiredTier); // 'ULTRA'
}
Errors
| Class | When |
|---|---|
Unauthorized | 401 — key missing, unknown, or disabled |
UpgradeRequired | 403 — valid key, tier too low (has .requiredTier) |
NotFound | 404 — no such resource, or no data yet |
RateLimited | 429 — has .retryAfter (seconds); a daily 429 also has .resetsAt, the absolute ISO instant the allowance returns |
AbuseThrottled | 429 abuse_throttled — a ~24h block for chronic over-cap use; has .retryAtEpoch. Fix the retry loop |
ServerError / ServiceUnavailable | 5xx |
APIConnectionError / APITimeoutError | never reached the API |
All extend LiveTennisAPIError (AbuseThrottled extends RateLimited, so an
existing catch keeps working).
Requests retry on per-minute 429 and 5xx only, honouring Retry-After with
exponential backoff and jitter. Other 4xx are never retried — a bad key or an
unentitled tier cannot start working, and retrying only burns rate limit. Nor
are the two 429s retrying cannot fix: a daily 429 (nothing lifts before
.resetsAt) and abuse_throttled (the block that counting retries earned).
The results archive (1968–2022) and head-to-head
Two halves, one product: the results archive — a licensed corpus of
completed-match results, ATP and WTA, main draws, qualifying and the
ITF/futures tiers, 1968 through 2022 — and the point-by-point tape
(2023→now) behind listCompletedMatches. The archive ends exactly where the
tape begins, so no match is ever served from two datasets.
// Winner/loser-shaped results with ranks and seeds AT THE TIME of the match.
const { data } = await client.listArchiveMatches({ tour: 'atp', name: 'borg', round: 'F' });
// Cross-era head-to-head — archive + our own completed matches, in one call.
const h2h = await client.getH2H('federer', 'nadal');
console.log(h2h.totals, h2h.by_surface);
// Career aggregates: W-L by surface/level/year, titles, summed serve stats.
const career = await client.getArchiveCareer('borg');
Three things worth knowing before you lean on it:
event_dateis the tournament START date — per-match dates do not exist in this era's records, and none are invented.- Names are the keys for
getH2HandgetArchiveCareer(archive people have no roster ids). A fragment matching more than one player is refused with a400 ambiguous_namecarrying the candidate list inerr.body.candidates— disambiguate and retry. meetings[].winnerin an H2H is 1|2 of your request (p1/p2as you passed them), not of the underlying match row.
The tape, statistics, rankings and shot-by-shot data
Everything the 1.4.0 surface adds, in one place:
// The point-by-point tape for one match — works on a LIVE match too. BASIC.
// sequence: 'clean' collapses corrections to one row per score state and is
// the only sequence that carries point_winner. Check meta.coverage before
// backtesting; tiebreaks holds per-set tiebreak final scores.
const tape = await client.getMatchTape(18953, { sequence: 'clean' });
// In-play statistics — aces, serve split, hold/break %, break points. ULTRA.
// Two families: derived (from the tape) and measured (counted upstream).
// Absent measured fields are omitted, never zero-filled.
const stats = await client.getMatchStatistics(18953);
// Point-in-time rankings. Listing mode (PRO): the full published table for
// one system. Per-player mode (ULTRA): the record in force at as_of.
// Rows carry previous_rank (ATP/WTA) for week-on-week movement.
const table = await client.listRankings({ system: 'atp', limit: 100 });
const asOf = await client.listRankings({ player: 925, as_of: '2026-07-01' });
// Our surface-aware Elo rating rides the same endpoint as system: 'elo'.
// It is NEVER implied — omitting `system` returns published rankings only —
// and its leaderboard requires `tour` (ratings are computed per tour).
// surface / archive_player / min_matches / activity_weeks shape the board.
const elo = await client.listRankings({ system: 'elo', tour: 'atp', surface: 'clay' });
// Shot-by-shot rally construction (Match Charting Project corpus). ULTRA.
// Its own id space, reaching back decades; getMatchRally() resolves OUR
// match ids and 404s with errorCode 'not_charted' when nobody charted it.
const charted = await client.listRallyMatches({ player: 'sampras' });
const rally = await client.getRallyMatch(charted.data[0]!.rally_match_id!);
// Career serve/return/clutch aggregate for one charted player. ULTRA.
const profile = await client.getChartingPlayer('graf', { gender: 'women' });
// Bulk packages — whole months of tape as JSONL/CSV. PRO+.
// kind: 'rally' | 'rankings' and the ?year= listing need ULTRA.
const packages = await client.listHistoryPackages();
const manifest = await client.getHistoryPackage('2026-07');
Singles vs doubles, and the coverage table
Every match carries draw: 'singles', 'doubles', or null — and the null
is an answer, not a gap. Team ties and team exhibitions never state which
discipline a rubber was, so those matches carry a null draw rather than a
guess (is_doubles remains, but cannot say "unknown"). The same word filters
listMatches(), listCompletedMatches(), listTournaments() and
listFixtures(); a null-draw row matches NEITHER filter value, so filtering
by singles and then by doubles is not everything.
const page = await client.listCompletedMatches({ tour: 'itf', draw: 'singles' });
const cov = await client.getHistoryCoverage(); // BASIC, or any History plan
console.log(cov.as_of, cov.totals);
for (const [name, bucket] of Object.entries(cov.buckets ?? {})) {
console.log(name, bucket.point_complete, 'of', bucket.completed);
}
getHistoryCoverage() states, per tour_draw bucket (atp_singles,
itf_doubles, …), how many completed matches we hold, how many carry any
tape, how many have a complete point-by-point tape available, and how many a
default read serves complete. As of 2026-08-18 the totals were: 174,393
completed matches; 171,808 (98.5%) with a tape; 91,318 (52.4%) with a
complete tape available — of which 81,196 (46.6% of completed) were served
complete on a default read. The buckets are why the draw split exists: on the
same date ITF singles was 51.1% point-complete while ITF doubles was
3.5% — a single itf number would have hidden both. The table is a built
artifact (as_of stamps the build): a 503 coverage_unavailable means it is
not built yet, not that coverage is zero.
Pagination
limit defaults to 50; the API rejects anything above 200. To walk everything —
paginate() clamps the page size for you:
for await (const player of client.paginate((p) => client.searchPlayers('nadal', p))) {
console.log(player.name);
}
Forward compatibility
The API ships additive changes within v1, so every response type carries an
index signature. A field added server-side is readable immediately, without
upgrading this package and without a type error:
const match = await client.getMatch(18953);
match.some_new_field; // readable — typed as `unknown`
The score shape (read this one)
games is player-major, not set-major:
score.games // [[6, 3, 2], [4, 6, 1]] -> 6-4, 3-6, 2-1
// ^p1 per set ^p2 per set
score.sets // [1, 1]
score.server // 1 | 2
Indexing it the other way is the most common mistake made against this API, so there are helpers:
import { gamesForSet, formatScore } from 'livetennisapi';
gamesForSet(score, 0); // [6, 4]
formatScore(score); // '6-4 3-6 2-1 (40-30)'
Authentication
Keys are twjp_… strings. The client sends Authorization: Bearer <key> by
default — the preferred form — or X-API-Key with authHeader: 'x-api-key'.
The native WebSocket feed authenticates with ?token=<key> on the handshake,
because the browser WebSocket API cannot set headers; over TLS it is encrypted
in transit. The push feed (PushStream) never puts the key on the socket at
all — it authenticates with a short-lived token minted over REST. Only
health() needs no key.
Configuration
new LiveTennisAPI({
apiKey: 'twjp_…', // or $LIVETENNISAPI_KEY
baseUrl: undefined, // or $LIVETENNISAPI_BASE_URL
timeout: 30_000,
maxRetries: 2,
authHeader: 'bearer', // or 'x-api-key'
fetch: undefined, // inject a custom fetch
});
Contributing
Issues and pull requests welcome at livetennisapi/livetennisapi-js.
npm install
npm run test:unit # unit tests, offline
LIVETENNISAPI_KEY=twjp_… npm run test:contract # verify against the live API
The contract tests assert the live API's real responses match these types. If the API and the spec disagree, that's a bug worth reporting.
Related
Everything in the Live Tennis API developer surface:
| Install | Source | Package | |
|---|---|---|---|
| Python client | pip install livetennisapi | repo | package |
| JavaScript / TypeScript client (this repo) | npm install livetennisapi | — | package |
| MCP server for LLM agents | npx livetennisapi-mcp | repo | package |
| Vercel AI SDK tools | npm install livetennisapi-ai | repo | — |
| Break-point starter — Python | — | repo | — |
| Break-point starter — Node | — | repo | — |
| Break-point starter — Go | — | repo | — |
- API reference — https://docs.livetennisapi.com (plain-HTML version, no JavaScript required)
- OpenAPI 3.1 specification — livetennisapi/openapi
- Get a free API key — https://livetennisapi.com/subscribe/free
- Products — https://livetennisapi.com/products
- Website and plans — https://livetennisapi.com
- Discord — https://discord.gg/f8WUZHgDm6
- GitHub org — https://github.com/livetennisapi
Affiliate program
Know developers who need tennis data? The affiliate program pays 51% recurring commission for the life of every referred subscription — 30-day cookie, and the people you refer get 10% off.
Licence
MIT — see LICENSE. Use of the API service is governed by the Terms of Service.