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:
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.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.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 singleconnected 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:
marketIdis the market symbol (the same key asmarketIdonuser_filland onGET /api/orders/{id}).order_placed,order_cancelledandorder_expiredcarry it;order_rejecteddoesn’t.originalQuantityis the order size as placed, in shares (decimal string, printed canonically:"10.5", not"10.50").order_placed,order_cancelledandorder_expiredcarry it, with the same value for one order.orderType(LIMIT/MARKET),timeInForce(GTC/IOC/FOK) andplacedAt(the time the exchange accepted the order, ISO 8601; shared by all orders of oneplace-batch) are on the same frame.remainingQtyonorder_cancelledandorder_expiredis the unfilled size at the moment the order was cancelled or expired, in shares (decimal string, same canonical printing).order_cancelledcan leave it (andoriginalQuantity) out when a resting order is cancelled by self-trade prevention, the same case that can leave outclientOrderId.- On
order_cancelledandorder_expired,originalQuantity - remainingQtyis what had filled when the order ended (10 - 4 = 6in 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 laterGET /api/orders/{id}can report a differentfilledQty. Take the fill total fromGET /api/orders/{id}, not from this difference;user_fillis the real-time signal. eventAtonorder_cancelled,order_expiredandorder_rejectedis the time the exchange recorded that event (ISO 8601). On those framestsMsis the time the frame was sent, so order them byeventAt.
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:
- 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).
- The fee is per leg: the maker and the taker of a trade each pay
their own fee.
roletells you which leg is yours; the amount is final only after settlement (point 1). - The fee unit depends on the side: a BUY leg is charged in outcome
tokens, a SELL leg in USDC.
/api/me/tradesreturns your leg’sfeetogether withfeeUnit(USDC/OUTCOME_TOKENS) andfeeTokenId; readfeeUnitbefore treatingfeeas dollars.
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.
vault_positions frames — vault_position_split,
vault_positions_merged, vault_position_redeemed — are listed in
Server events.
Rejection codes
Related
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.