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

# WebSocket

> Real-time streaming of odds, scores, plays, and game stats via WebSocket

## Overview

TheRundown provides a V2 WebSocket endpoint for streaming real-time data without polling:

| Endpoint | Description |
| - | - |
| `/api/v2/ws/markets` | Streams market price changes (odds updates) as they happen |
| `/api/v2/ws` | Multiplexed endpoint — subscribe to `markets`, `scores`, `plays`, `stats`, `futures`, and `live` channels over one connection |

WebSocket connections do **not** count against your REST API rate limit — but each plan has a hard cap on **concurrent connections** (and on subscriptions per connection on the multiplexed endpoint). Opening a connection beyond the cap is rejected at upgrade time with a `429` (`WebSocket connection limit reached`).

| Plan | Concurrent connections | Subscriptions per connection |
| - | - | - |
| Ultra | 1 | 3 |
| Super | 3 | 5 |
| Mega | 5 | 10 |
| Max | 10 | 25 |
| Enterprise | 50 | 50 |

<Note>
  WebSocket access requires a real-time API tier (**Ultra and above**). 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).
</Note>

***

## Connection

Connect from a server-side WebSocket 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; use an authenticated backend relay for browser consumers.

<Warning>
  Use `wss://` (secure WebSocket), not `ws://`. Connections on `ws://` will be rejected.
</Warning>

***

## Markets WebSocket

### `GET /api/v2/ws/markets`

Streams V2 market price changes in real time. Every time a sportsbook updates a price on any tracked market, you receive a message.

### Filter Parameters

All filters are optional. If none are specified, you receive all messages across all sports and markets.

| Parameter | Description |
| - | - |
| `affiliate_ids` | Comma-separated sportsbook IDs (e.g., `19,23`) |
| `sport_ids` | Comma-separated sport IDs (e.g., `4,6`) |
| `event_ids` | Comma-separated event IDs |
| `market_ids` | Comma-separated market IDs (e.g., `1,2,3`) |
| `main_line` | `true` to receive **main lines only**: price updates flagged `is_main_line` at publish time, plus closes of the current main. Alternate-ladder updates are not delivered — and therefore not metered. When a book re-centers its ladder onto a line whose price also changed, you receive that update immediately; if only the designation moved (price unchanged), the newly-main line surfaces on its next price update — so pair the stream with a periodic REST refresh using `main_line=true` when you need the authoritative current main at any instant. |

### Example Connection URLs

```bash theme={null}
# All NBA market updates
wss://therundown.io/api/v2/ws/markets?sport_ids=4

# DraftKings + FanDuel moneyline updates only
wss://therundown.io/api/v2/ws/markets?affiliate_ids=19,23&market_ids=1

# Updates for a specific event
wss://therundown.io/api/v2/ws/markets?event_ids=EVENT_ID
```

<Warning>
  **Live frames carry the in-play market IDs.** A `market_ids=1,2,3` filter automatically includes the live variants `41,42,43` server-side, so live full-game updates DO stream — but each frame's `data.market_id` is the in-play ID (`41/42/43`), never relabeled to `1/2/3`. Clients that match strictly on the prematch IDs silently drop the live stream. Accept both ID families (or subscribe to the live IDs explicitly); half markets keep the same IDs in-play (`4,5,6` first half, `1008,1009,1010` second half). See [Market IDs — Live / In-Play](/reference/markets#live--in-play-markets). Every delivered message is metered as data points, so a tight `market_ids` + `affiliate_ids` + `event_ids` scope is also how you control usage.
</Warning>

### Message Format

Each message is a JSON object with two top-level fields: `meta` (message metadata) and `data` (the update payload). Unlike the REST API's nested structure, WebSocket messages deliver **one price update per message** in a flat format — each message represents a single price change for one participant, one market, and one sportsbook.

#### Example: Market Price Update

```json theme={null}
{
  "meta": {
    "type": "market_price",
    "version": "v2",
    "timestamp": 1772495104
  },
  "data": {
    "id": 193600383,
    "event_id": "9b9d0cf6007fdaeb15c3a1888dcfd5df",
    "affiliate_id": 26,
    "market_participant_id": 19402291,
    "market_id": 3,
    "line": "1.5",
    "price": "-117",
    "previous_price": "-122.0000",
    "price_delta": 5,
    "is_main_line": true,
    "normalized_market_participant_id": 10,
    "normalized_market_participant_type": 3,
    "sport_id": 7,
    "updated_at": "2026-03-02T23:44:44Z",
    "liquidity_usd": 4210.5
  }
}
```

