StreamingLive data
WebSocket API reference
Connect to the production WebSocket endpoint:
wss://stream.predictefy.com/v1/streamThe service accepts JSON text frames. Venue names are normalized to lowercase; venue-native market ids and feed symbols keep their case. API keys are never accepted in the URL.
Authentication
Section titled “Authentication”Non-browser clients should send the API key in the upgrade request:
Authorization: Bearer pk_live_YOUR_KEYBrowser clients cannot set that header. Their first frame must arrive within 10 seconds and have this exact shape:
{ "op": "auth", "apiKey": "pk_live_YOUR_KEY" }Successful first-frame authentication returns this acknowledgement before queued subscription acknowledgements:
{ "type": "auth", "status": "ok" }Header-authenticated clients do not receive an auth acknowledgement. A missing, invalid, unknown,
revoked, or non-read-scoped key first receives an UNAUTHORIZED error frame, then the service
closes the socket with code 4001. A browser client that sends another operation before auth, or
does not authenticate before the deadline, is closed the same way.
Client subscription frames
Section titled “Client subscription frames”marketId is the venue-native upstream id. For example, a Polymarket order book uses the
outcome’s CLOB asset/token id, while Hyperliquid uses its coin symbol.
Per-market order books and trades:
{ "op": "subscribe", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }{ "op": "unsubscribe", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }{ "op": "subscribe", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }{ "op": "unsubscribe", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }A Polymarket trades subscription accepts an optional filters object selecting the trade lane:
{ "op": "subscribe", "channel": "trades", "venue": "polymarket", "marketId": "<asset_id>", "filters": { "status": "pending" }}filters.status is confirmed (the default, and the only behaviour before this option existed),
pending (the public-mempool lifecycle only), or all (native confirmed frames plus the pending
lane’s pending and dropped frames). Omitting filters is confirmed.
Validation is strict, and every failure is a non-fatal BAD_MESSAGE that leaves the socket open:
filters on a channel other than trades, filters on a venue other than polymarket, a
filters value that is not a plain object, a key other than status, or a status value outside
those three. A trades subscription is keyed by venue and market id, so changing status means
unsubscribe followed by a fresh subscribe.
Venue-wide order books are available only when the upstream implements a real firehose or multiplexed stream:
{ "op": "subscribeAll", "channel": "orderbook", "venue": "polymarket" }{ "op": "unsubscribeAll", "channel": "orderbook", "venue": "polymarket" }Reference-feed tickers use feed and symbol, not venue and marketId:
{ "op": "subscribeFeedTicker", "feed": "binance", "symbol": "BTC/USDT" }{ "op": "unsubscribeFeedTicker", "feed": "binance", "symbol": "BTC/USDT" }The venue option-price lane carries the on-chain market address separately. It is a price stream, not an order book:
{ "op": "subscribePrice", "venue": "rain", "marketId": "<market_id>", "marketAddress": "0x..." }{ "op": "unsubscribePrice", "venue": "rain", "marketId": "<market_id>", "marketAddress": "0x..." }The executable-arbitrage lane is cross-venue. Like the feed-ticker lane it carries neither venue
nor marketId — one shared surface spans every priced venue:
{ "op": "subscribeArbitrage", "executableOnly": true, "venues": ["polymarket", "kalshi"], "minEdge": 0.02 }{ "op": "subscribeArbitrage" }{ "op": "unsubscribeArbitrage" }subscribeArbitrage accepts optional filters applied server-side: executableOnly (boolean),
venues (string array of allowed venues), and minEdge (number). Omitting filters preserves the
default unfiltered surface.
Acknowledgement frames
Section titled “Acknowledgement frames”A successful per-market subscription is acknowledged before any cached snapshot or live frame:
{ "type": "subscribed", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }{ "type": "unsubscribed", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }The other acknowledgement shapes are:
{ "type": "subscribed", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }{ "type": "subscribed", "channel": "orderbook:all", "venue": "polymarket" }{ "type": "subscribed", "channel": "feedTicker", "feed": "binance", "symbol": "BTC/USDT" }{ "type": "subscribed", "channel": "price", "venue": "rain", "marketId": "<market_id>" }An unsubscribed acknowledgement uses the same fields. A Rain, XO, or PRED trade subscription
served from the configured chain-scanner tape also adds a disclosure object:
{ "type": "subscribed", "channel": "trades", "venue": "rain", "marketId": "<market_id>", "disclosure": { "provenance": "chain-scan", "latencyMs": 90000, "completeness": "<venue-specific omission disclosure>" }}A Polymarket trades subscription with filters.status of pending or all echoes the status
and discloses the mempool provenance:
{ "type": "subscribed", "channel": "trades", "venue": "polymarket", "marketId": "<asset_id>", "status": "pending", "disclosure": { "provenance": "mempool", "latencyMs": 1700, "completeness": "<pending-fill completeness disclosure>" }}status is echoed only for pending and all; a default confirmed subscription is
acknowledged exactly as before. latencyMs reports the measured lead ahead of confirmation
rather than a delay behind it.
The cross-venue arbitrage lane has nothing to echo, so its acknowledgements carry channel alone:
{ "type": "subscribed", "channel": "arbitrage" }{ "type": "unsubscribed", "channel": "arbitrage" }Order-book frames
Section titled “Order-book frames”For a per-market order-book subscription, the first book is a snapshot. Later venue ticks are
update frames. Both contain a complete book, never a delta. Prices are probabilities in [0, 1];
bids are best-first descending and asks are best-first ascending.
{ "type": "snapshot", "venue": "polymarket", "marketId": "<asset_id>", "data": { "bids": [{ "price": 0.4, "size": 10 }], "asks": [{ "price": 0.42, "size": 8 }], "timestamp": 1780000000000 }, "ts": 1780000000123}{ "type": "update", "venue": "polymarket", "marketId": "<asset_id>", "data": { "bids": [{ "price": 0.41, "size": 9 }], "asks": [{ "price": 0.43, "size": 7 }], "timestamp": 1780000000200 }, "ts": 1780000000210}When backpressure coalesces skipped book ticks, the latest complete book is sent as another
snapshot once the socket drains.
Trade frames
Section titled “Trade frames”Native and configured chain-scanner trade subscriptions share one frame shape:
{ "type": "trade", "venue": "hyperliquid", "marketId": "BTC", "data": { "id": "<trade_id>", "time": "2s ago", "timestamp": 1780000000000, "type": "Buy", "usd": 125.5, "outcome": "Yes", "outcomeIndex": 0, "shares": 10, "price": 0.55, "maker": "hyperliquid", "transactionHash": "<transaction_id>", "wallet": "0x...", "counterparty": "0x..." }, "ts": 1780000000123}outcomeIndex, wallet, and counterparty can be absent or null. usd and price can be
null on parimutuel venues where execution-time values do not exist. A chain-scanner frame adds
"provenance": "chain-scan" at the top level. Trade frames are dropped rather than buffered while
the client is backpressured.
Pending-fill trade frames (Polymarket)
Section titled “Pending-fill trade frames (Polymarket)”A subscription with filters.status of pending or all also receives public-mempool
observations of Polymarket settlements, roughly 1.7 s (p90 2.5 s) before they mine. They use the
same trade frame with four extra top-level fields: status, seenAt (ISO, when the fill was
first seen in the mempool), provenance, and then blockNumber or dropReason on the follow-up
frame. data.id is <txHash>:<fillIndex>, where index 0 is the taker fill and the makers
follow in settlement order; data.wallet is the filling order’s wallet and data.counterparty
is the other side when it is known.
{ "type": "trade", "venue": "polymarket", "marketId": "<asset_id>", "status": "pending", "seenAt": "2026-09-15T16:15:16.700Z", "provenance": "mempool", "data": { "id": "0x<transaction_hash>:1", "time": "1s ago", "timestamp": 1789500000700, "type": "Buy", "usd": 10.73, "outcome": "Yes", "outcomeIndex": 0, "shares": 29, "price": 0.37, "maker": "polymarket", "transactionHash": "0x<transaction_hash>", "wallet": "0x...", "counterparty": "0x..." }, "ts": 1789500000712}Each pending fill resolves exactly once, under the same data.id and data.transactionHash: a
confirmed frame adds blockNumber, and a dropped frame adds dropReason, either timeout
(no receipt arrived in the drop window) or reverted (the transaction mined with a failed
status, so no fill happened).
{ "type": "trade", "venue": "polymarket", "marketId": "<asset_id>", "status": "dropped", "seenAt": "2026-09-15T16:15:16.700Z", "provenance": "mempool", "dropReason": "reverted", "data": { "id": "0x<transaction_hash>:1", "time": "1m ago", "timestamp": 1789500000700, "type": "Buy", "usd": 10.73, "outcome": "Yes", "outcomeIndex": 0, "shares": 29, "price": 0.37, "maker": "polymarket", "transactionHash": "0x<transaction_hash>", "wallet": "0x...", "counterparty": "0x..." }, "ts": 1789500060000}A pending fill is an observation of an unsettled transaction, not a completed trade: about 1 in
100 never confirm. A pending subscription carries the mempool lifecycle only, while all adds
these frames to the native confirmed tape and omits the lane’s own confirmed frame, so the
native frame remains the single confirmation; match the two by data.transactionHash, since the
native frame carries its own data.id. Pending frames pause while the mempool watcher is
unavailable; the subscription stays open and no error frame is sent.
Feed-ticker and option-price frames
Section titled “Feed-ticker and option-price frames”Feed tickers carry the normalized ticker under data. Only symbol, asOf, and provenance are
always present; price, volume, timestamp, datetime, and sourceMetadata fields are present only
when the upstream proves them.
{ "type": "feedTicker", "feed": "binance", "symbol": "BTC/USDT", "data": { "symbol": "BTC/USDT", "last": 61714.63, "asOf": "2026-08-13T12:00:00.000Z", "provenance": { "source": "binance-ws" }, "sourceMetadata": { "transport": "websocket" } }, "ts": 1780000000123}The option-price lane currently relays the normalized Rain frame:
{ "type": "price", "venue": "rain", "marketId": "<market_id>", "marketAddress": "0x...", "data": { "provider": "rain", "marketId": "<market_id>", "marketAddress": "0x...", "prices": [ { "choiceIndex": 0, "label": "Yes", "price01": 0.55, "rawPrice": "550000000000000000" } ], "triggeredBy": { "eventName": "<event_name>", "transactionHash": "0x...", "blockNumber": "123", "logIndex": 4 }, "asOfISO": "2026-08-13T12:00:00.000Z" }, "ts": 1780000000123}choiceIndex, label, and rawPrice can be null. Every field inside triggeredBy can also be
null, and the whole object can be null. Feed-ticker and option-price frames are dropped rather
than buffered under backpressure.
Arbitrage frames
Section titled “Arbitrage frames”The arbitrage lane relays one shared server-side recompute of the cross-venue executable-arbitrage
surface — the streaming twin of GET /api/router/fetchArbitrage.
There is one client operation for this lane: subscribeArbitrage. It delivers the complete selected
surface — the full surface when no filters are set — as kind: "snapshot" and sequence-ordered
kind: "delta" frames. A complete snapshot is due every 30 seconds; on that recompute pass it is
sent immediately before the pass’s delta. Every successful recompute sends a delta, including an
empty upserts/removes delta when no row changed.
Every arbitrage data message has type: "arbitrage", the arbitrage frame in data, and a
socket-write ts. Both data variants carry exchange, seq, computedAt, publishedAt,
intervalMs, heartbeatMs, contracts, limit, and part: { i, n }. Snapshot and delta frames
are split into messages no larger than 200KiB when needed, and every part of one logical frame
shares its seq.
A new or reconnecting subscriber receives the current coherent snapshot after its subscribed
acknowledgement. If a client falls behind, the relay repairs it with a current snapshot before
resuming incremental delivery.
Each base cluster emits every ordered cross-venue pair, up to 90 rows. One batched live-book
read per venue supplies the selected outcome books reused across those pairs. A row’s
clusterId is the composite ${clusterId}:${venueA}:${venueB}. Base cluster ids can contain
:, so strip the last two colon-delimited segments to recover the base id.
Snapshot frame
Section titled “Snapshot frame”{ "type": "arbitrage", "data": { "exchange": "router", "kind": "snapshot", "seq": 1042, "computedAt": "2026-08-31T12:00:00.000Z", "publishedAt": "2026-08-31T12:00:00.004Z", "intervalMs": 3000, "heartbeatMs": 30000, "contracts": 100, "limit": 500, "part": { "i": 1, "n": 1 }, "rows": [ { "clusterId": "cluster:real:polymarket:kalshi", "question": "Will Team A win?", "similarity": 0.92, "contracts": 100, "legs": { "buyYes": { "venue": "polymarket", "canonicalMarketId": "polymarket:real", "side": "yes", "executable": true, "reasons": [], "vwap": 0.41, "cost": 41, "fee": 0, "filled": 100, "fullyFilled": true }, "buyNo": { "venue": "kalshi", "canonicalMarketId": "kalshi:real", "side": "no", "executable": true, "reasons": [], "vwap": 0.45, "cost": 45, "fee": 0.7, "filled": 100, "fullyFilled": true } }, "resolution": { "compatible": true, "reason": "", "auditReasons": [] }, "settlementFee": 0, "totalCost": 86.7, "payout": 100, "netEdge": 13.3, "roi": 0.1534, "resolutionEquivalence": "verified", "matchScore": 100, "matchBand": "verified", "matchDifferences": [], "executable": true, "reasons": [], "label": "arbitrage", "asOf": "2026-08-31T11:59:58.000Z" } ] }, "ts": 1780000000123}Delta frame
Section titled “Delta frame”{ "type": "arbitrage", "data": { "exchange": "router", "kind": "delta", "seq": 1043, "computedAt": "2026-08-31T12:00:03.000Z", "publishedAt": "2026-08-31T12:00:03.004Z", "intervalMs": 3000, "heartbeatMs": 30000, "contracts": 100, "limit": 500, "part": { "i": 1, "n": 1 }, "upserts": [ { "clusterId": "cluster:real:polymarket:kalshi", "question": "Will Team A win?", "similarity": 0.92, "contracts": 100, "legs": { "buyYes": { "venue": "polymarket", "canonicalMarketId": "polymarket:real", "side": "yes", "executable": true, "reasons": [], "vwap": 0.41, "cost": 41, "fee": 0, "filled": 100, "fullyFilled": true }, "buyNo": { "venue": "kalshi", "canonicalMarketId": "kalshi:real", "side": "no", "executable": true, "reasons": [], "vwap": 0.45, "cost": 45, "fee": 0.7, "filled": 100, "fullyFilled": true } }, "resolution": { "compatible": true, "reason": "", "auditReasons": [] }, "settlementFee": 0, "totalCost": 86.7, "payout": 100, "netEdge": 13.3, "roi": 0.1534, "resolutionEquivalence": "verified", "matchScore": 100, "matchBand": "verified", "matchDifferences": [], "executable": true, "reasons": [], "label": "arbitrage", "asOf": "2026-08-31T12:00:02.000Z" } ], "removes": ["cluster:stale:polymarket:kalshi"] }, "ts": 1780000003123}Each row preserves matchScore (an integer from 0 to 100 or null), matchBand
(verified, minor, material, hidden, or unscored), and the full matchDifferences
list of { kind, detail, points }. That list includes scoring-only confidence deductions;
its points sum to 100 - matchScore for every visible numeric score. These fields are the
same as REST, in both snapshots and delta upserts. Hidden rows are removed before publication.
The relay preserves the honesty rule executable implies
resolutionEquivalence: "verified". When matchBand is present, an unknown band or a score
that is neither null nor an integer from 0 to 100 drops the frame with the logged reason
invalid-match-score. Older frames that omit the band remain compatible. See the
match score tables for deductions and bands.
A row is labeled arbitrage only when it has positive net edge, both legs are depth-executable at
the requested size against live asks, and resolution equivalence is verified. Every other visible row is
served as indicative price discrepancy with the per-leg reasons codes explaining why. Rows are
never filtered down to the winners — the indicative rows are part of the surface, with their
evidence.
part tags each message chunk with { i, n } (1-based part index i of total parts n), ensuring
every published chunk remains ≤200KiB. seq is a monotonically increasing sequence counter across
snapshots and deltas.
intervalMs is the true recompute cadence (3000 ms by default). This is a shared server-side
recompute, not a tick-by-tick feed. Every successful pass publishes a delta; when the priced surface
did not change, that delta has empty upserts and removes. A pass with a due snapshot publishes
the snapshot first and then its delta under the next seq, so computedAt and publishedAt keep
publisher liveness observable without pretending the market moved.
heartbeatMs is the conservative liveness bound the server computed for its own configuration,
not a nominal target. It is the first recompute tick at or after the 30000 ms target — 30000 ms at
the default 3000 ms interval, and 40000 ms at a 20000 ms one. Successful per-pass deltas normally
arrive more often, at the disclosed intervalMs; an empty delta is liveness, not a market change.
Take heartbeatMs from the frame rather than hard-coding it. Sustained silence beyond it means a
publisher outage or an entitlement teardown, not a quiet market.
The three timestamps let you measure the lane instead of trusting it. publishedAt − computedAt is
the time the server spent turning a finished computation into a published frame. On live delivery,
ts − publishedAt is relay and fan-out latency, and nothing on that path deliberately buffers,
batches, or waits for a timer. On retained replay, that same gap is the last-known frame’s age. The
recompute interval is the only deliberate live-delivery delay, and it exists to bound upstream venue
API cost rather than as a design preference — expect a change to surface within one interval, and on
average within half of one.
A new subscriber receives a snapshot of the current surface immediately after its subscribed
acknowledgement. The replay preserves the frame’s original publishedAt; only the outer ts
records the new socket-write time. Compare that age with heartbeatMs: a retained frame older than
the advertised heartbeat is stale and does not claim that the publisher is still live. If this
relay has never observed a valid publisher frame, the request gets NOT_SUPPORTED instead of a
success acknowledgement.
Staleness contract. The retained frame’s publishedAt is the publisher-liveness signal: a
healthy publisher refreshes it on every successful recompute, including an empty delta. If
publishedAt trails the envelope’s ts by more than roughly 90 seconds (the current SDK default),
treat the publisher as stale and fall back to REST. A publisher that goes permanently dark after
publishing once therefore continues to yield an aging retained frame instead of reverting to
NOT_SUPPORTED; that is intentional, and client-side age detection is the safeguard. The
TypeScript SDK will surface this condition as a staleness event in the current SDK release train.
Per-leg vwap, cost, and fee are null whenever the leg cannot honestly be priced as the
claimed trade; note and feeBasis are present only where the venue’s fee model needs them.
settlementFee, totalCost, netEdge, and roi are null when the pair cannot be priced, and
asOf is null when either book is unavailable. Arbitrage frames are dropped rather than buffered
under backpressure; clients that fall behind receive a fresh snapshot automatically via hub-side
stale-client recovery.
Errors and unsupported capabilities
Section titled “Errors and unsupported capabilities”Protocol errors are JSON frames. Depending on the failed operation they echo venue, marketId,
channel, feed, or symbol:
{ "type": "error", "code": "NOT_SUPPORTED", "message": "venue 'predictstreet' has no native live trade stream", "venue": "predictstreet", "marketId": "<market_id>", "channel": "trades"}If a venue has neither a native live trade stream nor a configured chain-scanner tape, a trades
subscription returns this honest NOT_SUPPORTED frame. The socket stays open, no subscription is
created, and the service never fabricates polling or trade data. Unsupported order-book,
venue-wide, feed-ticker, and option-price subscriptions follow the same non-fatal pattern.
subscribeArbitrage answers the same NOT_SUPPORTED code — echoing channel alone — when no
Redis relay is configured or when the wired relay has not yet observed any valid publisher frame.
No success acknowledgement or client subscription is created; the client can retry after the
publisher is enabled.
The arbitrage channel is additionally gated on the same arbitrage plan feature as the REST verb
GET /api/router/fetchArbitrage. A key whose plan does not include it receives a non-fatal
PLAN_UPGRADE_REQUIRED frame instead of a subscription:
{ "type": "error", "code": "PLAN_UPGRADE_REQUIRED", "message": "the \"arbitrage\" feature requires the Builder plan or higher (current plan: \"free\")", "channel": "arbitrage"}The Free plan does not include it. The socket stays open and no subscription is created.
The same frame is also sent mid-stream. Entitlements are re-checked on the connection’s per-minute
metering tick against uncached key state, so a plan that stops entitling the feature loses the
arbitrage subscription within about a minute: the service unsubscribes it, releases its
subscription slot, and sends PLAN_UPGRADE_REQUIRED. If instead the API key itself has stopped
verifying — revoked, deleted, or rotated — the channel is torn down the same way but the frame
carries UNAUTHORIZED, because that caller needs to re-authenticate rather than upgrade.
In both cases the socket is never closed and every other subscription on it continues. A verification attempt that FAILS to complete changes nothing: only a fresh, conclusive answer tears the channel down, so an unreachable key store never interrupts a paying customer.
Pending fills are gated the same way, on the Pro plan and above. A pending or all trades
subscription from a lower plan receives a non-fatal PLAN_UPGRADE_REQUIRED frame and no
subscription:
{ "type": "error", "code": "PLAN_UPGRADE_REQUIRED", "message": "the \"pending fills\" feature requires the Pro plan or higher (current plan: \"builder\")", "venue": "polymarket", "marketId": "<asset_id>", "channel": "trades"}An entitled key still pays for the pending lane: the first pending connection-minute (800
credits, in addition to the connect-time minute already prepaid) is charged when the subscription
is accepted, and every later minute holding a pending or all subscription costs 800 instead
of the base 2. A balance that cannot cover it receives INSUFFICIENT_CREDITS as a non-fatal
error frame and the subscription is refused — unlike the connect-time and per-minute credit
failures, the socket is not closed and every other subscription on it keeps streaming.
Other non-fatal operation codes are BAD_MESSAGE, NOT_SUBSCRIBED, MARKET_NOT_FOUND,
SUBSCRIPTION_LIMIT, and post-auth RATE_LIMITED. Authentication, credit, platform, connection,
and server-lifecycle failures can close the connection after their error frame or close reason.