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

# WebSocket Odds Stream — Live Prices per Match

> Connect to wss://passthrough.sportrix.ai/v1/pinnacle/data to stream live odds, betting cutoffs, and stake limits. Subscribe per match, apply deltas by market version, and resync after a gap.

The live stream is how you get prices and limits. Subscribe to the matches you care about and the server pushes a full snapshot for each, then only what changes afterwards. Every selection includes the market's betting cutoff and maximum accepted risk stake.

```text theme={null}
wss://passthrough.sportrix.ai/v1/pinnacle/data
```

**Scope:** `odds` · **Sportsbook grant:** `pinnacle`

## Connecting

The key is verified during the handshake, before the connection is upgraded — a rejected key never becomes a WebSocket. Send it as the `X-API-Key` header, or as a `key` query parameter where headers are not possible:

```bash theme={null}
wscat -c "wss://passthrough.sportrix.ai/v1/pinnacle/data?key=sk_your_api_key_here"
```

One connection carries as many matches as you like. Open a second connection only for redundancy, not for throughput.

<Note>
  Inbound messages are capped at **4096 bytes**. Every control message is far smaller than that; a client that exceeds it has almost certainly serialized something wrong, and the connection is closed.
</Note>

## Client → server messages

Send JSON objects. Unrecognized actions and malformed JSON are ignored silently — they do not close the connection and do not produce an error frame.

**Subscribe** to a match and immediately receive its snapshot:

```json theme={null}
{"action": "subscribe", "match_id": 1632570636}
```

**Subscribe in bulk** by passing an array. Prefer this form as soon as you are following more than a handful of matches — one frame with a thousand ids replaces a thousand frames:

```json theme={null}
{"action": "subscribe", "match_ids": [1632570636, 1632570637, 1632570638]}
```

**Subscribe to everything the key is granted**, so a client following a whole book does not have to poll [`/matches`](/passthrough/matches) first and echo the ids back. Optionally narrow to one sport:

```json theme={null}
{"action": "subscribe_all"}
{"action": "subscribe_all", "sport": 12}
```

**Unsubscribe** — single or batch. Unsubscribing a parent match also releases its specials (see below):

```json theme={null}
{"action": "unsubscribe", "match_id": 1632570636}
{"action": "unsubscribe", "match_ids": [1632570636, 1632570637]}
```

**Resync** — re-send a fresh snapshot for every match you are currently subscribed to, without changing your subscriptions:

```json theme={null}
{"action": "resync"}
```

| Field       | Type    | Description                                                                                            |
| ----------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `action`    | string  | `subscribe`, `subscribe_all`, `unsubscribe`, or `resync`                                               |
| `match_id`  | integer | Single match id from [`GET /matches`](/passthrough/matches). A `match_id` of `0` is ignored            |
| `match_ids` | array   | Batch form of `match_id`. Merged with `match_id` when both are present                                 |
| `sport`     | integer | `subscribe_all` only. Limits the subscription to one sport id. `0` or absent means every granted sport |

Each socket may hold up to **5,000** subscriptions and accept up to **5,000** ids in a single batch frame by default. A batch that exceeds the cap is truncated and answered with an `error` frame naming the limit; hitting the subscription cap silently stops applying further ids in that batch.

<Tip>
  Use `resync` after a reconnect, after a missed heartbeat, or whenever you suspect your local book has drifted. It is far cheaper than tearing down and re-subscribing every match, and it is the intended recovery path.
</Tip>

### Subscribing to a game covers its specials

A game's derivatives — player props, correct score, winning margin, odd/even, and similar — are their own matchups with their own ids and their own markets. A parent's book does not contain them, and there can be dozens per game.

**One `subscribe` for the parent match streams the parent and every one of its specials.** A subscribe on match `1632737408` returns 52 snapshots — the game itself plus its 51 derivatives — followed by live updates for all of them. Specials the sportsbook adds later are picked up automatically, without a fresh subscribe.

Every frame keeps its own `match_id`, and a special also carries `parent_id`, so a client can still route them to the right local book. Unsubscribing the parent releases the specials with it. Subscribing to a special directly is unaffected — a special has no children of its own.

## Server → client frames

Every frame is a JSON object with a `type` field.