The example above is a Polymarket price (`affiliate_id: 26`), so it carries `liquidity_usd` as order-book depth. Pinnacle prices can carry the same field as the book's max stake. Other sportsbook affiliates omit the field entirely instead of sending `null` or `0`.

#### Meta Fields

| Field | Type | Description |
| - | - | - |
| `type` | string | `"market_price"` for price updates, `"heartbeat"` for keep-alive |
| `version` | string | API version (`"v2"`) |
| `timestamp` | number | Unix epoch timestamp when the message was sent |

#### Data Fields

| Field | Type | Description |
| - | - | - |
| `id` | number | Unique price record ID |
| `event_id` | string | Canonical V2 event ID |
| `affiliate_id` | number | Sportsbook ID (see [Sportsbook IDs](/reference/sportsbooks)) |
| `market_id` | number | Market type (see [Market IDs](/reference/markets)) |
| `market_participant_id` | number | Sportsbook-specific participant ID for this market entry |
| `normalized_market_participant_id` | number | Canonical participant ID — maps to `participant.id` in REST API responses |
| `normalized_market_participant_type` | number | Participant type identifier |
| `line` | string | Line value (e.g., `"-4.5"` for spread, `"224.5"` for total, `"0"` for moneyline) |
| `price` | string | Current American odds (e.g., `"-117"`, `"+150"`) |
| `previous_price` | string | Price before this update |
| `price_delta` | number | Numeric change from previous price |
| `is_main_line` | boolean | Whether this is the primary line |
| `sport_id` | number | Sport ID |
| `updated_at` | string | ISO 8601 timestamp of the price change |
| `liquidity_usd` | number | USD reading for this price. Order-book depth on Kalshi (affiliate 25) and Polymarket (affiliate 26); the book's max stake on Pinnacle (affiliate 3). Omitted when a current reading is not available. |

<Note>
  `liquidity_usd` rides along on price updates, so a liquidity-only change does not produce its own message. It appears on supported snapshot frames (`"snapshot": true`) and live deltas when available. See [What is `liquidity_usd` on a price object?](/faq) for its public meaning and availability.
</Note>

***

## Multiplexed WebSocket Channels

The multiplexed endpoint at `/api/v2/ws` carries multiple logical channels over a single connection. Instead of query-parameter filters, you send JSON subscribe messages after connecting:

```
wss://therundown.io/api/v2/ws
```

For a step-by-step client walkthrough of this endpoint — connecting, subscribing, routing messages, and reconnecting — see the [Multiplexed WebSocket guide](/guides/multiplexed-websocket).

<Note>
  The `plays`, `stats`, and `live` channels require the live game state entitlement (**Ultra plan or higher**) — the same entitlement as the REST [plays endpoint](/api-reference/generated/v2-events/get-play-by-play-for-an-event). The `futures` channel requires the futures entitlement (**Ultra plan or higher** for API keys; Pro or higher for web sessions), matching the REST [futures endpoints](/guides/futures). The `markets` and `scores` channels are available to any WebSocket-entitled key.
</Note>

### Subscribing

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

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

The server acknowledges with:

```json theme={null}
{ "type": "subscribed", "id": "p1", "sequence": 42, "message": "subscribed to plays" }
```

| Channel | Delivers |
| - | - |
| `markets` | Market price updates for games (same payloads as `/api/v2/ws/markets`). Competition (futures) frames only via explicit future-class `market_ids` or competition `event_ids` filters |
| `futures` | Futures/outright price updates (`meta.type=market_price`) for competition boards only (futures entitlement — Ultra plan or higher for API keys, Pro or higher for web sessions) |
| `scores` | Score and event-status updates (no live game state fields) |
| `plays` | Play-by-play deltas for live games (Ultra plan or higher) |
| `stats` | Changed team and player box-score rows (`meta.type=game_stats`) for live games (Ultra plan or higher) |
| `live` | Everything in-game on one subscription: score/status deltas including the `live_game_state` surface (`meta.type=score`), play-by-play deltas (`meta.type=play`), and game-stat deltas (`meta.type=game_stats`) (Ultra plan or higher). `live_game_state` and `game_state` are accepted aliases. |

