Skip to main content
Every server → client push uses the same envelope:
Use sid to route inside your client; use id to identify which specific subscribed entity matched.

user_orders (/ws/user, id = your wallet) ✅ live

Fields on the order_* frames:
  • marketId — the market symbol, the same key as marketId on user_fill and on GET /api/orders/{id}. On order_placed, order_cancelled and order_expired; order_rejected doesn’t carry it.
  • originalQuantity (order_placed, order_cancelled, order_expired) — the order size as placed, in shares (decimal string, printed canonically: "10.5", not "10.50"), the same value on every frame of one order. A resting order cancelled by self-trade prevention can arrive without it. On a cancel or expiry, originalQuantity - remainingQty is what had filled when the order ended, not a final fill total: the order’s fills are recounted after it ends, so read the total from GET /api/orders/{id}.
  • remainingQty (order_cancelled, order_expired) — the unfilled size at the moment the order was cancelled or expired, in shares (decimal string, same canonical printing). A resting order cancelled by self-trade prevention (reason: "self_trade_prevention") can arrive without remainingQty.
  • orderType (order_placed) — LIMIT or MARKET.
  • timeInForce (order_placed) — GTC, IOC or FOK.
  • placedAt (order_placed) — the time the exchange accepted the order, ISO 8601; tsMs on that frame is the same instant in ms. All orders of one place-batch share it.
  • eventAt (order_cancelled, order_expired, order_rejected) — the time the exchange recorded the cancel, expiry or rejection, ISO 8601. On these frames tsMs is the time the frame was sent, so order them by eventAt.
A key that has no value for an event is left out of the frame, as clientOrderId is. Key your order state by orderId / clientOrderId from the placement response. These frames are state-change notifications: each reports one transition of one order with the fields known at that point. They are not a snapshot of the order — there is no filledQty (a cancel or expiry gives only the size filled by then, see originalQuantity above), and status is the event label (placed / cancelled / rejected / expired), not the REST order status. Read the current state with GET /api/orders/{id}; the field mapping between the two is in user_orders frames vs REST order detail. trade_failed names the market in marketId (the market symbol) when it can be resolved.

user_fills (/ws/user, id = your wallet) ✅ live


vault_positions (/ws/user, ids = [your vault address]) ✅ live

Amounts and balances are base-unit integers as decimal strings. balanceAfter on split / merge / redeem is the vault’s collateral balance after the operation. These frames carry no tsMs; order them by blockNumber / logIndex. vault_position_balance_changed.reason is the vault contract’s bytes4 tag, sent as hex (e.g. "0x53504c54" = SPLT). Values: SPLT 0x53504c54 (split), MERG 0x4d455247 (merge), INFL 0x494e464c (exchange delivered tokens into the vault — a fill), OUTF 0x4f555446 (exchange took tokens out of the vault — a fill), REDM 0x5245444d (redeemed, balance goes to 0), CONV 0x434f4e56 (neg-risk conversion), EMER 0x454d4552 (emergency withdrawal). Treat unknown tags as a generic balance change; don’t drop them. Required scope: portfolio:read. Subscription must include at least one vault id, and every id must be the vault bound to this socket: your key’s vault, or the vault of the X-User-Wallet wallet on a multi-wallet key. Any other vault comes back under rejected[] with forbidden, and so does every vault id when the socket was opened before that wallet had a vault — reconnect once vault_created arrives, and if the new greeting still shows vaultAddress: null, reconnect again a little later.

vault_activity (/ws/user, ids = [your vault address]) ✅ live

trade_fill. The exchange emits one OrderFilled per filled order: one for each maker order in a match and one for the taker order. orderHash is the order the event is about, makerVault the vault that signed it, and takerVault its counterparty — the taker’s vault on a maker order’s event, the exchange contract on the taker order’s own event. side is your vault’s place in that event, not your trade role: maker means the event is for your own order (orderHash and fee are yours), taker means it is the event of a maker order your order took. A maker therefore gets one frame per fill; a taker gets one for its own order plus one per maker order it matched. Read your fee from the side: "maker" frames. makerAssetId / takerAssetId are "0" for USDC and the outcome tokenId otherwise, seen from the order in orderHash (what it gave / received). makerWallet / takerWallet are the vaults’ owner wallets (EIP-55), null when not resolved. Amounts on the on-chain frames are base-unit integers as decimal strings; exchange_balance_synced amounts are human-decimal USDC, except balanceAfterRaw (the vault’s on-chain balance in base units). These frames carry no tsMs; order them by blockNumber / logIndex. Required scope: portfolio:read. The subscription must name your own vault — any other vault comes back under rejected[] with forbidden.