| `type`          | When sent                                                                         | Carries                                                                                                |
| --------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `snapshot`      | On subscribe, on resync, and whenever a slow client's queued deltas are coalesced | `match` metadata plus **all** current `selections`                                                     |
| `update`        | Whenever a subscribed match changes                                               | Only the changed `selections`, plus `removed_market_keys`                                              |
| `match_removed` | The match left the book                                                           | Nothing beyond `match_id` and `ts`                                                                     |
| `heartbeat`     | Every 10 seconds                                                                  | Per-sport `feeds` health scoped to your subscriptions, plus a `feed_healthy` and `last_msg_ms` summary |
| `error`         | Subscribe to an unknown match                                                     | `error` text                                                                                           |

### `snapshot`

```json theme={null}
{
  "type": "snapshot",
  "match_id": 1632570636,
  "ts": 1785110400123,
  "match": {
    "sport_id": 12,
    "league_id": 100,
    "league_name": "CS2 - ESL Pro League",
    "home": "T1",
    "away": "FURIA",
    "start_time": "2026-07-27T18:00:00Z",
    "status": "started",
    "is_live": true,
    "type": "matchup"
  },
  "selections": [
    {
      "market_key": "s;0;m",
      "market_name": "Match - Money Line",
      "period": 0,
      "bet_name": "T1",
      "price": -120,
      "decimal": 1.833,
      "status": "unlocked",
      "version": 100,
      "cutoff_at": "2026-07-27T19:00:00Z",
      "limit": 1000
    }
  ]
}
```

The `match` object is the lightweight header — the same fields as the [match object](/passthrough/matches) minus `participants`, `version`, and `markets`. On a special it also carries `special` and `parent_id`.

Each selection carries `cutoff_at` and `limit`. The limit applies to that market and can differ across selections in the same match. Replace your local state for this match wholesale when a snapshot arrives.

### `update`

```json theme={null}
{
  "type": "update",
  "match_id": 1632570636,
  "ts": 1785110401456,
  "selections": [
    {
      "market_key": "s;0;m",
      "market_name": "Match - Money Line",
      "period": 0,
      "bet_name": "T1",
      "price": -124,
      "decimal": 1.806,
      "status": "unlocked",
      "version": 101,
      "cutoff_at": "2026-07-27T19:00:00Z",
      "limit": 1000
    }
  ],
  "removed_market_keys": ["s;1;ou;2.5"]
}
```

| Field                 | Type  | Description                                                                                                                     |
| --------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------- |
| `selections`          | array | Only selections from markets that changed. Absent when nothing changed but markets were removed                                 |
| `removed_market_keys` | array | Market keys that no longer exist. Drop every selection you hold with these `market_key` values. Absent when nothing was removed |

<Warning>
  An `update` is a **delta**, not a full picture. Merge it into the state you built from the snapshot — do not replace. This is the one place where the passthrough stream differs from the [SportriX live-scores stream](/api/websocket-messages), which sends a full snapshot on every frame.
</Warning>

When a market's prices change, **every** selection in that market is re-sent, not just the one that moved. Markets are versioned as a unit.

### `match_removed`

```json theme={null}
{"type": "match_removed", "match_id": 1632570636, "ts": 1785110480000}
```

The match is gone from the book — settled, pulled by the sportsbook, or pruned after disappearing upstream. Discard your local state for it. Your subscription is not automatically cleaned up, so send an `unsubscribe` if you track subscriptions yourself.

### `heartbeat`

```json theme={null}
{
  "type": "heartbeat",
  "ts": 1785110410000,
  "feed_healthy": true,
  "last_msg_ms": 1785110409871,
  "feeds": {
    "12": {"connected": true,  "last_msg_ms": 1785110409871},
    "29": {"connected": true,  "last_msg_ms": 1785110409402}
  }
}
```

Sent every **10 seconds** on every connection, whether or not you are subscribed to anything.

