> ## Documentation index
> Fetch the complete documentation index at: https://docs.predictefy.com/llms.txt
> Use it to discover every available page before exploring further.

# Streaming

> The live Predictefy WebSocket service — endpoint, protocol, auth handshake, and close codes.

:::note[Live, capability-qualified]
Use the WebSocket origin shown in your developer dashboard and connect to
`/v1/stream`. Venue and channel support is capability-qualified: unsupported
subscriptions return an honest `NOT_SUPPORTED` frame instead of a synthetic stream.
:::

Predictefy streams three things over one WebSocket, one auth handshake, and one metered
connection:

- **Prediction-market streaming** — live venue **order books and trades**. Multiple
  clients watching the same market share **one** upstream venue subscription, so you never
  burn a venue's rate limits by scaling out consumers.
- **Data Feeds streaming** — auxiliary **Binance/Chainlink reference tickers**
  (`subscribeFeedTicker`), the streaming analogue of the REST `fetchTicker` verb. See
  [Data Feeds streaming](#data-feeds-streaming) below.
- **Cross-venue arbitrage streaming** — one shared **executable-arbitrage surface**
  (`subscribeArbitrage`), recomputed server-side on a disclosed cadence rather than tick by
  tick, and gated on the same plan feature as the REST verb. See
  [Cross-venue arbitrage streaming](#cross-venue-arbitrage-streaming) below.

- Endpoint path: `/v1/stream` (WebSocket upgrade).
- Venues without a public stream (or without a native trade channel) answer an **honest
  `NOT_SUPPORTED` error without closing the socket** — never a fake stream.

## Auth handshake

Connect with the same `pk_live_…` API key as the REST API:

- `Authorization: Bearer pk_live_…` header (preferred), or
- a **first-frame auth message** for browser clients (which cannot set WebSocket
  headers): `{ "op": "auth", "apiKey": "pk_live_…" }`. It must be the first frame,
  within 10 seconds, or the socket closes `4001`.

**Keys are never read from the URL** — `?apiKey=` is deliberately unsupported (raw keys
in URLs leak into proxy and observability logs).

Auth is checked **before any venue subscription is created**. Failures close the socket:

| Close code | Meaning                                                                                          |
| ---------- | ------------------------------------------------------------------------------------------------ |
| `1001`     | Server shutting down (going away).                                                               |
| `1008`     | Policy violation — pre-auth message budget (count/bytes) exceeded.                               |
| `1009`     | Inbound frame exceeded the payload size cap.                                                     |
| `4001`     | `UNAUTHORIZED` — missing, unknown, or revoked API key.                                           |
| `4002`     | `INSUFFICIENT_CREDITS` — balance cannot cover the next connection-minute.                        |
| `4003`     | `PLATFORM_UNAVAILABLE` — metering store unreachable. **Fail-closed**: never an unmetered stream. |
| `4004`     | `CONNECTION_LIMIT` — global or per-account concurrent-connection cap reached.                    |
| `4008`     | `RATE_LIMITED` — too many failed auth attempts from this IP.                                     |

Idle peers that stop answering heartbeat pings are terminated.

## Wire protocol (JSON text frames)

Client → server:

```jsonc
// `marketId` is the VENUE-NATIVE book id (see "Which id goes in marketId?"
// below) — for polymarket the CLOB asset/token id = the outcome's `outcomeId`.
{ "op": "subscribe",      "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
{ "op": "unsubscribe",    "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
{ "op": "subscribe",      "channel": "trades",    "venue": "<venue>",    "marketId": "<market>" }
{ "op": "subscribeAll",   "channel": "orderbook", "venue": "<venue>" }
{ "op": "unsubscribeAll", "channel": "orderbook", "venue": "<venue>" }
// Data Feeds reference tickers — feed + symbol, no venue/marketId:
{ "op": "subscribeFeedTicker",   "feed": "binance",   "symbol": "BTC/USDT" }
{ "op": "unsubscribeFeedTicker", "feed": "chainlink", "symbol": "BTC/USD" }
// Cross-venue executable arbitrage — no venue/marketId, no fields at all:
{ "op": "subscribeArbitrage" }
{ "op": "unsubscribeArbitrage" }
```

Server → client:

```jsonc
{ "type": "subscribed",   "channel": "orderbook", "venue": "polymarket", "marketId": "…" }
{ "type": "unsubscribed", "channel": "orderbook", "venue": "polymarket", "marketId": "…" }
// books: full-state frames; prices are probabilities in [0,1]
{ "type": "snapshot", "venue": "polymarket", "marketId": "…",
  "data": { "bids": [{ "price": 0.4, "size": 10 }], "asks": [], "timestamp": 1780000000000 },
  "ts": 1780000000123 }
{ "type": "update",   /* same shape as snapshot */ }
{ "type": "trade",    "venue": "…", "marketId": "…", "data": { /* trade */ }, "ts": 1780000000123 }
// Data Feeds ticker ack + data (feed/symbol, not venue/marketId):
{ "type": "subscribed", "channel": "feedTicker", "feed": "binance", "symbol": "BTC/USDT" }
{ "type": "feedTicker", "feed": "binance", "symbol": "BTC/USDT",
  "data": { "symbol": "BTC/USDT", "last": 61714.63, "asOf": "…",
            "provenance": { "source": "binance-ws" },
            "sourceMetadata": { "transport": "websocket" } },
  "ts": 1780000000123 }
{ "type": "error",    "code": "NOT_SUPPORTED", "message": "…", "venue": "…", "marketId": "…" }
```

Notes:

- `snapshot` is the first frame after (re)subscribe and after backpressure coalescing;
  `update` marks live ticks. Both carry the **full book**, never deltas.
- Protocol-level problems (`BAD_MESSAGE`, `NOT_SUPPORTED`, `NOT_SUBSCRIBED`,
  `SUBSCRIPTION_LIMIT`) are **non-fatal**: the socket stays open.
- `subscribeAll` requires a venue-wide firehose upstream; venues without one answer an
  honest `NOT_SUPPORTED`.
- Trades stream only where the venue has a native fills channel.
- Active logical subscriptions are capped by plan: 2 Free, 20 Builder, 100 Pro,
  and 500 Scale. A lower service safety cap can also apply. Exceeding either returns
  a non-fatal `SUBSCRIPTION_LIMIT`.

## Which id goes in `marketId`?

`marketId` is the **venue-native** id for the exact book you want — it is passed
straight through to the venue and is **not** always the unified `marketId` from
`fetchMarkets`. For **polymarket** it is the CLOB asset/token id, which the unified
API returns as the outcome's **`outcomeId`** (`fetchMarket` →
`outcomes[].outcomeId`) — the same id you pass to `fetchOrderBook`. Other venues
use their own native id (for example **hyperliquid** uses the coin symbol like
`BTC`).

Subscribing with the wrong id (e.g. a polymarket market-level `marketId`) returns a
`subscribed` ack and then **zero data frames** — the venue never recognizes it as a
book, so it looks identical to a quiet market. When in doubt, use the value you'd
pass to `fetchOrderBook`.

## Data Feeds streaming

`subscribeFeedTicker` streams auxiliary **reference tickers** — the WebSocket analogue of
the REST [`GET /api/feeds/{feed}/fetchTicker`](/api/feeds/) verb. This is **reference
data, separate from prediction-market venues** (it never touches markets, clusters, or
history) and rides the same auth and per-connection-minute credits as venue subscriptions.

- **`binance`** — spot reference prices over Binance's public market-data WebSocket;
  frames carry `sourceMetadata.transport = "websocket"`. Symbols: `BTC/USDT`, `ETH/USDT`,
  `SOL/USDT`, `XRP/USDT`.
- **`chainlink`** — on-chain oracle prices, **poll-backed** (there is no Chainlink push
  stream): the service polls the on-chain feed on a bounded interval and emits only when
  the round advances. Every frame **discloses `sourceMetadata.transport = "poll"`** so you
  always know it is poll-derived, not a push.

The official SDK wraps the handshake:

```ts
import { Predictefy } from '@predictefy/sdk';

const client = new Predictefy({ apiKey: process.env.PREDICTEFY_API_KEY });

const close = client.watchFeedTicker({ feed: 'binance', symbol: 'BTC/USDT' }, (ticker) =>
  console.log(ticker.last, ticker.sourceMetadata?.transport),
);
// later: close();
```

The key is sent over the safe first-frame handshake — **never** a `?apiKey=` URL. An
unknown feed or unsupported symbol answers a non-fatal `NOT_SUPPORTED` (the socket stays
open).

:::caution[Poll-backed Chainlink]
Chainlink feed streaming is **poll-backed**, not a native push stream. Full Data
Feeds parity is not claimed for this integration. No latency guarantees are made.
:::

## Cross-venue arbitrage streaming

`subscribeArbitrage` streams the **executable-arbitrage surface** — the WebSocket analogue
of the REST [`fetchArbitrage`](/guides/cross-venue/) verb. It is cross-venue, so it takes no
`venue` and no `marketId`, and **every frame is the whole current surface**, never a delta.
The frame shape is in the [WebSocket API reference](/reference/streaming/).

**The cadence is honest, not tick-by-tick.** This is one shared server-side recompute:
`intervalMs` is the real cadence (3000 ms by default). A frame is published only when the
priced surface actually changed — re-reading the same books at a fresher timestamp is not a
change — plus a heartbeat republish so a quiet market stays distinguishable from a dead
publisher. That heartbeat is a genuine recompute against live books rather than a replay, so
its `computedAt` is truthful.

**Read `heartbeatMs` off the frame; it is a computed bound, not a fixed constant.** A
republish can only happen on a recompute tick, so the advertised value is the first tick at or
after the server's 30000 ms target: 30000 ms at the default 3000 ms interval, but 40000 ms if
the interval were 20000 ms. A healthy publisher republishes at least that often, so
**sustained silence beyond the `heartbeatMs` you were actually sent means a publisher outage
or an entitlement teardown** (below), not a quiet market.

**You can measure the lane yourself.** Each frame carries `computedAt` (the pass finished),
`publishedAt` (the server handed it to its relay), and the envelope's `ts` (written to your
socket). The two gaps are sub-millisecond in practice — nothing on that path buffers, batches,
or waits for a timer. The recompute interval is the lane's only deliberate delay, and it is
there to bound upstream venue API cost, not because push is unwanted.

