Skip to main content

Endpoint

The session id alone addresses the feed — the server resolves which sport the session belongs to. The frame envelope is identical for every sport, only the state snapshot differs; the hello frame’s sport field (the first message on every subscription — see Connection flow) tells you which event vocabulary and state shape to parse, so the stream is self-describing even without a catalog row. See the Sports overview.

Path parameters

Query parameters

Close codes

Anything else — 1006 on a network drop, a server restart, a proxy timeout — is transient: reconnect and re-backfill (see Core Concepts — Reconnection).

The pre-close error frame

The 4404/4403/1011 closes are deliberate, informative refusals, so the server first accepts the connection, sends exactly one error frame, then closes with the matching code. (An unknown session id is only answered this way for an authenticated caller — an invalid token gets the uninformative HTTP 403 handshake rejection regardless of whether the session exists. An unrouted path is refused without an auth check; the endpoint vocabulary is public. 1011 is also answered without full authentication — it reveals only the server’s own health, never whether a token or session is real.)
On the wire that is a single JSON text message, e.g. {"type": "error", "code": "not_found"}:
It is not a WireFrame — it has no outSeq, state, or matchup — so branch on type before treating a message as a frame. The credential refusal happens before any WebSocket exists (the handshake itself is rejected with HTTP 403), so no error frame — or any message — precedes it. The 4000 close is not a refusal at all: it is the normal end of every session, preceded by a {"type": "session_closed"} control message instead of an error frame — see Connection flow.

Connection flow

  1. Connect and authenticate via ?token=.
  2. Receive the hello frame — a one-off {"type": "hello", "contractVersion": "...", "sport": "...", "league": "..."} control message (not a WireFrame). Compare the pair (sport, contractVersion) against what your client was built for before trusting any live data — versions count independently per sport, so the version alone is ambiguous. sport ("basketball" | "soccer" | "baseball" | "football") also selects the event vocabulary and state shape to parse; league is the session’s league slug (session metadata, sent once here rather than on every frame). On a sandbox mock session — and only there — the hello additionally carries "sandbox": true; the key is absent on real games.
  3. Receive the snapshot-on-connect frameevent is null, state is the current match state.
  4. Receive live event frames as the scout presses; each carries the triggering event and the full updated snapshot.
  5. Between event frames, receive periodic ping messages{"type": "ping"}, a control message (not a WireFrame), sent roughly every 15 seconds. Ignore it; it carries no data. It exists so the connection is never idle (some network middleboxes cut WebSockets that go quiet). Its absence is also a useful staleness signal: if you have received nothing at all — no frame, no ping — for well over the ping interval (say 45+ seconds), assume the connection is dead and reconnect.
  6. Keep the connection open. The server keeps sending frames until the match ends or the connection drops.
  7. When the match ends, the server sends a final {"type": "session_closed"} control message and closes the socket with code 4000. This is the normal end of every session — do not reconnect (the game is over; a reconnect attempt is refused 4404/4403). The full tape stays readable via GET /api/history/{session}.

Control message examples

The hello frame (step 2 above) — a real game never carries sandbox, only a sandbox mock session does:
The ping message (step 5) — no fields beyond type:
The session-closed message (step 7) — precedes every 4000 close, no fields beyond type:

Frame shape

Every message that is not a control message (hello, error, ping, session_closed — each a flat object with a type key; branch on type first) is a JSON-serialized WireFrame:
The envelope is the same for every sport; only the state body differs. The two examples below are deliberately from different sports to show that.

Snapshot-on-connect frame (a soccer session)

Event frame (a basketball session)

A made resolving a three-point attempt. It self-describes what it resolved — payload.team and payload.shot_type — no separate lookup needed (see Core Concepts — Open then resolve). Note the payload’s field name is shot_type (snake_case) even though the state snapshot’s equivalent field is shotType (camelCase) — event payload fields and state fields use different casing on this feed.

Advisory frame

Server-originated frames (e.g. scout liveness) arrive with event.seq === null. The snapshot is still included.
"dark" means the scout’s liveness signal has not been seen recently. "live" means they are active again. lastHeartbeat is the Unix timestamp of the most recent heartbeat received from the scout (null if none seen yet). See Core Concepts — Advisories.

Applying frames


Contract versioning

The first message on every subscription is the hello frame — {"type": "hello", "contractVersion": "...", "sport": "...", "league": "..."} — carrying the server’s current version for this sport, so a stale client can detect drift and force-reload before reading any live data. The schema identity is the pair (sport, contractVersion); compare both — versions count independently per sport, so “v4” alone is ambiguous, and a version-only pin would pass even with the wrong sport’s feed wired into your parser. Every GET /api/history/{session} response also carries the same sport and a contractVersion string — there the version is the schema the game’s log was recorded under. For a live game that is the server’s current version; for an older game it is whatever was current when the game was played. On connect, compare the hello frame’s (sport, contractVersion) pair (and the /history response’s, when backfilling) against what your client was built for before processing any events. A version mismatch on a historical log means it was recorded under a different schema; read the events against that version. GET /api/history/{session} is never gated on version — historical logs stay retrievable across schema changes. The version is bumped only when the wire schema changes (fields added, removed, or renamed; new event types). Logic or UI changes do not bump it.

Gap detection

If frame.outSeq skips a value, you missed a frame. The snapshot in the next frame already corrects your state — no resend is needed. If you need the missing tape row (for display), re-backfill from GET /api/history/{session} and dedup by seq.