| Field          | Type    | Description                                                                                                                                                                                                                                                                                                   |
| -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ts`           | integer | Epoch milliseconds when the heartbeat was generated                                                                                                                                                                                                                                                           |
| `feeds`        | object  | Per-sport health, keyed by sport id (as a string). Contains only the sports you are subscribed to. Each entry has `connected` (boolean — is the upstream feed for that sport up) and `last_msg_ms` (epoch milliseconds of the most recent message on that sport's feed). Empty when you have no subscriptions |
| `feed_healthy` | boolean | `true` when every sport in `feeds` is connected. When you have no subscriptions, this falls back to the whole-service view — `true` only if every upstream feed Sportrix ingests is up                                                                                                                        |
| `last_msg_ms`  | integer | Epoch milliseconds of the most recent message across the sports in `feeds`. Falls back to the whole-service maximum when you have no subscriptions                                                                                                                                                            |

**Heartbeats are scoped to your subscriptions.** `feeds`, `feed_healthy`, and `last_msg_ms` only summarise the sports covered by your current `subscribe` / `subscribe_all` set. A basketball client is not tripped by a tennis outage, and a tennis outage is not masked by fresh basketball traffic. Sports that Sportrix does not ingest are simply absent from `feeds` — subscribing to a match under an unknown sport does not synthesize a feed for it.

The scope updates as you subscribe and unsubscribe: adding a new sport to your subscriptions makes it appear in `feeds` on the next heartbeat, and dropping every match for a sport removes it. Until your first subscription lands, `feeds` is empty and the top-level fields report the whole-service view so you can still tell whether Sportrix is up.

The heartbeat does two jobs:

* **Liveness.** If heartbeats stop arriving, the socket is dead even if your TCP stack has not noticed. Reconnect and `resync`.
* **Feed health.** `feed_healthy: false` means at least one of the sports you are subscribed to has a disconnected upstream and its prices are no longer being refreshed. Consult `feeds` to see which sport, and consider `connected: false` on a specific sport as a signal to suspend that sport only.

<Warning>
  A client acting on these odds should **suspend on `feed_healthy: false`** and resync when it flips back to `true`. If you route bets by sport, prefer the per-sport `feeds[sportId].connected` flag so a single sport's outage does not stall the rest of your book. The prices you hold on an unhealthy sport are not being updated in the meantime, and they will not be marked stale individually.
</Warning>

### `error`

```json theme={null}
{"type": "error", "match_id": 999999, "ts": 0, "error": "unknown match"}
```

Sent when you subscribe to a match that is not in the book. The subscription is still registered, so if that id later appears you will begin receiving its updates.

## Applying updates correctly

<Steps>
  <Step title="Key your state by market_key">
    A selection is identified by `market_key` plus `bet_name`. A market is identified by `market_key` alone, and that is the unit that carries a `version`.
  </Step>

  <Step title="Drop anything not newer">
    Ignore an incoming selection whose `version` is not greater than the version you hold for the same `market_key`. Reordering and re-delivery are both possible; version comparison makes them harmless.
  </Step>

  <Step title="Honour removed_market_keys">
    Delete every selection whose `market_key` appears in `removed_market_keys`. A removed market is gone, not locked — those are different states.
  </Step>

  <Step title="Respect status">
    A selection with `"status": "locked"` is not bettable. Keep displaying its last price if you like, but never act on it.
  </Step>

  <Step title="Cap risk at limit">
    Read `limit` from the current selection before placing or sizing a bet. It is the maximum risk stake accepted for that market and can change with a later market version.
  </Step>
</Steps>

## Slow clients get coalesced snapshots

Each connection has a bounded send queue. A client that reads slower than the book moves does **not** silently lose ticks — instead, its queued deltas for a match are collapsed into an unsolicited `snapshot` for that match.

The queue holds at most one pending item per match. When a change arrives while something for that match is still queued, the pair collapses into a "send the current state" marker, which the writer renders as a full snapshot. So a lagging client receives fewer, larger messages — never a stale price. It loses intermediate ticks it could not have acted on; it does not lose the tick that is live.

Practical consequences for a client:

* **A `snapshot` can arrive at any time**, not only in response to `subscribe` or `resync`. Treat every snapshot the same way: replace everything you hold for that match.
* The queue is bounded by your subscription count, so following 1,000 matches costs at most 1,000 pending items regardless of how fast prices move.
* A socket that generates control frames faster than it drains reads is disconnected rather than buffered without limit. In `/pinnacle/stats`, `clients_cut_slow` counts those disconnects and `messages_coalesced` counts collapses — a steadily climbing coalesce counter means the client is not draining as fast as the book moves.

Defensive practice still applies:

* Read from the socket promptly and do your processing off the read loop.
* Treat a heartbeat gap or any suspicion of drift as a reason to `resync`.

<Note>
  If you suspect your connection is being cut for lagging, contact [SportriX support](mailto:support@sportrix.ai) — the disconnect counter is recorded server-side and can be checked against your connection.
</Note>

## Reconnect checklist

1. Reconnect with backoff — the handshake re-verifies your key.
2. Re-subscribe to your matches. Each returns a fresh `snapshot`.
3. If you kept your state across the reconnect, send `{"action": "resync"}` instead of re-subscribing, and replace state from the snapshots it produces.
4. Wait for a `heartbeat` with `feed_healthy: true` before treating prices as actionable.