Filters supported in `params`: `sport_ids`, `event_ids` (plus `market_ids`, `affiliate_ids`, and `main_line` on the `markets` and `futures` channels). The `stats` channel does not support `stats_ids`, team, or player filters; `market_ids` and `affiliate_ids` do not apply to it.

`"main_line": true` filters the live stream (and, combined with `"snapshot": true`, the snapshot) to main-line rows only: every price move of the current main and every close of the main. A re-designation onto a line whose price also moved arrives immediately; a designation-only change (price unchanged) surfaces on that line's next price update — pair with a periodic REST refresh (`main_line=true`) for the authoritative current main. Alternate-line updates are not delivered and not metered — for a per-book best-line tracker this typically cuts delivered volume by 60–75% on spread/total markets.

### Snapshots

Add `"snapshot": true` to `params` to receive the current state before live deltas begin. The server sends the `subscribed` ack, one or more `snapshot` frames, then `snapshot_complete`; deltas start after that. Each snapshot covers your subscription scope only — `markets` snapshots use the same filters and market defaults as the REST events endpoints (each frame carries `scope.requested_market_ids` / `scope.returned_market_ids`), `live` snapshots include the current score/status and live game state, and `plays` snapshots return up to 500 current plays with a `cursor` holding the oldest/newest play sequence. `futures` snapshots replay the REST futures endpoints — one frame per sport with the full board including `settlement` state. Snapshot requests need a bounded scope: `event_ids`, or `sport_ids` plus `date` (play snapshots require `event_ids`; `futures` snapshots require positive `sport_ids` with no `date`, and `event_ids` scoping there requires exactly one sport). An existing subscription can request a fresh snapshot at any time without reconnecting:

```json theme={null}
{ "action": "snapshot", "id": "p1" }
```

Snapshot frames are metered as data points by category, the same as the equivalent REST reads.

<Warning>
  The `stats` channel is delta-only and does not support snapshots, resync, resume, or replay. A snapshot request returns `snapshot_error` with code `unsupported_snapshot`, and a `live` snapshot does not contain a game-stat baseline. On first connect, reconnect, or a detected gap, fetch both [`GET /api/v2/events/{eventID}/stats`](/api-reference/generated/v2-stats/get-team-game-stats-for-an-event) and [`GET /api/v2/events/{eventID}/players/stats`](/api-reference/generated/v2-stats/get-player-game-stats-for-an-event), then apply new deltas.
</Warning>

### Play messages

Each play arrives wrapped in a delta envelope tagged with your subscription `id`:

```json theme={null}
{
  "type": "delta",
  "id": "p1",
  "sequence": 42,
  "sub_sequence": 7,
  "delta_last_id": "...",
  "data": {
    "meta": { "type": "play" },
    "data": {
      "sport_id": 3,
      "event_id": "816efd1e5767d7133b5bc70c77173a18",
      "sequence": 214,
      "period": 6,
      "half_indicator": "bottom",
      "type": "single",
      "description": "Bobby Witt Jr. singles on a line drive to center field.",
      "score_away_after": 2,
      "score_home_after": 3
    }
  }
}
```

To stop receiving a channel, send `{"action": "unsubscribe", "id": "p1"}`.

### Game stats messages

Subscribe on the multiplexed endpoint, not the dedicated `/api/v2/ws/markets` feed, and scope the subscription by sport, event, or both:

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

For supported live games, team and player box-score changes stream at play latency — typically within a few seconds of the corresponding play-by-play update (live game data as a whole trails the on-field action by roughly 15–20 seconds, in line with the typical broadcast delay). Each frame contains only stat rows whose value changed; it is not a complete box score:

