Skip to content
Get an API key

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 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_SUPPORTED error without closing the socket — never a fake stream.

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.

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:

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

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 (fetchMarketoutcomes[].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.

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

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

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();

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.

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.