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

# WebSocket stream

> [!NOTE]
>
> The stream is not live yet. Connections are refused until it is enabled.

The WebSocket stream at `wss://live.trendlinelabs.ai/public/v1/stream` sends live book, price and trades
frames for the markets you subscribe to: book and price frames on the venues whose order books are served,
trades frames on the venues whose trades are served ([Venues](/guides/venues)). It needs a key granted
`market:read`, sent once, at the handshake, as `Authorization: Bearer <key>`. The stream takes no query parameters. The stream is not live yet: until it is
enabled, a handshake is refused.

Use the stream when you need every change as it happens; use the REST routes for a one-off read, history
(candles) and anything you poll. Both read the same market data.

## Connect

```python theme={null}
# pip install "websockets>=14"
import asyncio, json, os, websockets

async def main():
    url = "wss://live.trendlinelabs.ai/public/v1/stream"
    headers = {"Authorization": f"Bearer {os.environ['TRENDLINE_API_KEY']}"}
    async with websockets.connect(url, additional_headers=headers) as ws:
        await ws.send(json.dumps({"op": "subscribe", "id": "sub-1", "channels": [
            {"type": "book", "market": "mk-btc-100k-2026", "venue": "kalshi", "depth": 3},
            {"type": "trades", "market": "mk-btc-100k-2026", "venue": "kalshi"},
        ]}))
        async for raw in ws:
            print(json.loads(raw))

asyncio.run(main())
```

Browsers cannot set the `Authorization` header on a WebSocket, so connect from your server.

## Subscribe

Send JSON messages with an `op` and your own `id`; the answer to each echoes that `id`.

| op | fields | answer |
| - | - | - |
| subscribe | `id`, `channels`: 1 to 50 of `{type, market, venue, depth}` | `subscribed`, one result per channel |
| unsubscribe | `id`, `subs`: 1 to 50 sub ids | `unsubscribed` |
| ping | `id` | `pong` |

A channel's `type` is `book` (the order book to `depth` levels per side, 1 to 10, default 10), `price`
(the top of book on each side) or `trades` (prints). `market` is the market's `universal_id` and
`venue` is the venue its leg trades on, as on the REST routes. A message is at most 16384 bytes, and a
connection may send 10 messages a second (bursts to 20).

```json theme={null}
{"op": "subscribe", "id": "sub-1", "channels": [{"type": "book", "market": "mk-btc-100k-2026", "venue": "kalshi", "depth": 3}]}
```

```json theme={null}
{"v": 1, "type": "subscribed", "seq": 1, "ts": 1792511666597, "id": "sub-1",
 "results": [{"channel": {"type": "book", "market": "mk-btc-100k-2026", "venue": "kalshi", "depth": 3}, "sub": "s6ko7kvvckl2n"}]}
```