```json theme={null}
{
  "type": "delta",
  "id": "nba-stats",
  "sequence": 43,
  "sub_sequence": 8,
  "delta_last_id": "...",
  "data": {
    "meta": {
      "type": "game_stats",
      "version": "v2",
      "timestamp": 1785529872
    },
    "data": {
      "event_id": "EVENT_ID",
      "sport": "nba",
      "sport_id": 4,
      "complete": false,
      "updated_at": "2026-07-31T20:31:12Z",
      "team_stats": [
        {
          "team": {
            "team_id": 42,
            "name": "Boston",
            "abbreviation": "BOS",
            "is_away": true,
            "is_home": false
          },
          "meta": { "complete": false, "event_id": "EVENT_ID" },
          "stats": [
            {
              "team_id": 42,
              "stat_id": 1,
              "stat": {
                "id": 1,
                "name": "points",
                "display_name": "Points",
                "category": "scoring",
                "abbreviation": "PTS",
                "description": "Points scored",
                "sport_id": 4
              },
              "value": "83"
            }
          ]
        }
      ],
      "player_stats": [
        {
          "player": {
            "id": 1002,
            "sport_id": 4,
            "team_id": 42,
            "display_name": "Jayson Tatum"
          },
          "meta": { "complete": false, "event_id": "EVENT_ID" },
          "stats": [
            {
              "player_id": 1002,
              "stat_id": 1,
              "stat": {
                "id": 1,
                "name": "points",
                "display_name": "Points",
                "category": "scoring",
                "abbreviation": "PTS",
                "description": "Points scored",
                "sport_id": 4
              },
              "value": "31"
            }
          ]
        }
      ]
    }
  }
}
```

| Field | Description |
| - | - |
| `event_id`, `sport`, `sport_id` | Event and sport identity for filtering and cache routing |
| `complete` | Whether the event's box score is marked complete. A zero-row frame with `complete: true` is the terminal completion marker |
| `updated_at` | RFC 3339 timestamp on a full-push delta |
| `team_stats[]`, `player_stats[]` | Groups that changed in this frame; an omitted group did not change |
| `team` / `player` | Canonical owner identity for the group |
| group `meta` | The group's `event_id` and `complete` state, matching the REST response shape |
| `stats[]` | Changed rows only. Upsert each row by owner ID plus `stat_id`; do not replace the full group |
| `stat` | Embedded stat dictionary (`id`, name/display fields, category, abbreviation, description, and `sport_id`) |
| `value` | Raw stat value, always encoded as a JSON string (for example, `"31"` or `"38-79"`) |

Each changed row across `team_stats[].stats` and `player_stats[].stats` consumes one data point in the `stats` category, including when the frame arrives through the `live` channel. A valid `game_stats` frame with no nested rows — either a completion marker or an invalidation fallback — consumes one stats data point. If one inbound frame matches overlapping `stats` and `live` subscriptions on the same connection, it is delivered to both subscriptions but billed once on that connection.

<Warning>
  A `game_stats` frame with both stat arrays absent has one of two meanings:

  * If `complete` is `true`, it is the terminal completion marker. It includes `event_id`, `sport`, `sport_id`, and an RFC 3339 `updated_at`. Mark the cached box score complete; repeated markers are idempotent. Refetch only if you do not have a baseline or your normal gap policy requires it.
  * If `complete` is absent, it is the legacy invalidation fallback. It contains `event_id`, `sport_id`, and a numeric Unix-seconds `updated_at`. Refetch both REST box-score resources.

  Parse `updated_at` tolerantly across row deltas, completion markers, and invalidation fallbacks.
</Warning>

The outer `sub_sequence` can help detect a gap while the subscription is live, but neither it nor `delta_last_id` can replay game-stat changes. `delta_last_id` is the markets-delta cursor even when it appears on a stats frame. Recovery is always a REST refetch followed by continued streaming.

### Error codes

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

| Code | Meaning |
| - | - |
| `forbidden` | Subscribing to `plays`, `stats`, or `live` without the live game state entitlement (Ultra plan or higher) |
| `invalid_channel` | Unknown `channel` value |
| `missing_id` | Subscribe action sent without an `id` |
| `duplicate_id` | An active subscription already uses this `id` |
| `subscription_limit` | Your plan's concurrent subscription cap was reached |
| `buffer_overflow` | The multiplexed connection fell behind and will close; reconnect, resubscribe, and catch up from REST |

***

## Heartbeat

The WebSocket endpoint sends a heartbeat message every **15 seconds** to keep the connection alive:

```json theme={null}
{
  "meta": {
    "type": "heartbeat"
  },
  "data": {
    "now": "2026-02-27T01:15:00Z"
  }
}
```

Your client should detect heartbeats and use them to confirm the connection is healthy. If you stop receiving heartbeats, the connection may have dropped -- reconnect.

***

## Message Queue

On the multiplexed endpoint, every non-market subscription has its own **1024-message outbound queue**. The `markets` channel is sized separately for its higher message volume. If a subscription or fan-out queue cannot accept a live frame, the server closes the connection rather than let it continue with a silent gap. When possible, it first sends:

