Skip to main content
Per-partner activity gateway. Subscribe once with your API key and you’ll receive live events for every order, fill, and vault-position change keyed to the wallet associated with the key. Trying to subscribe to a public channel (token_trade_matches, token_trade_settlements, token_book, token_ohlc, condition_lifecycle, system, the all_* channels, …) on this gateway returns { "code": "forbidden" } in rejected[] — use /ws/market for public streams.

Authentication

The handshake rejects malformed, unknown, revoked, expired, suspended, or IP-denied keys with a 4401 <reason> close frame before any subscribe command is accepted. A handshake that carries no credentials at all is closed with 1008 unauthorized. Supplying a key commits the handshake to the key path — a bad key never falls back to another auth scheme; it ends the connection cleanly.

Multi-wallet partners — X-User-Wallet required on upgrade

Partners whose key was provisioned as multi_wallet (one key fanning out across many sub-account vaults — see API keys / multi_wallet flow) must declare the acting wallet on the upgrade request — same shape as their HTTP calls:
Both headers are read at handshake time. The socket scopes every private subscription to the wallet declared in X-User-Wallet, and vault_positions resolves to that wallet’s vault. Without X-User-Wallet, the gateway closes 4401 api_key_user_wallet_required even though the key itself is valid — multi_wallet keys carry no fixed wallet, so the gateway has nothing to bind to. single_wallet partners do not send this header; their wallet is fixed to the partner’s associated wallet. If a multi_wallet partner also has an associated wallet configured, the socket binds to that wallet and X-User-Wallet is ignored (see API keys).
Authority model — symmetric with HTTP /api/me/*. The gateway binds whatever X-User-Wallet value the partner sends, the same way /api/me/* HTTP calls do. The platform does not enforce per- wallet ownership against the partner account at the request boundary — partner authority is enforced at the KYB / onboarding contract level. Partners are responsible for sending only wallets they legitimately act on behalf of.
Browser clients that can’t set custom headers on a WebSocket upgrade fall back to the query-param form:
key= and user_wallet= carry exactly the same semantics as the headers; mixing the two (e.g. header X-Api-Key + query user_wallet=) is also accepted.
Every private channel requires the portfolio:read scope on the API key. Subscribing without it surfaces { "channel": "user_orders", "code": "api_key_scope_missing", "message": "api_key_scope_missing: channel user_orders needs portfolio:read" } in rejected[]. See API keys for the full scope catalog.For vault_positions and vault_activity, ids must be the vault of the wallet this socket is bound to — the vaultAddress in the connected greeting. Any other vault comes back under rejected[] with forbidden. If the wallet had no vault yet when the socket connected (vaultAddress: null), every vault subscribe is rejected with forbidden; the socket keeps the vault it resolved at handshake, so reconnect once the vault is deployed. The gateway can pick up a new vault slightly after vault_created arrives: if the new greeting still shows vaultAddress: null, reconnect again a little later.
Auth is validated once at handshake. Issuing a new key doesn’t affect a socket opened with the old one — close and reopen to switch identity. Live sockets bound to a keyId are closed immediately with 4401 api_key_revoked when the key is revoked, and also when its scopes are edited, the partner is suspended, or the partner’s wallet setup (associated wallet, single_wallet / multi_wallet mode, per-wallet KYC requirement) changes. Reconnect once: if the handshake succeeds, the key is still valid.

Close codes

Connect greeting

The server pushes a single connected frame after the upgrade handshake so you don’t need a parallel REST call to learn the wallet tied to the key:
vaultAddress is the bound wallet’s vault — the only id vault_positions and vault_activity accept on this socket — or null if that wallet had no vault yet at handshake.

Available channels

See Server events for full payload shapes per type.

Channels that take your wallet as ids

me_limits_invalidated, bond_payout_credited and dispute_intent need the socket’s own wallet — the walletAddress in the connected greeting (for a multi_wallet key, the X-User-Wallet wallet) — as their only id on an API-key socket. The id is case-insensitive. Leaving ids out is rejected with invalid_params, and any other wallet with forbidden. (A JWT session subscribes to them without ids.) The ack lists these subscriptions without ids, and their id can’t be changed with update_subscription.
dispute_intent reports on disputes filed through the PredictStreet web app; a challenge you send on-chain yourself has no dispute intent, so the channel stays silent for it. notifications mirrors the web app’s notification centre — display text, governed by the preferences set there, best-effort — so keep driving your own state from the typed channels above.

Subscribe example

Subscribe response

Push examples

order_placed / order_cancelled (channel: user_orders)

clientOrderId is present when the original POST /api/orders/place supplied one; absent otherwise. The cancel frame echoes the same value so a partner driving its state machine off WS gets the correlation key on both sides without a follow-up REST call. The exception is a resting order cancelled by self-trade prevention (reason: "self_trade_prevention"): its frame can arrive without clientOrderId. Key your state by orderId / clientOrderId. The same channel also pushes order_rejected, order_expired and trade_failed; order_rejected and order_expired never carry clientOrderId and identify the order by orderId only, so keep the orderId mapping too. See Server events for every shape. The frames also name the market and the size:
  • marketId is the market symbol (the same key as marketId on user_fill and on GET /api/orders/{id}). order_placed, order_cancelled and order_expired carry it; order_rejected doesn’t.
  • originalQuantity is the order size as placed, in shares (decimal string, printed canonically: "10.5", not "10.50"). order_placed, order_cancelled and order_expired carry it, with the same value for one order. orderType (LIMIT / MARKET), timeInForce (GTC / IOC / FOK) and placedAt (the time the exchange accepted the order, ISO 8601; shared by all orders of one place-batch) are on the same frame.
  • remainingQty on order_cancelled and order_expired is the unfilled size at the moment the order was cancelled or expired, in shares (decimal string, same canonical printing). order_cancelled can leave it (and originalQuantity) out when a resting order is cancelled by self-trade prevention, the same case that can leave out clientOrderId.
  • On order_cancelled and order_expired, originalQuantity - remainingQty is what had filled when the order ended (10 - 4 = 6 in the example above). It is not a final fill total: the order’s fills are recounted after it ends (a fill matched just before the cancel can be recorded after the cancel, a fill whose on-chain settlement fails is taken back out, and a settled fill is restated to the amount delivered on chain), so a later GET /api/orders/{id} can report a different filledQty. Take the fill total from GET /api/orders/{id}, not from this difference; user_fill is the real-time signal.
  • eventAt on order_cancelled, order_expired and order_rejected is the time the exchange recorded that event (ISO 8601). On those 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 rather than sent as null.

user_orders frames vs REST order detail

The order_* frames are state-change notifications: each one reports a single transition of one order and carries the fields known at that transition. They are not a snapshot of the order. For the order’s current state — fills so far, the live remaining size, the order status — read GET /api/orders/{id}, which returns the Order object. Field by field: Fills are not reported on user_orders: follow them on user_fills (below) or with GET /api/orders/{id}/fills.

user_fill (channel: user_fills)

Fires the moment the matcher prints a trade where your wallet is on either side — this is a match-time notification, not a settlement- confirmed one. The matcher emits before on-chain settlement completes, so the payload deliberately omits fields that only exist post- settlement: per-wallet fee, txHash, blockNumber, and the on-chain orderHash. For those, poll GET /api/me/trades (or GET /api/me/fees for the per-side fee breakdown) once you receive this event — see user_fill vs REST trade history below. side is 'buy' or 'sell' from your perspective, and price is your price for outcomeIndex. orderId / clientOrderId always identify your own order in the match (never the counterparty’s); use them to correlate the fill back to the placement you submitted. role is your order’s part in the match: "maker" when it was resting in the book, "taker" when it was the order that crossed it. If your own two orders ever match each other, you get two user_fill frames for that tradeId, one maker and one taker, each with its own orderId, so key fills by tradeId and orderId, not by tradeId alone. signedUserPrice is your leg’s price as the on-chain settlement uses it: the same as price on a same-outcome match, your order’s signed limit price on a cross-outcome MINT / BURN match. The market is identified by marketId (the market symbol, the same key as /api/me/trades) plus outcomeIndex; the frame carries no tokenId or conditionId.
tradeId is a numeric string, the same id /api/me/trades returns as id. quantity is the filled size in shares (decimal string). clientOrderId is present only when your order was placed with one; the key is omitted from the JSON when absent. signedUserPrice is omitted if it could not be determined for the fill, and role on a fill recorded before it was added; treat a missing role as unknown, not as taker. All other fields above are present on every emission.

user_fill vs REST trade history

The two surfaces serve different jobs and intentionally publish different shapes: fee is intentionally absent from the WS payload because:
  1. The fill event fires from the matcher, at match time, before the trade settles on-chain; a leg’s fee is final only after settlement (a trade whose settlement fails is charged no fee).
  2. The fee is per leg: the maker and the taker of a trade each pay their own fee. role tells you which leg is yours; the amount is final only after settlement (point 1).
  3. The fee unit depends on the side: a BUY leg is charged in outcome tokens, a SELL leg in USDC. /api/me/trades returns your leg’s fee together with feeUnit (USDC / OUTCOME_TOKENS) and feeTokenId; read feeUnit before treating fee as dollars.
Recommended pattern: treat user_fill as the real-time “fill happened” signal — use it to re-render the activity feed, update positional UI, trigger re-balance logic. Then call GET /api/me/trades (with limit=50 or so, paged by passing the returned nextCursor back as cursor) for the post-settlement view — that includes the resolved fee and the vaultAddress. Combine the two: WS for latency, REST for accounting. If you need your fees as a ledger, use GET /api/me/fees — it returns one row per (tradeId, role) for your wallet, with the amount, unit and token id.

vault_position_balance_changed (channel: vault_positions)

balance is the vault’s absolute post-change balance of tokenId (base units, decimal string). reason is the vault contract’s bytes4 tag, sent as hex: 0x53504c54 SPLT (split), 0x4d455247 MERG (merge), 0x494e464c INFL / 0x4f555446 OUTF (exchange moved tokens into / out of the vault on a fill), 0x5245444d REDM (redeem), 0x434f4e56 CONV (neg-risk conversion), 0x454d4552 EMER (emergency withdrawal). New tags can land — treat unknown as generic.
The other vault_positions frames — vault_position_split, vault_positions_merged, vault_position_redeemed — are listed in Server events.

Rejection codes

Commands

update_subscription / unsubscribe / list_subscriptions / ping.

Server events

Full event payload catalog per channel.

Reconnect

Heartbeat, sid rebuild, snapshot resync.

API keys

Scope catalog, rotation, revoke.