Live Tennis API DocsGet a free key
Explore the documentation

Live Tennis API · docs · version 1.13.60

Tennis WebSocket feed and webhooks

How do you receive tennis data as it happens instead of polling? Two ways, both ULTRA. GET /ws-token mints a short-lived token for the high-fan-out push feed, which streams score and point changes as they are written. POST /webhooks registers an outbound HTTP callback for the same events; GET /webhooks lists your registrations and never returns the signing secret. Webhooks require a direct key, not a marketplace one.

4 endpoints on this page. Base URL https://api.livetennisapi.com/api/public/v1; authenticate with the X-API-Key header. Plans involved: ULTRA. A free key needs no card.

The endpoints

Each one below lists its plan tier, every parameter, the response shape and an example. They are the same entries as in the full reference, which carries all 48 operations of the API on one page.

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, and since 2026-09-12 the stoppage family: medical_timeout_start/end, trainer_called/_end, toilet_break_start/end, stoppage_start/end, pause_start/end). A family absent from the response will not deliver for your key right now. Deliberately separate channels: a slate:all subscriber asked for score states and never starts receiving events unasked. Point and signal frames are events, not states — a missed point does NOT self-correct on the next frame; recover it via GET /matches/{matchId}/points?after_seq= and dedup by seq. WITHDRAWN STATES, AND HOW TO GET ONE BACK. This feed publishes every state we accept and runs no trust deference, while GET /matches/{matchId}/score runs the legality gate and can serve an older state than the newest row. So a state you receive here can later be one we do not stand behind, and when that happens a score_withdrawn frame follows the state's own score frame on the same channel. Eight keys, and no replacement score: ``json { "type": "score_withdrawn", "match_id": 4711, "kind": "withdrawn", "superseded_sequence": 118, "reason": "illegal_completed_set", "safe_to_resume": true, "ts": "2026-09-25T10:00:03.108Z", "published_at": "2026-09-25T10:00:03.115Z" } ` superseded_sequence is the sequence you hold and are to discard. The state to resume from is the one on the next score frame, or on a fresh GET /matches/{matchId}/score. kind is withdrawn (discard), deferred (the refused state may be early and is pending the next accept, so hold it) or withheld (nothing publishable, games is null). ts is when the judgement was made, published_at when this feed was handed the frame. A given (match, sequence) is withdrawn at most once per publishing process and there is no replay. A consumer that was disconnected recovers the judgement from the durable record, added 2026-09-25: GET /matches?withdrawn_since=<instant> for the matches that had one, GET /matches/{matchId}/withdrawals for the whole record on a match (each row carrying the frame exactly as it went out, and the sequence served in the refused state's place), and GET /matches/{matchId}/score?sequence=N to ask about the one sequence you hold. Records are kept 90 days. The live verdict` on the score read is the same judgement on demand, but it is recomputed over the newest state, so it stops answering for N as soon as the next state is accepted. The record does not.

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