```json theme={null}
{
  "type": "error",
  "code": "buffer_overflow",
  "message": "WebSocket client is not reading fast enough; reconnect and catch up via REST."
}
```

The following close frame has reason `buffer_overflow:reconnect_and_catchup`. Reconnect, re-send every subscription, and restore current state before applying new deltas. For `stats`, fetch both event game-stat REST resources and merge newly buffered deltas because snapshots and replay are unsupported.

Keep handlers fast and offload heavy work asynchronously. Narrow each subscription with the supported sport, event, market, and affiliate filters to reduce queue pressure.

***

## Client Examples

Install `ws` with `npm install ws` for the Node.js examples. The Python example
uses `websockets>=14`, where `additional_headers` adds HTTP headers to the
WebSocket handshake.

<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 = new WebSocket(
    "wss://therundown.io/api/v2/ws/markets?sport_ids=4&market_ids=1,2,3",
    { headers: { "X-TheRundown-Key": API_KEY } }
  );

  ws.on("open", () => {
    console.log("Connected to TheRundown WebSocket");
  });

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

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

    const d = msg.data;
    console.log(
      `Event ${d.event_id} | market=${d.market_id} aff=${d.affiliate_id}`
    );
    console.log(
      `  line=${d.line} price=${d.price} (was ${d.previous_price}, delta=${d.price_delta})`
    );
  });

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

  ws.on("close", (code, reason) => {
    console.log(`Disconnected: code=${code} reason=${reason}`);
    // Implement reconnection logic here
  });
  ```

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

  API_KEY = os.environ["THERUNDOWN_API_KEY"]
  URL = "wss://therundown.io/api/v2/ws/markets?sport_ids=4"

  async def listen():
      async with websockets.connect(
          URL, additional_headers={"X-TheRundown-Key": API_KEY}
      ) as ws:
          print("Connected to TheRundown WebSocket")
          async for message in ws:
              msg = json.loads(message)

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

              d = msg["data"]
              print(
                  f"Event {d['event_id']} | market={d['market_id']} aff={d['affiliate_id']}"
              )
              print(
                  f"  line={d['line']} price={d['price']}"
                  f" (was {d['previous_price']}, delta={d['price_delta']})"
              )

  asyncio.run(listen())
  ```

  ```javascript Node.js 22+ (ws) with Reconnection theme={null}
  const WebSocket = require("ws");

  const API_KEY = process.env.THERUNDOWN_API_KEY;
  if (!API_KEY) throw Error('Set THERUNDOWN_API_KEY');

  const URL = "wss://therundown.io/api/v2/ws/markets?sport_ids=4";

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

    ws.on("open", () => console.log("Connected"));

    ws.on("message", (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.meta?.type === "heartbeat") return;
      const d = msg.data;
      console.log(`Update: event=${d.event_id} market=${d.market_id} price=${d.price}`);
    });

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

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

  connect();
  ```
</CodeGroup>

***

<Info>
  If WebSocket is not an option for your architecture, use the [REST delta endpoints](/guides/efficient-polling) to poll for changes efficiently. For the full list of market types you can filter on, see [Market IDs](/reference/markets).
</Info>

## Best Practices

<AccordionGroup>
  <Accordion title="Filter to reduce volume">
    The unfiltered market feed can be very high volume. Always apply `sport_ids`, `market_ids`, or `event_ids` filters to receive only the data you need. On the multiplexed endpoint, queue overflow closes the connection and requires a reconnect plus state recovery.
  </Accordion>

  <Accordion title="Implement automatic reconnection">
    WebSocket connections can drop due to network issues, server deployments, idle timeouts, or multiplexed queue overflow. Always implement reconnection logic with exponential backoff (e.g., 1s, 2s, 4s, 8s, max 30s). After `buffer_overflow:reconnect_and_catchup`, restore current state from REST before applying new deltas.
  </Accordion>

  <Accordion title="Use heartbeats for health monitoring">
    If you have not received any message (including heartbeats) for 30+ seconds, assume the connection is dead and reconnect. Do not wait for the WebSocket `close` event, as it may not fire reliably in all network conditions.
  </Accordion>

  <Accordion title="Process messages asynchronously">
    Keep your message handler fast. Parse the JSON and push work to a queue or separate processing thread. On the multiplexed endpoint, a slow handler can fill a subscription's outbound queue, causing the server to close the connection rather than continue after a silent gap.
  </Accordion>
</AccordionGroup>


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