**The label discipline is the REST verb's.** A row is labeled `arbitrage` only with positive
net edge, both legs depth-executable at the requested size against live asks, and resolution
equivalence verified. Everything else is served as an _indicative price discrepancy_ with the
per-leg `reasons` codes explaining why. The surface is never trimmed down to the winners — the
indicative rows are part of it, with their evidence.

A new subscriber gets the current surface right after its `subscribed` ack when one is
available; otherwise the first frame arrives on the next recompute. The channel is gated on
the same `arbitrage` plan feature as the REST verb — a key whose plan does not include it gets
a non-fatal `PLAN_UPGRADE_REQUIRED` instead of a subscription, and the Free plan does not
include it. Deployments without the publisher answer `NOT_SUPPORTED`.

**The entitlement is re-checked while the socket is open**, roughly once a minute. If the plan
stops entitling the feature mid-stream, the arbitrage subscription is torn down and you get
the same `PLAN_UPGRADE_REQUIRED` frame; if the key itself is revoked or rotated you get
`UNAUTHORIZED` instead, since that needs re-authentication rather than an upgrade. Either way
the socket stays open and every other subscription on it keeps streaming, and a re-check that
cannot complete never interrupts you.

The SDK wraps it like the ticker lane. Pass `onError` for any long-lived watcher: an entitled
socket with nothing to report and a refused one look identical without it.

```ts
const close = client.watchArbitrage(
  ({ frame }) => {
    for (const row of frame.rows) {
      console.log(row.label, row.executable, row.reasons);
    }
  },
  { onError: (e) => console.error(e.code, e.message) },
);
// later: close();
```

## Backpressure

A slow consumer never causes unbounded buffering:

- **Order books are coalesced** — intermediate updates are dropped and only the latest
  book per subscribed market is parked; when your socket drains, that latest book
  arrives as a single `snapshot`.
- **Trades are dropped** (not parked) while backpressured — the trade stream is lossy
  under backpressure by design.
- **Feed tickers are dropped** (not parked) while backpressured — same policy as trades.
  Reference tickers are low-frequency and the next frame supersedes the last.
- **Arbitrage frames are dropped** (never coalesced or parked) while backpressured —
  each frame is the whole current surface, so the next one supersedes anything you
  missed.

## Credits

Streaming costs **2 credits per connection-minute**, prepaid: minute #1 is charged at
connect, each subsequent minute on the minute. When your balance cannot cover the next
minute you get an `INSUFFICIENT_CREDITS` error frame, then close `4002`. See
[Credits & billing](/guides/credits/).