user_activity (/ws/user, no ids, id = your wallet) ✅ live

There is no withdrawal_* frame for the final confirmation: it arrives as withdrawal_confirmed on vault_activity, and GET /api/me/withdrawals/{id} reports CONFIRMED. The channel also repeats order and trade events (order_placed, order_cancelled, order_rejected, order_expired, trade_matched, trade_failed) in an unnormalized form not described here; use user_orders and user_fills for those. Required scope: portfolio:read.

notifications (/ws/user, no ids, id = notification id) ✅ live

The live side of the PredictStreet web app’s notification centre. data.type is the kind of notification — ORDER_FILLED, POSITION_SETTLED, DEPOSIT_CONFIRMED, WITHDRAWAL_CONFIRMED, MARKET_RESOLVED, MARKET_STATUS_CHANGED, MARKET_CLOSING_SOON, MARKET_VOIDED, NEW_MARKETS, PROMOTION — not to be confused with the envelope type, which is always notification. New kinds can be added; treat an unknown one generically. title and body are display text. category is the notification-preference item the kind belongs to (e.g. order-filled, deposit-completed) and is sent as null for kinds that are not a preference item, such as MARKET_VOIDED. payload is a kind-specific object; its occurredAt is when the underlying event happened, while createdAt (ISO 8601) and tsMs are when the notification was added. marketId and marketSymbol are omitted when unknown, and either one can be missing on a notification about a market. Which notifications are created, and whether they are pushed here, follows the notification preferences set in the web app — a kind whose in-app delivery is switched off there sends no frame. The notification feed itself is a web-app surface, not part of this API. Frames are best-effort: a frame missed while you were disconnected is not sent again. Don’t drive trading or accounting from this channel — use user_orders, user_fills, vault_activity and user_activity. Required scope: portfolio:read.

dispute_intent (/ws/user, id = dispute intent id) ✅ live

Every frame carries { id, walletAddress, marketSymbol, txHash?, status, challengeId?, rejectionReason?, expiresAt, createdAt, updatedAt, tsMs }. Fields without a value are omitted; txHash is absent on an intent that never got a transaction. challengeId is the challenge’s id in GET /api/challenges. expiresAt, createdAt and updatedAt are ISO 8601; tsMs is updatedAt in milliseconds. A dispute intent is the record the PredictStreet web app keeps when a user files a dispute from the app. There is no API to create one, and a challenge you send on-chain yourself (see Challenges) has no intent — so on an API-key socket this channel stays silent unless the socket’s wallet also files disputes through the web app. Follow your own challenges with GET /api/challenges?challengerWallet=<address> and the market_disputed frame on market_lifecycle. On an API-key socket, subscribe with your wallet as the only id — { "channel": "dispute_intent", "ids": ["<the greeting's walletAddress>"] }; a JWT session subscribes without ids. Required scope: portfolio:read.

bond_payout_credited (/ws/user, id = challenge id) ✅ live

Fires when the decision on a challenge you raised credits your bond back to pendingBondPayouts(yourAddress) on the oracle you challenged, once the platform has indexed that decision: the proposal was overridden (bond plus reward), the market was voided (bond), or the proposal was cleared through the emergency proposal clear (bond). Nothing is sent when the original outcome is confirmed — the bond is forfeited. See Challenges. Amounts are USDC base units (6 decimals) as decimal strings: amountUsdcRaw = bondUsdcRaw + rewardUsdcRaw, and rewardUsdcRaw is omitted when there is no reward. It is the amount this decision credited, not your total pending balance. sourceChallengeId (also the envelope id) is the challenge’s id in GET /api/challenges; creditedAt is ISO 8601 and tsMs the same instant. The credit is not paid out automatically — claim it with withdrawBondPayout(to). The frame is best-effort: read pendingBondPayouts(yourAddress) on the oracle for the authoritative amount. On an API-key socket, subscribe with your wallet as the only id ("ids": ["<the greeting's walletAddress>"]); a JWT session subscribes without ids. Required scope: portfolio:read.

me_limits_invalidated (/ws/user, id = transaction hash) ✅ live

Fires when an on-chain DepositLimitRegistry transaction that changes your wallet’s tier, caps, or deposit / trading / withdrawal restriction is indexed. reason names the kind of change; the frame carries none of the new values, so re-read GET /api/me/deposit-limits when it arrives. That makes it the push alternative to polling while a new wallet waits for its first tier (Initialisation timing). The REST view is refreshed separately from this push, so if the first re-read still shows the old values, read again a few seconds later. There is no tsMs; use the envelope serverTs. On an API-key socket, subscribe with your wallet as the only id ("ids": ["<the greeting's walletAddress>"]); a JWT session subscribes without ids. Required scope: portfolio:read.

