npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

livetennisapi

v1.9.0

Published

Official JavaScript/TypeScript client for the Live Tennis API — real-time tennis scores, players and fixtures over REST and WebSocket, for ATP, WTA, Challenger, ITF and juniors. Market prices, statistics, rankings and model win-probability are available o

Readme

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.

CI npm types license

Documentation · Get a free API key


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, with points: true, the live point feed with built-in REST resume, and, with signals: true, the signal events (break points and model/market divergence).
  • LiveScoreStream — the native /ws feed. One plain WebSocket, but it rides shared, capacity-capped infrastructure — keep it for short-lived tooling and prefer PushStream for 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 WebSocket is 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_date is the tournament START date — per-match dates do not exist in this era's records, and none are invented.
  • Names are the keys for getH2H and getArchiveCareer (archive people have no roster ids). A fragment matching more than one player is refused with a 400 ambiguous_name carrying the candidate list in err.body.candidates — disambiguate and retry.
  • meetings[].winner in an H2H is 1|2 of your request (p1/p2 as 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 | — |

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.