Streaming
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 RESTfetchTickerverb. See 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 below. -
Endpoint path:
/v1/stream(WebSocket upgrade). -
Venues without a public stream (or without a native trade channel) answer an honest
NOT_SUPPORTEDerror without closing the socket — never a fake stream.
Auth handshake
Section titled “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 closes4001.
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)
Section titled “Wire protocol (JSON text frames)”Client → server:
// `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:
{ "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:
snapshotis the first frame after (re)subscribe and after backpressure coalescing;updatemarks 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. subscribeAllrequires a venue-wide firehose upstream; venues without one answer an honestNOT_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?
Section titled “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
Section titled “Data Feeds streaming”subscribeFeedTicker streams auxiliary reference tickers — the WebSocket analogue of
the REST GET /api/feeds/{feed}/fetchTicker 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 carrysourceMetadata.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 disclosessourceMetadata.transport = "poll"so you always know it is poll-derived, not a push.
The official SDK wraps the handshake:
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).
Cross-venue arbitrage streaming
Section titled “Cross-venue arbitrage streaming”subscribeArbitrage streams the executable-arbitrage surface — the WebSocket analogue
of the REST fetchArbitrage 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.
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.
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
Section titled “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
Section titled “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.