token_trade_matches (/ws/market, id = tokenId) ✅ live

Fires the moment the matcher prints a trade — fastest tape feed, arrives several seconds before the chain settlement. tradeId is the platform trade id, a numeric string (the same id that GET /api/me/trades and /api/markets/{symbol}/trades return). trade_settled does not carry tradeId; tie a settlement back to your own orders through orderHash (also sent on your order_placed). On a binary market every trade is published on both outcome tokens: the frame for outcome 0 and a mirrored frame for outcome 1 at price 1 − p, with the same tradeId. takerOutcomeIndex is the outcome the taker actually traded and is identical on both frames. If you subscribe to both tokens of a market, keep only the frame whose outcomeIndex equals takerOutcomeIndex (or dedupe on tradeId) before counting trades or volume.
No backfill on subscribe — query GET /api/markets/{symbol}/trades for history.

token_trade_settlements (/ws/market, id = tokenId) ✅ live

Fires when the platform indexes an on-chain OrderFilled event. The exchange emits one per filled order — one for each maker order in a match and one for the taker order — so a single match produces several trade_settled frames, each describing the order in orderHash (side, price and quantity are that order’s). Use it for accounting-grade confirmations of your own orders, keyed by orderHash; don’t add the frames up as independent trades.

token_ohlc (/ws/market, id = tokenId) ✅ live

5-second candles built from the trade_matched tape, pushed whenever a trade prints inside the active window. isClosed: false while the bar is still accumulating; isClosed: true is the final tick of the bar. For history: GET /api/markets/{symbol}/ohlc?interval=s5.

market_price_tick (/ws/market, id = <symbol>:<outcomeIndex>) ✅ live

The live edge of the probability chart: each frame is the point GET /api/markets/{symbol}/price-history returns for that second, and price is the value the market serves as chances — the book midpoint when the book is two-sided with a spread of at most 0.10, else the last trade. When neither exists the last known mark is carried forward, and before any activity it is 1 / outcomeCount. Subscribe with ids of the form <symbol>:<outcomeIndex> — the market symbol (case-insensitive, lower-cased in the ack and in the envelope id) and the 0-based outcome index without leading zeros (0–255), one id per outcome you chart. data.symbol keeps the symbol’s original case. Frames are event-driven: at most about one per second per outcome, and only when that outcome’s best bid, best ask or last trade changed, so a frame can repeat the previous price and a quiet market sends nothing. Only markets that are OPEN and not past their close time are ticked. A frame that is more than about 10 seconds old by the time it reaches the gateway is dropped rather than delivered late. No backfill on subscribe — seed the chart from GET /api/markets/{symbol}/price-history?range=live (one-second points) and append frames by ts.

token_book (/ws/market, id = tokenId) ✅ live

book_update is the raw full-depth replacement; book_delta is the computed diff. Most integrators want book_delta; book_update is there for clients that prefer to replay state wholesale. Live book_update is dropped if an upstream frame is missing seq / prevSeq — the gateway never emits a divergent delta. What a book frame holds. book_update is not just the best bid and ask: it is the complete book after the change, every resting price level on both sides, aggregated per price. Levels whose total size is below a small dust threshold are left out of every book frame, the same filter the REST orderbook applies. book_snapshot is capped at 100 levels per side, but prices sit on a 0.01 grid, so a side never has more than 99 levels and a snapshot also carries the whole book. book_delta carries the same change as a diff. Apply book_update frames, or book_snapshot + book_delta frames, not both. On book_snapshot_failed, retryable is always true: back off, then send get_book_snapshot again or read the REST orderbook. subscriptionActive only tells you whether the snapshot was requested under a sid: true on subscribe, on update_subscription and on get_book_snapshot with a sid (that subscription stays in place and keeps streaming), false on a get_book_snapshot sent with ids alone. seq is monotonically increasing. After subscribe the server pushes exactly one book_snapshot (depth 100), then one book_update per book change, each followed by the book_delta for the same change with the same seq (the delta is skipped when the gateway lacks the previous state). Apply one stream, not both — book_update frames, or book_snapshot + book_delta frames — and track the last seq you applied; a frame whose data.prevSeq differs from it means you missed a change. On such a gap or unexpected silence, issue get_book_snapshot to refresh without unsubscribing. See Reconnect — orderbook resync.

all_trade_matches / all_trade_settlements / all_books (/ws/market, no ids, id = tokenId) ✅ live

