> ## Documentation Index
> Fetch the complete documentation index at: https://docs.akaramarkets.com/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket feed

> Subscribe to live match frames

## Endpoint

```
wss://mm.akaramarkets.com/api/subscribe/{session}
```

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](#connection-flow)) tells you which event vocabulary and state shape to parse, so the stream is self-describing even without a [catalog](/api-reference/catalog) row. See the [Sports overview](/sports/overview).

### Path parameters

| Parameter | Description                                  |
| --------- | -------------------------------------------- |
| `session` | Session id for the match (alphanumeric slug) |

### Query parameters

| Parameter | Required | Description                                                                                                     |
| --------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `token`   | Yes      | Consumer token. Browser clients must use this form — a browser `WebSocket` cannot set an `Authorization` header |

### Close codes

| Code                                      | Meaning                                                                                                                                                                                                                                                                           | Error frame first?                                                         | Should you reconnect?                                                                                                          |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `4000`                                    | The session ended normally — the game is over and the feed is sealed. **Every** session ends this way                                                                                                                                                                             | No — but a `{"type": "session_closed"}` control message precedes the close | No — the game will not resume; the full tape stays readable via `GET /api/history/{session}`                                   |
| *(none — handshake rejected, HTTP `403`)* | Missing or invalid token, or malformed session id. The upgrade request is refused before any WebSocket exists, so there is no close frame or code at all — a browser `WebSocket` observes a failed connection (close `1006`); a non-browser client sees the `403` response itself | No — nothing is ever sent                                                  | No — fix the token first; retrying with the same credential gets the same refusal                                              |
| `4404`                                    | Session unknown (never published, already ended, or the id resolves to no live feed) — or the URL path itself has no WebSocket endpoint (a typo, or a retired path shape)                                                                                                         | Yes — `code: "not_found"`                                                  | No — this answer will not change; check the URL against this page, or fetch the tape from `GET /api/history/{session}` instead |
| `4403`                                    | Not authorized: your account is not entitled to this game, or the session is no longer open to consumers (the game is over and the feed is sealed)                                                                                                                                | Yes — `code: "not_authorized"`                                             | No — re-check your entitlements, or replay the finished game via `GET /api/history/{session}`                                  |
| `1011`                                    | The server could not verify any credential — its auth infrastructure is unavailable (an outage or misconfiguration on our side, **not** something you can fix). Your token was never examined, let alone rejected                                                                 | Yes — `code: "unavailable"`                                                | **Yes** — the one retriable refusal: reconnect with backoff; do not rotate the token                                           |

Anything else — `1006` on a network drop, a server restart, a proxy timeout — is transient: reconnect and re-backfill (see [Core Concepts — Reconnection](/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.)

```typescript theme={null}
interface ErrorFrame {
  type: "error";
  code: "not_found" | "not_authorized" | "unavailable"; // matches the close: not_found → 4404, not_authorized → 4403, unavailable → 1011
}
```

On the wire that is a single JSON text message, e.g. `{"type": "error", "code": "not_found"}`:

```json theme={null}
{ "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).

***

## 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](/sandbox) — and only there — the hello additionally carries `"sandbox": true`; the key is absent on real games.
3. Receive the **snapshot-on-connect frame** — `event` 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](/sandbox) does:

```json theme={null}
{ "type": "hello", "contractVersion": "v5", "sport": "basketball", "league": "nba" }
```

The ping message (step 5) — no fields beyond `type`:

```json theme={null}
{ "type": "ping" }
```

The session-closed message (step 7) — precedes every `4000` close, no fields beyond `type`:

```json theme={null}
{ "type": "session_closed" }
```

***

## 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`:

```typescript theme={null}
interface WireFrame {
  outSeq: number;         // always increases; a gap means you missed a frame (there is no resend)
  event: WireEvent | null; // null only on the first frame (the snapshot on connect)
  state: object;          // the full sport-specific state snapshot after the event is applied
  matchup: { a: string; b: string }; // the two side display names; included on every frame
}
```

```typescript theme={null}
interface WireEvent {
  type: string;            // event type — see the sport's events page for all values
  payload: object;         // the event's fields (everything except `type`); field names are
                           // snake_case here, unlike the camelCase `state` snapshot below
  seq: number | null;      // the scout press number; null on server-sent advisories
  serverIngestTs: number;  // when the server received the press — Unix time in seconds, fractional
                           // (microsecond-level precision). A receipt stamp, not an ordering key:
                           // order by `seq`, and detect gaps with the frame's `outSeq`
}
```

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)

```json theme={null}
{
  "outSeq": 47,
  "event": null,
  "matchup": { "a": "Brazil", "b": "France" },
  "state": {
    "score": { "a": 1, "b": 0 },
    "scoreUnderReview": false,
    "phase": "2H",
    "live": true,
    "zone": "b_half",
    "inBox": false,
    "possession": "A",
    "pendingSetPiece": null,
    "openVar": null,
    "playersOnPitch": { "a": 11, "b": 10 },
    "yellowCards": { "a": 1, "b": 2 },
    "redCards": { "a": 0, "b": 0 }
  }
}
```

### 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](/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.

```json theme={null}
{
  "outSeq": 112,
  "event": {
    "type": "made",
    "payload": { "team": "A", "shot_type": "3pt" },
    "seq": 89,
    "serverIngestTs": 1718632800.445
  },
  "matchup": { "a": "Celtics", "b": "Knicks" },
  "state": {
    "score": { "a": 78, "b": 74 },
    "clock": "LIVE",
    "period": 4,
    "ftsRemaining": 0,
    "possession": "A",
    "tipoffTeam": "A",
    "openAttempt": null
  }
}
```

### Advisory frame

Server-originated frames (e.g. scout liveness) arrive with `event.seq === null`. The snapshot is still included.

```json theme={null}
{
  "outSeq": 49,
  "event": {
    "type": "scout_status",
    "payload": { "status": "dark", "lastHeartbeat": 1718632845.3 },
    "seq": null,
    "serverIngestTs": 1718632860.0
  },
  "state": { "...": "current snapshot" }
}
```

`"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](/concepts#advisories).

***

## Applying frames

```javascript theme={null}
ws.onmessage = (msg) => {
  const frame = JSON.parse(msg.data);

  // Control messages first — they are not WireFrames and carry no state.
  // The schema identity is the pair (sport, contractVersion) — compare both.
  if (frame.type === "hello") return checkSchemaIdentity(frame.sport, frame.contractVersion);
  if (frame.type === "error") return; // a refusal; the close that follows (4404/4403/1011) says why
  if (frame.type === "ping") return;  // keepalive, ~every 15s; carries nothing
  if (frame.type === "session_closed") return; // the game is over; a 4000 close follows — don't reconnect

  // Always replace the snapshot — it is authoritative.
  state = frame.state;

  if (frame.event === null) return; // snapshot-on-connect; no tape row

  const { type, payload, seq, serverIngestTs } = frame.event;

  if (seq !== null) {
    // Scout press — append to tape (dedup by seq on reconnect).
    tape.set(seq, { type, payload, serverIngestTs });
  }
  // Advisory frames (seq === null) affect state only; no tape row.
};
```

***

## 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`.
