WebSocket API reference
Connection, authentication, subscriptions, acknowledgements, data frames, and capability-honest errors.
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.
Authentication
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.
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" }
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" }
{ "op": "unsubscribeArbitrage" }
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>"
}
}
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
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
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.
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
The arbitrage lane relays one shared server-side recompute of the cross-venue executable-arbitrage
surface — the streaming twin of GET /api/router/fetchArbitrage. Every frame is the whole current
surface; there is no incremental delta.
{
"type": "arbitrage",
"data": {
"exchange": "router",
"computedAt": "2026-08-13T12:00:00.000Z",
"publishedAt": "2026-08-13T12:00:00.004Z",
"intervalMs": 3000,
"heartbeatMs": 30000,
"contracts": 100,
"limit": 10,
"rows": [
{
"clusterId": "cluster:real",
"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",
"executable": true,
"reasons": [],
"label": "arbitrage",
"asOf": "2026-08-13T11:59:58.000Z"
}
]
},
"ts": 1780000000123
}
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 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.
intervalMs is the true recompute cadence (3000 ms by default). This is a shared server-side
recompute, not a tick-by-tick feed: a frame is published only when the priced surface actually
changed — a re-read of the same books carries a fresher asOf and is not treated as a change —
plus a heartbeat republish so a quiet market is distinguishable from a dead publisher. A heartbeat
frame is a genuine recompute against live books rather than a replay, so its computedAt and
publishedAt are truthful.
heartbeatMs is the bound the server computed for its own configuration, not a nominal target:
a republish can only happen on a recompute tick, so the advertised value is the first tick at or
after the 30000 ms target — 30000 ms at the default 3000 ms interval, and 40000 ms at a 20000 ms
one. Take it from the frame rather than hard-coding it. A healthy publisher republishes at least
that often, so sustained silence beyond the advertised heartbeatMs 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 the retained last-known 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 at least once per roughly 30-second heartbeat recompute. If
publishedAt trails the envelope's ts by more than roughly 90 seconds (three heartbeats), 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 coalesced
or buffered under backpressure — because each frame is the whole surface, the next frame supersedes
anything skipped.
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 'smarkets' has no native live trade stream",
"venue": "smarkets",
"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.
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.