Endpoint
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
The4404/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.)
{"type": "error", "code": "not_found"}:
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
- Connect and authenticate via
?token=. - Receive the hello frame — a one-off
{"type": "hello", "contractVersion": "...", "sport": "...", "league": "..."}control message (not aWireFrame). 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;leagueis 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. - Receive the snapshot-on-connect frame —
eventisnull,stateis the current match state. - Receive live event frames as the scout presses; each carries the triggering event and the full updated snapshot.
- Between event frames, receive periodic ping messages —
{"type": "ping"}, a control message (not aWireFrame), 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. - Keep the connection open. The server keeps sending frames until the match ends or the connection drops.
- When the match ends, the server sends a final
{"type": "session_closed"}control message and closes the socket with code4000. This is the normal end of every session — do not reconnect (the game is over; a reconnect attempt is refused4404/4403). The full tape stays readable viaGET /api/history/{session}.
Control message examples
The hello frame (step 2 above) — a real game never carriessandbox, only a sandbox mock session does:
type:
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:
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)
Amade 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 withevent.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
Ifframe.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.