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

# Price Stream

> Live prices over a WebSocket.

Live prices over a WebSocket. This section is the contract — everything below is what the
server actually sends.

### Connecting

```
wss://builder.prod.bedrock.ostium.io/v1/prices/stream?pairs=BTC-USD,ETH-USD
```

`?pairs=` is optional; omit it to receive every asset. No authentication, though the handshake is
capped — see **Limits**. Browser clients are subject to an origin allowlist; a rejected upgrade is
closed with a bare `403`.

### Server messages

One snapshot on connect, carrying the `seq` baseline for every asset:

```json theme={null}
{ "type": "snapshot", "seq": { "BTC-USD": 41 }, "data": [{ "pair": "BTC-USD", "bid": 1, "mid": 1, "ask": 1 }] }
```

Then one frame per tick:

```json theme={null}
{ "type": "tick", "seq": 42, "data": { "pair": "BTC-USD", "bid": 1, "mid": 1, "ask": 1 } }
```

`data` is byte-identical to a row from `GET /v1/prices` — `seq` sits on the frame, not inside it,
so the tick payload stays the same shape as REST.

| `type`     | Sent when                                                        |
| ---------- | ---------------------------------------------------------------- |
| `snapshot` | Once, on connect                                                 |
| `tick`     | A price updates                                                  |
| `gap`      | Ticks were dropped for you — precedes the next frame you receive |
| `ack`      | Your `subscribe`/`unsubscribe` was applied                       |
| `error`    | Your message was rejected; the filter is unchanged               |

### Client messages

```json theme={null}
{ "type": "subscribe",   "pairs": ["EUR-USD"] }
{ "type": "unsubscribe", "pairs": ["EUR-USD"] }
```

Every message gets exactly one reply — an `ack` carrying `pairCount` (the number of assets you now
receive; `null` is all of them, `0` is none), or an `error` carrying a `code` of `malformed`,
`unknown_type`, `invalid_pairs`, `no_filter` or `rate_limited`.

```json theme={null}
{ "type": "ack",   "for": "subscribe", "pairCount": 2 }
{ "type": "error", "code": "no_filter", "message": "..." }
```

Filters only widen. `subscribe` on an unfiltered connection is a no-op acked with
`"pairCount": null` — to narrow, reconnect with `?pairs=`. `unsubscribe` needs a filtered
connection and is rejected with `no_filter` otherwise. Unsubscribing your last asset leaves you
receiving nothing (`"pairCount": 0`), which `subscribe` recovers without reconnecting.

### Detecting loss

Delivery is best effort, but loss is **detectable**. If more than 1 MB is buffered for a slow
client, ticks are dropped rather than queued — and you are told:

```json theme={null}
{ "type": "gap", "dropped": 17 }
```

`seq` is a per-asset counter, so a jump between consecutive `tick` frames for one asset means you
missed that many. Baseline each asset from the `snapshot`, then compare.

Two limits on what `seq` can tell you. It is **per connection** — process-wide, not global, so it
does not survive a reconnect and is not comparable across replicas. And there is **no replay**: the
stream tells you that you fell behind, not what you missed, so recover by re-reading
`GET /v1/prices`.

Assets with no trading schedule are omitted entirely, from the snapshot and from ticks. A missing
asset is not a signal that its market is closed.

### Limits

Per pod, so the effective ceiling scales with replica count. Read the headers on a rejected
upgrade rather than hard-coding these.

| Limit                                 | Default | Rejected with                   |
| ------------------------------------- | ------- | ------------------------------- |
| Connections per IP                    | 20      | `429`                           |
| Connections in total                  | 500     | `503`                           |
| Upgrades per IP per minute            | 60      | `429`                           |
| Client messages per socket per minute | 120     | `error`, `code: "rate_limited"` |

Rejected upgrades carry `Retry-After`.

### Heartbeat

The server pings every 30 seconds and terminates a connection that has not ponged since the
previous ping. Browsers pong automatically; other clients need a library that does. On shutdown the
server closes with `1001 going away` after draining.
