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

# Multiplexed WebSocket

> Subscribe to markets, scores, play-by-play, live game stats, and live game state over a single connection to /api/v2/ws.

The multiplexed WebSocket at `wss://therundown.io/api/v2/ws` carries multiple logical channels — `markets`, `scores`, `plays`, `stats`, and `live` — over one connection. Instead of encoding filters in the connection URL, you send JSON subscribe messages after connecting, and you can add or remove subscriptions at any time without reconnecting.

<Note>
  WebSocket access is enabled on real-time API tiers (Ultra and above) by default. The `plays`, `stats`, and `live` channels additionally require the live game state entitlement, which is also included from Ultra up — see [Rate Limits](/rate-limits#current-api-tier-defaults). Each plan also caps concurrent connections and subscriptions per connection — see the [connection limits table](/api-reference/v2/websocket#overview).
</Note>

## When to Use the Multiplexed Endpoint

| If you need... | Use |
| - | - |
| Market price updates only, with a fixed set of filters | [`/api/v2/ws/markets`](/api-reference/v2/websocket#markets-websocket) — simpler, filters via query parameters |
| Scores, play-by-play, live game stats, or live game state streams | `/api/v2/ws` — these channels are only available here |
| Multiple feeds with independent filters | `/api/v2/ws` — one subscription per feed, one connection |
| To change filters at runtime | `/api/v2/ws` — unsubscribe and resubscribe without reconnecting |

## Connect

Connect to `wss://therundown.io/api/v2/ws` from a server-side client and send
`X-TheRundown-Key: $THERUNDOWN_API_KEY` in the upgrade request. WebSocket
access requires an Ultra plan or higher. Native browser WebSocket clients cannot
set custom headers, so browsers should connect to an authenticated backend relay.

The connection starts with no subscriptions. Until you subscribe, the only messages you receive are [heartbeats](/api-reference/v2/websocket#heartbeat) every 15 seconds.

## Subscribe to a Channel

Send a `subscribe` action with a client-chosen `id`, the `channel`, and optional `params` filters:

```json theme={null}
{
  "action": "subscribe",
  "id": "nba-markets",
  "channel": "markets",
  "params": { "sport_ids": [4], "market_ids": [1, 2, 3] }
}
```

| Field | Required | Description |
| - | - | - |
| `action` | Yes | `"subscribe"`, `"unsubscribe"`, or `"snapshot"` (see [Request a Snapshot](#request-a-snapshot)) |
| `id` | Yes | Client-chosen identifier, unique among your active subscriptions. Every message for this subscription is tagged with it |
| `channel` | `subscribe` only | `"markets"`, `"scores"`, `"plays"`, `"stats"`, `"futures"`, or `"live"` |
| `params` | No (`subscribe` only) | Filters for this subscription. Without filters, you receive everything the channel carries |

The server acknowledges each successful subscription:

```json theme={null}
{ "type": "subscribed", "id": "nba-markets", "sequence": 12, "message": "subscribed to markets" }
```

### Channels and Filters

| Channel | Delivers | `params` filters |
| - | - | - |
| `markets` | Market price updates for games — same payloads as [`/api/v2/ws/markets`](/api-reference/v2/websocket#markets-websocket). Competition (futures) frames are included only when the subscription explicitly names future-class `market_ids` or competition `event_ids` — see [Futures & Outrights](/guides/futures#streaming-futures-over-websocket) | `sport_ids`, `event_ids`, `market_ids`, `affiliate_ids` |
| `futures` | Futures/outright price updates for competition boards (**Ultra plan or higher** for API keys; Pro or higher for web sessions) — same `market_price` payload shape as `markets`, competition events only | `sport_ids`, `event_ids`, `market_ids`, `affiliate_ids` |
| `scores` | Score and event-status updates (no live game state fields) | `sport_ids`, `event_ids` |
| `plays` | Play-by-play deltas for live games (**Ultra plan or higher**) | `sport_ids`, `event_ids` |
| `stats` | Changed team and player box-score rows for live games (**Ultra plan or higher**) | `sport_ids`, `event_ids` |
| `live` | Everything in-game on one subscription: score/status deltas including `live_game_state`, play-by-play deltas, and game-stat deltas (**Ultra plan or higher**). `live_game_state` and `game_state` are accepted aliases | `sport_ids`, `event_ids` |

<Warning>
  Unlike REST query parameters, `params` values are JSON arrays, not comma-separated strings: numbers for `sport_ids`, `market_ids`, and `affiliate_ids` (e.g., `[4, 6]`), strings for `event_ids`.
</Warning>

## Handle Incoming Messages

Channel data arrives wrapped in a delta envelope tagged with your subscription `id`. Route messages on the top-level `type` field, then on `id`:

```json theme={null}
{
  "type": "delta",
  "id": "nba-markets",
  "sequence": 42,
  "sub_sequence": 7,
  "delta_last_id": "...",
  "data": {
    "meta": { "type": "market_price", "version": "v2", "timestamp": 1772495104 },
    "data": {
      "event_id": "9b9d0cf6007fdaeb15c3a1888dcfd5df",
      "affiliate_id": 26,
      "market_id": 3,
      "line": "1.5",
      "price": "-117",
      "previous_price": "-122.0000",
      "price_delta": 5,
      "is_main_line": true,
      "sport_id": 7,
      "updated_at": "2026-03-02T23:44:44Z"
    }
  }
}
```

| Field | Description |
| - | - |
| `type` | `"delta"` for channel data. Other top-level types include `"subscribed"`, `"snapshot"`, `"snapshot_complete"`, `"snapshot_error"`, and `"error"` |
| `id` | The subscription `id` you chose when subscribing — use it to route the message |
| `sequence` | Server-assigned sequence number for messages on this connection |
| `sub_sequence` | Message counter within this subscription |
| `delta_last_id` | Cursor of the underlying delta feed for this change |
| `data` | The channel payload — an object with its own `meta` and `data`, in the same format as the dedicated stream for that channel |

Heartbeats arrive in the same format as on the dedicated markets stream — `{"meta": {"type": "heartbeat"}, ...}` — and are not tagged with a subscription `id`. For the full payload field reference per channel, see the [WebSocket reference](/api-reference/v2/websocket#message-format); play-by-play payloads are documented under [Play messages](/api-reference/v2/websocket#play-messages), and box-score deltas under [Game stats messages](/api-reference/v2/websocket#game-stats-messages).

## Request a Snapshot

Deltas only tell you what changed — to start from current state, add `"snapshot": true` to `params` when subscribing. Snapshot requests need a bounded scope: `event_ids`, or `sport_ids` plus `date`. Two channels differ: `plays` snapshots require `event_ids`, and `futures` snapshots require positive `sport_ids` with **no** `date` (boards are long-lived intervals; adding `event_ids` there additionally requires exactly one sport):

```json theme={null}
{
  "action": "subscribe",
  "id": "nba-markets",
  "channel": "markets",
  "params": { "sport_ids": [4], "date": "2026-07-19", "market_ids": [1, 2, 3], "snapshot": true }
}
```

After the `subscribed` ack, the server sends one or more `snapshot` frames followed by `snapshot_complete`; live deltas begin after that. An active subscription can also request a fresh snapshot at any time without reconnecting:

```json theme={null}
{ "action": "snapshot", "id": "nba-markets" }
```

Snapshot frames are metered as data points by category, the same as the equivalent REST reads. See [Snapshots](/api-reference/v2/websocket#snapshots) in the reference for per-channel snapshot contents.

<Warning>
  `stats` is a delta-only channel and does not support snapshots, resync, resume, or replay. Setting `snapshot: true` on an initial `stats` subscribe returns `snapshot_error` with code `unsupported_snapshot` and does not leave that subscription active; subscribe again without `snapshot`. A `live` snapshot also does not include a game-stat baseline. Bootstrap and recover game stats through the two REST event-stat endpoints as described in [Streaming Live Game Stats](#streaming-live-game-stats).
</Warning>

## Unsubscribe

Send an `unsubscribe` action with the subscription's `id`:

```json theme={null}
{ "action": "unsubscribe", "id": "nba-markets" }
```

To change a subscription's filters, unsubscribe and subscribe again. If your application cannot tolerate a gap between the two, subscribe with the new filters under a **different** `id` first, then unsubscribe the old one — you may briefly receive duplicate messages while both are active, so deduplicate during the overlap.

## Handle Errors

Errors are returned as `{"type": "error", "id": "...", "code": "...", "message": "..."}`:

| Code | Meaning | What to do |
| - | - | - |
| `forbidden` | Subscribing to `plays`, `stats`, or `live` without the live game state entitlement (Ultra plan or higher), or to `futures` without the futures entitlement (Ultra plan or higher for API keys, Pro or higher for web sessions) | Upgrade your plan, or drop the gated subscription |
| `futures_unavailable` | The `futures` channel cannot classify competition events yet (service warm-up) | Transient — retry the subscribe shortly |
| `invalid_channel` | Unknown `channel` value | Use `markets`, `scores`, `plays`, `stats`, `futures`, or `live` |
| `missing_id` | Subscribe action sent without an `id` | Include a client-chosen `id` |
| `duplicate_id` | An active subscription already uses this `id` | Pick a different `id`, or unsubscribe the existing one first |
| `subscription_limit` | Your plan's concurrent subscription cap was reached | Unsubscribe an existing subscription, or consolidate filters into fewer subscriptions |
| `buffer_overflow` | A subscription or fan-out queue could not accept a live frame; this connection will close | Reconnect, re-send all subscriptions, and recover current state from REST before applying new deltas |

`buffer_overflow` is a connection-level error and can omit `id`. When possible, the server sends it immediately before closing with reason `buffer_overflow:reconnect_and_catchup`.

## Complete Client Example

A client that subscribes to NBA market prices and scores on one connection, routes messages by subscription `id`, and resubscribes automatically after a reconnect:

The Node.js example requires `ws` (`npm install ws`). The Python example uses
`websockets>=14` and its `additional_headers` handshake option.

<CodeGroup>
  ```javascript Node.js 22+ (ws) theme={null}
  const WebSocket = require("ws");
  const API_KEY = process.env.THERUNDOWN_API_KEY;
  if (!API_KEY) throw Error('Set THERUNDOWN_API_KEY');

  const WS_URL = "wss://therundown.io/api/v2/ws";

  const SUBSCRIPTIONS = [
    {
      action: "subscribe",
      id: "nba-markets",
      channel: "markets",
      params: { sport_ids: [4], market_ids: [1, 2, 3] },
    },
    {
      action: "subscribe",
      id: "nba-scores",
      channel: "scores",
      params: { sport_ids: [4] },
    },
  ];

  function handleDelta(msg) {
    const payload = msg.data; // { meta: {...}, data: {...} }

    switch (msg.id) {
      case "nba-markets": {
        const d = payload.data;
        console.log(
          `Price: event=${d.event_id} market=${d.market_id} ${d.price} (was ${d.previous_price})`
        );
        break;
      }
      case "nba-scores":
        console.log("Score update:", payload.data);
        break;
    }
  }

  function connect() {
    const ws = new WebSocket(WS_URL, {
      headers: { "X-TheRundown-Key": API_KEY },
    });

    ws.on("open", () => {
      console.log("Connected — subscribing");
      // Subscriptions only live as long as the connection,
      // so re-send them on every (re)connect
      SUBSCRIPTIONS.forEach((sub) => ws.send(JSON.stringify(sub)));
    });

    ws.on("message", (raw) => {
      const msg = JSON.parse(raw.toString());

      if (msg.meta?.type === "heartbeat") return;

      switch (msg.type) {
        case "subscribed":
          console.log(`Subscription ${msg.id} active: ${msg.message}`);
          break;
        case "delta":
          handleDelta(msg);
          break;
        case "error":
          console.error(`Subscription error [${msg.code}]: ${msg.message}`);
          break;
        case "snapshot_error":
          console.error(`Snapshot error [${msg.code}]: ${msg.message}`);
          break;
      }
    });

    ws.on("close", () => {
      console.log("Disconnected, reconnecting in 3s...");
      setTimeout(connect, 3000);
    });

    ws.on("error", (error) => {
      console.error("WebSocket error:", error);
      ws.close();
    });
  }

  connect();
  ```

  ```python Python theme={null}
  import asyncio
  import json
  import os
  import websockets

  API_KEY = os.environ["THERUNDOWN_API_KEY"]
  WS_URL = "wss://therundown.io/api/v2/ws"

  SUBSCRIPTIONS = [
      {
          "action": "subscribe",
          "id": "nba-markets",
          "channel": "markets",
          "params": {"sport_ids": [4], "market_ids": [1, 2, 3]},
      },
      {
          "action": "subscribe",
          "id": "nba-scores",
          "channel": "scores",
          "params": {"sport_ids": [4]},
      },
  ]


  def handle_delta(msg):
      payload = msg["data"]  # {"meta": {...}, "data": {...}}

      if msg["id"] == "nba-markets":
          d = payload["data"]
          print(
              f"Price: event={d['event_id']} market={d['market_id']}"
              f" {d['price']} (was {d['previous_price']})"
          )
      elif msg["id"] == "nba-scores":
          print(f"Score update: {payload['data']}")


  async def listen():
      while True:
          try:
              async with websockets.connect(
                  WS_URL, additional_headers={"X-TheRundown-Key": API_KEY}
              ) as ws:
                  print("Connected — subscribing")
                  # Subscriptions only live as long as the connection,
                  # so re-send them on every (re)connect
                  for sub in SUBSCRIPTIONS:
                      await ws.send(json.dumps(sub))

                  async for raw in ws:
                      msg = json.loads(raw)

                      if msg.get("meta", {}).get("type") == "heartbeat":
                          continue

                      msg_type = msg.get("type")
                      if msg_type == "subscribed":
                          print(f"Subscription {msg['id']} active: {msg['message']}")
                      elif msg_type == "delta":
                          handle_delta(msg)
                      elif msg_type == "error":
                          print(f"Subscription error [{msg['code']}]: {msg['message']}")
                      elif msg_type == "snapshot_error":
                          print(f"Snapshot error [{msg['code']}]: {msg['message']}")

          except (websockets.ConnectionClosed, ConnectionError):
              print("Disconnected, reconnecting in 3s...")
              await asyncio.sleep(3)


  asyncio.run(listen())
  ```
</CodeGroup>

For production use, replace the fixed 3-second reconnect delay with exponential backoff and jitter — see [Reconnection Best Practices](/guides/websocket-streaming#reconnection-best-practices).

## Streaming Play-by-Play

On an Ultra plan or higher, add a `plays` subscription to receive live play-by-play for in-progress games:

```json theme={null}
{
  "action": "subscribe",
  "id": "mlb-plays",
  "channel": "plays",
  "params": { "sport_ids": [3], "event_ids": ["816efd1e5767d7133b5bc70c77173a18"] }
}
```

Each play arrives in the delta envelope with `meta.type` of `"play"` in the inner payload — description, period, running score, and (as attribution rolls out) the players involved. For the full play payload, see [Play messages](/api-reference/v2/websocket#play-messages) in the reference.

To load the plays that happened before you subscribed, either add `"snapshot": true` to the subscription (`plays` snapshots return up to 500 current plays), or fetch the timeline from the REST endpoint [`GET /api/v2/events/{eventID}/plays`](/api-reference/generated/v2-events/get-play-by-play-for-an-event) and apply streamed plays on top.

If you want scores, `live_game_state`, plays, and game stats together for the same games, subscribe to the `live` channel instead of pairing separate subscriptions — it delivers all in-game updates on one subscription (score/status deltas arrive with `meta.type` of `"score"`, plays with `"play"`, and stats with `"game_stats"`), which also conserves your plan's subscription slots.

## Streaming Live Game Stats

For supported live games, the `stats` channel pushes changed team and player box-score rows inline at play latency — typically within a few seconds of the corresponding play-by-play update (live game data overall trails the on-field action by roughly 15–20 seconds, in line with the typical broadcast delay). Connect to the multiplexed `/api/v2/ws` endpoint and subscribe by sport, event, or both:

```json theme={null}
{
  "action": "subscribe",
  "id": "nba-stats",
  "channel": "stats",
  "params": { "sport_ids": [4], "event_ids": ["EVENT_ID"] }
}
```

`stats_ids`, team IDs, player IDs, `market_ids`, and `affiliate_ids` cannot filter this channel. Each incoming delta has inner `meta.type` of `"game_stats"`; its `team_stats[]` and `player_stats[]` groups contain only rows that changed.

Use this bootstrap and recovery flow:

1. Subscribe (or resubscribe) to `stats` and temporarily buffer its delta frames.
2. Fetch the current team box from [`GET /api/v2/events/{eventID}/stats`](/api-reference/generated/v2-stats/get-team-game-stats-for-an-event) and the current player box from [`GET /api/v2/events/{eventID}/players/stats`](/api-reference/generated/v2-stats/get-player-game-stats-for-an-event).
3. Replace your local baseline with those REST responses, then apply the buffered deltas in order.
4. For every later frame, upsert each nested row by its team or player ID plus `stat_id`. Keep `value` as a string and do not replace an entire group with one delta. When normalizing the REST baseline, player rows use the group's `player.id` and the nested `stat.id`; WebSocket player rows additionally carry `player_id` and `stat_id`.
5. If both stat arrays are absent and `complete` is `true`, mark the cached box score complete; repeated completion markers are idempotent. If both arrays are absent and `complete` is not `true`, the frame is an invalidation fallback, so refetch both REST resources.
6. If you detect a `sub_sequence` gap or reconnect after `buffer_overflow`, refetch both REST resources. Neither `sub_sequence` nor `delta_last_id` is a stats replay cursor.

```javascript theme={null}
function applyGameStatsDelta(cache, msg, refetchCurrentBox) {
  const update = msg.data.data;
  const hasStatArrays = Array.isArray(update.team_stats) || Array.isArray(update.player_stats);
  const teamGroups = update.team_stats ?? [];
  const playerGroups = update.player_stats ?? [];
  cache.team ??= {};
  cache.player ??= {};

  // A zero-row complete:true frame is the terminal completion marker.
  if (!hasStatArrays) {
    if (update.complete === true) {
      cache.complete = true;
      return;
    }

    // A zero-row frame without complete:true is an invalidation fallback.
    refetchCurrentBox(update.event_id);
    return;
  }

  if (update.complete === true) cache.complete = true;

  for (const group of teamGroups) {
    const teamID = group.team.team_id;
    cache.team[teamID] ??= { team: group.team, stats: {} };
    cache.team[teamID].team = group.team;
    for (const row of group.stats ?? []) {
      cache.team[teamID].stats[row.stat_id ?? row.stat.id] = row;
    }
  }

  for (const group of playerGroups) {
    const playerID = group.player.id;
    cache.player[playerID] ??= { player: group.player, stats: {} };
    cache.player[playerID].player = group.player;
    for (const row of group.stats ?? []) {
      cache.player[playerID].stats[row.stat_id ?? row.stat.id] = row;
    }
  }
}
```

Each changed nested stat row is one data point in the `stats` billing category. A zero-row completion marker or invalidation fallback costs one stats data point. See [Game stats messages](/api-reference/v2/websocket#game-stats-messages) for the full envelope, completion and fallback timestamp variants, and field reference.

## Best Practices

<AccordionGroup>
  <Accordion title="Resubscribe on every reconnect">
    Subscriptions live only as long as the connection. After any disconnect, re-send all subscribe messages as soon as the new connection opens — put the subscribe logic in your `onopen` handler so it runs on every connect, as in the example above.
  </Accordion>

  <Accordion title="Re-sync state after a reconnect">
    You may have missed updates while disconnected. For snapshot-capable channels, resubscribe with `"snapshot": true` so current state arrives before deltas resume, or fetch current state from the REST API (or the [delta endpoints](/guides/efficient-polling)). A `buffer_overflow:reconnect_and_catchup` close always requires this recovery. For `stats`, fetch both REST game-stat resources and then merge new deltas; snapshots and replay are unsupported. Snapshot frames are metered like the equivalent REST reads.
  </Accordion>

  <Accordion title="Filter every subscription">
    An unfiltered subscription can stream more data than you need. Every non-market subscription has its own [1,024-message outbound queue](/api-reference/v2/websocket#message-queue); if a live frame cannot be queued, the connection closes so you can recover instead of continuing with a silent gap. Scope each subscription with `sport_ids` and `event_ids`; market subscriptions also support `market_ids` and `affiliate_ids`.
  </Accordion>

  <Accordion title="Use meaningful subscription ids">
    The `id` is how you route incoming messages, so name subscriptions after what they carry (`nba-markets`, `mlb-plays`, `nba-stats`) rather than opaque strings. Keep a map of active ids to handlers and you can add or remove feeds without touching your message loop.
  </Accordion>

  <Accordion title="Consolidate subscriptions where filters allow">
    Plans cap subscriptions per connection — from 3 on Ultra to 50 on Enterprise (error code `subscription_limit`; see the [connection limits table](/api-reference/v2/websocket#overview)). One `markets` subscription filtered to two sports uses one slot; two single-sport subscriptions use two. The `live` channel can replace separate `scores`, `plays`, and `stats` subscriptions with a single slot. Split subscriptions only when you need to route or manage the feeds independently.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="WebSocket Reference" icon="book" href="/api-reference/v2/websocket">
    Full protocol details and payload field tables
  </Card>

  <Card title="WebSocket Streaming" icon="bolt" href="/guides/websocket-streaming">
    The dedicated markets stream, heartbeats, and reconnection patterns
  </Card>

  <Card title="Play-by-Play REST Endpoint" icon="list" href="/api-reference/generated/v2-events/get-play-by-play-for-an-event">
    Backfill the play timeline before streaming
  </Card>

  <Card title="Game Stats" icon="chart-simple" href="/api-reference/v2/stats">
    Bootstrap team and player box scores before streaming deltas
  </Card>

  <Card title="Efficient Polling" icon="rotate" href="/guides/efficient-polling">
    REST delta endpoints as a fallback or backfill
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.