Platform-wide firehoses: every frame the per-token channel sends, for every token, without listing ids. Subscribe with no ids at all — sending any is rejected with invalid_params. The per-token channels take explicit token ids only (there is no * wildcard); these channels are the way to receive everything. Each frame is identical to the per-token frame — same type, data, envelope id (the frame’s tokenId) and, on all_books, the same seq and hash — only sid and channel differ.
  • Duplicates across subscriptions. A connection holding both a per-token subscription and the matching all_* channel receives each frame twice, once per sid. Use one or the other on a connection, or dedupe on the envelope id together with tradeId (trades), orderHash + txHash (settlements) or seq (books).
  • Two frames per binary trade. all_trade_matches carries the outcome-0 frame and its NO mirror for every binary-market trade, as described under token_trade_matches: keep the frame whose outcomeIndex equals takerOutcomeIndex (or dedupe on tradeId) before counting trades or volume. trade_matched names its market in marketSymbol; for trade_settled and the book frames, resolve an unknown token with GET /api/markets/by-token/{id}. For one frame per trade with market titles attached, use global_activity below.
  • No initial book. all_books sends no book_snapshot on subscribe. Each book_update is a token’s full book, so start a token from its first book_update, or request snapshots with get_book_snapshot by ids (a get_book_snapshot naming this channel’s sid is rejected — it needs a token_book subscription). Apply a book_delta only on top of the state at its prevSeq.
  • Volume. all_books delivers every change of every book on the platform as a full-depth book_update plus a book_delta. A client that doesn’t keep up fills its outbound buffer and is disconnected (see resource limits); subscribe to token_book for the tokens you need unless you really need all of them.
Nothing is replayed on subscribe; each channel counts as one subscription toward the per-connection limit.

global_activity (/ws/market, no ids, id = tradeId) ✅ live

The platform-wide trade feed: exactly one frame per matched trade (not two per binary trade, unlike all_trade_matches), sent at match time, from the taker’s side. outcomeIndex, side and price are the taker’s own leg — on a binary market where the taker traded NO, outcomeIndex is 1 and price is 1 − p of the YES price. actorWallet is the taker’s wallet and actorUsername their public username. outcomeLabel is the outcome’s canonical label (YES / NO, or the outcome name) and outcomeDisplayLabel the label shown to traders. tokenId is the taker’s outcome token. price and quantity are decimal strings; tsMs is the trade time. The market, event and username fields are display metadata looked up for each trade. A field without a value is omitted, and when the lookup is unavailable the frame is still sent with the trade fields only — render a fallback (the market symbol, a shortened wallet) rather than waiting for them. A username is not shown for a wallet without one or whose public profile is not available. The lookup can reorder frames by a few milliseconds; order by tsMs. No ids: subscribe with { "channel": "global_activity" }. No backfill on subscribe — load the latest rows from GET /api/activity/recent (same field names; the REST rows send null where the frame omits a key), then apply frames on top, deduplicating on tradeId.

condition_lifecycle (/ws/market, id = conditionId) ✅ live

Only the on-chain per-condition trading pause / unpause is published here, so the channel is silent most of the time. Nothing is pushed on subscribe. Status changes, disputes and resolution are on market_lifecycle below.

market_lifecycle (/ws/market, id = market symbol) ✅ live

Keyed by the app symbol (lower-cased), so one subscription per market page carries status, dispute, resolution and — on a crypto window — the frozen open and the bound close. condition_lifecycle is not a per-condition copy of this channel: it carries only the on-chain trading pause.
Null fields are omitted rather than sent as null — treat every optional key above as “may be absent”. Frames are a low-latency hint; re-fetch GET /api/markets/{slug} for the authoritative state.

crypto_price_tick (/ws/market, id = instrumentId) ✅ live

The price of the coin (BTC-USD), not of a market. Keyed by instrument, so every crypto market on that asset shares one feed. price is an unscaled integer as a decimal string — render as price / 10 ** decimals, never Number(price) (18 decimals overflows Number.MAX_SAFE_INTEGER).
src distinguishes the two producers: "datastreams" (live spot, ~1/s, drives short Up/Down windows) and "candlestick" (one-minute candle close, ~1/min, drives long-dated markets). Plot points at their own at, de-duplicate on it, and expect silence when no producer is active for the instrument. No backfill on subscribe — seed the curve from GET /api/markets/{symbol}/crypto-price-history. Full guide: Crypto price feeds.

system (/ws/market, id = "platform_status") — no frames yet

The gateway accepts system subscriptions, but no platform_status frames are published today, so the channel stays silent. To detect a platform freeze, poll GET /api/platform/status, which returns { frozen, reason, since, activeNotices }.