Each result carries a `sub` id for that channel, or an `error` (see [Codes in frames](#codes-in-frames)).

## Frames

Every frame has `v` (the protocol version, 1), `type`, `seq` (rising by exactly 1 per frame on the
connection) and `ts` (when it was sent, ms). Data frames carry the `sub` they belong to.

`book`: the whole book to your depth, both outcomes, with the same freshness fields as the REST order book.

```json theme={null}
{"v": 1, "type": "book", "seq": 2, "ts": 1792511666597, "sub": "s6ko7kvvckl2n", "data": {
  "universal_id": "mk-btc-100k-2026", "venue": "kalshi", "venue_market_id": "KXBTC-26DEC31-B100000",
  "yes": {"bids": [{"price": 0.304, "size": 14.0}, {"price": 0.303, "size": 13.0}, {"price": 0.302, "size": 12.0}],
          "asks": [{"price": 0.6, "size": 10.0}, {"price": 0.601, "size": 11.0}, {"price": 0.602, "size": 12.0}]},
  "no": {"bids": [{"price": 0.384, "size": 14.0}, {"price": 0.383, "size": 13.0}, {"price": 0.382, "size": 12.0}],
         "asks": [{"price": 0.7, "size": 10.0}, {"price": 0.701, "size": 11.0}, {"price": 0.702, "size": 12.0}]},
  "no_source": "book", "ts_ms": 1792511663593, "age_ms": 3000, "max_age_s": 1200, "as_of_ms": 1792511666593}}
```

`price`: the best bid and ask on each side.

```json theme={null}
{"v": 1, "type": "price", "seq": 4, "ts": 1792511666597, "sub": "stiktkexkxk5g", "data": {
  "universal_id": "mk-btc-100k-2026", "venue": "kalshi", "venue_market_id": "KXBTC-26DEC31-B100000",
  "yes": {"bid": {"price": 0.304, "size": 14.0}, "ask": {"price": 0.6, "size": 10.0}},
  "no": {"bid": {"price": 0.384, "size": 14.0}, "ask": {"price": 0.7, "size": 10.0}},
  "no_source": "book", "ts_ms": 1792511663593, "age_ms": 3000, "max_age_s": 1200, "as_of_ms": 1792511666593}}
```

`trades`: the first frame is a snapshot of recent trades (`snapshot: true`), then each new print.

```json theme={null}
{"v": 1, "type": "trades", "seq": 8, "ts": 1792511667797, "sub": "sz5ioqxa6zent", "data": {
  "universal_id": "mk-btc-100k-2026", "venue": "kalshi", "venue_market_id": "KXBTC-26DEC31-B100000",
  "snapshot": false, "gap": false, "tape_ts_ms": null,
  "trades": [{"ts_ms": 1792511667793, "outcome": "YES", "notional_usd": 10.5}]}}
```

`status`: a subscription's state, `live` once frames flow, or a code from the table below.

```json theme={null}
{"v": 1, "type": "status", "seq": 3, "ts": 1792511666597, "sub": "s6ko7kvvckl2n", "state": "live", "code": null, "message": null}
```

The server also sends a `heartbeat` every 15 seconds, and a `pong` for each `ping`.

## Handshake refusals

A refused handshake is an HTTP answer carrying the same error envelope as the REST routes. Its auth
refusals (unauthorized, access\_not\_granted, tier\_required, insufficient\_scope, ip\_not\_allowed, rate\_limited) are
described on [Errors](/guides/errors).

| status | code | meaning |
| - | - | - |
| 401 | unauthorized | no key, a key in the query string, or a key that is unknown, revoked or expired |
| 422 | invalid\_request | the handshake carried query parameters |
| 429 | connection\_limit | the key already holds as many stream connections as its plan allows |
| 503 | stream\_capacity | this server is at its connection capacity; retry after `Retry-After` |
| 503 | dependency\_unavailable | one of our own stores could not be read; retry later |

## Codes in frames

Frame codes carry no HTTP status. Each arrives in the frame named in the first column.

| frame | code | meaning |
| - | - | - |
| subscribed (error) | not\_found | no such market |
| subscribed (error) | venue\_not\_listed | the market has no leg on that venue |
| subscribed (error) | venue\_unsupported | the stream does not serve that venue |
| subscribed (error) | market\_closed | the market's leg on that venue no longer trades |
| subscribed (error) | subscription\_limit | the connection already holds as many subscriptions as the key's plan allows |
| subscribed (error) | stream\_capacity | this server holds as many subscriptions as it can; subscribe again later |
| subscribed (error) | upstream\_unavailable | the venue's data could not be read |
| unsubscribed (error) | unknown\_sub | an id this connection does not hold |
| error | invalid\_request | a malformed or unknown message |
| status | upstream\_unavailable | the venue's data is unreadable or older than its bound, or (Limitless) the venue's feed has applied no update in the last 120 s; frames resume when it recovers |
| status | resyncing | the book is being rebuilt after a gap; the next book frame is complete again |
| status | dependency\_unavailable | one of our own stores could not be read (when the subscription started, or for a Limitless liveness check); frames resume when it can |
| status | stream\_stalled | the stream could not judge the data's age in time; frames resume when it can |
| status | internal\_error | an unexpected failure on our side |
| status (closed) | market\_closed | the market's leg closed; the subscription ends |
| status (closed) | cleared | the venue cleared the market's book; the subscription ends |

## Close codes

| code | when |
| - | - |
| 4401 | the key was revoked, expired or became unknown |
| 4403 | the key owner's plan no longer includes the stream, or the account's API access was withdrawn |
| 4408 | slow consumer: a frame not taken in time, more frames pending than the connection's bound allows, or the longest-unread closed statuses when the server holds more than its cap of them |
| 4429 | too many messages, too many channel changes for the key across its connections, or the connection lost its slot |
| 4503 | one of our own stores failed while re-checking the key or the connection's slot |
| 1009 | a message over the size limit |
| 1011 | an unexpected failure on our side |
| 1012 | the server is restarting; reconnect |

## Reading the stream

Treat every book and price frame as complete; append trades, and when a trades frame says
`gap: true`, read the REST trades route if you need the missing prints. A Limitless trade can arrive
about a minute after its `ts_ms` (an estimate from one observed trade). Limitless trades subscriptions
are not affected by the Limitless liveness check that applies to book and price subscriptions. On a gap in `seq`, a close,
or no heartbeat for two heartbeat intervals, reconnect and subscribe again. Read your frames as they
arrive: frames you have not read count against your connection's bound, and unsubscribing lowers
that bound at once, so a client that stops reading can be closed 4408. Closed statuses not yet read are
also capped across the server (`closing_cap`): past it, the connection whose unread ones have waited longest is
closed 4408, never one for statuses that have just closed.

`GET https://live.trendlinelabs.ai/public/v1/stream/protocol` returns the protocol's current numbers: the message
and channel-change rates, the heartbeat interval, the connection's bound and the closed-status cap.

The free plan allows 2 open stream connections per key at a time, counted across all our stream
servers, and 50 subscriptions per connection. A third connection is refused at the handshake with 429 connection\_limit,
and a 51st subscription on one connection answers subscription\_limit.


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