---
title: "Mint a connection token for the high-fan-out push feed (ULTRA)"
method: GET
path: "/ws-token"
---

# Mint a connection token for the high-fan-out push feed (ULTRA)

`GET /ws-token`

## Request

- Base URL: `https://api.livetennisapi.com/api/public/v1`
- URL: `https://api.livetennisapi.com/api/public/v1/ws-token`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

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.

## Response `200`

Connection token, push URL and channel vocabulary

- object
  - `token` string
  - `expires_in` integer
  - `ws_url` string: The push WebSocket URL to connect to with the token.
  - `channels` object: Channel vocabulary — `match` is the per-match pattern (`match:{id}`), `slate` is the every-live-score channel (`slate:all`). A channel listed here will actually deliver for your key; one missing will not.
    - `match` string
    - `slate` string
    - `point_match` string: Per-match point events (`point:match:{match_id}`). Present only when the point feed is enabled server-side and the key's plan carries the point surface.
    - `point_slate` string: Every point frame (`point:slate`). Present only when the point feed is enabled server-side and the key's plan carries the point surface.

## Other responses

- `401`: Missing, unknown, or disabled credentials
- `403`: Your tier doesn't unlock this endpoint
- `429`: Rate limit exceeded (Retry-After header present). Three body shapes, told apart by `error` and `scope`: the per-MINUTE limit (`rate_limited`, with `upgrade_url`, `tier` and `price` naming the next tier up); the per-DAY quota (`rate_limited` with `scope: "day"`, `limit_per_day`, and `resets_at` — the absolute ISO instant the daily window resets); and `abuse_throttled` with `retry_at_epoch` — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.

---

[API](https://skmtc.dev/livetennisapi/apis/live-tennis-api.md) · [All operations](https://skmtc.dev/livetennisapi/apis/live-tennis-api/llms.txt) · [OpenAPI document](https://skmtc.dev/livetennisapi/apis/live-tennis-api/revisions/7881947e8e43?raw)
