Skip to content

StreamingLive data

Connect to the production WebSocket endpoint:

wss://stream.predictefy.com/v1/stream

The 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.

Non-browser clients should send the API key in the upgrade request:

Authorization: Bearer pk_live_YOUR_KEY

Browser 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.

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.

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" }

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.

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.

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 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.

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.

{
"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
}
{
"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.

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.