token_trade_matches,
token_trade_settlements, token_book, token_ohlc,
condition_lifecycle, system) on this gateway returns
{ "code": "forbidden" } in rejected[] — use /ws/market for
public streams.
Authentication
The handshake rejects missing, malformed, revoked, expired, suspended,
or IP-denied keys with a
4401 <reason> close frame before any
subscribe command is accepted. There is no fallback to any other auth
scheme; a bad key 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_no_associated_wallet even though the key itself is valid
— multi_wallet keys carry no fixed wallet on the partner row, so the
gateway has nothing to bind to. single_wallet partners do not send
this header; their wallet is fixed at the partner row’s
associated_wallet.
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 row 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
{ "code": "api_key_scope_missing", "message": "channel user_orders needs portfolio:read" }
in rejected[]. See API keys for the full scope
catalog.For vault_positions, each requested vault must be covered by the
API key’s associated_vault row — vaults outside the grant come
back under rejected[] with forbidden.keyId immediately via Redis apikey:invalidate pub/sub.
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:
Available channels
See Server events for full payload
shapes per
type.
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
from orders.client_order_id so a partner driving its state machine off
WS gets the correlation key on both sides without a follow-up REST call.
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 /api/me/trades below.
side is 'buy' or 'sell' from your perspective. orderId /
clientOrderId are your side of the match (the buyer’s id when
side === 'buy', the seller’s when side === 'sell'); use them to
correlate the fill back to the placement you submitted. The market is
identified at the asset level (tokenId + conditionId +
outcomeIndex); resolve to your application marketId via
GET /api/markets if needed.
clientOrderId is forwarded only if the matcher’s trade payload
carries it (today the matcher does not always populate this field; the
key is omitted from the JSON when absent — compactRecord strips
nulls). All other fields above are present on every emission.
user_fill vs /api/me/trades
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, before
exchange.fee_ledgeris written. Per-wallet fee is not yet resolved at emit time. - The fee row is keyed by
(trade_id, role)—roleis'maker'if the wallet placed the resting order,'taker'otherwise. The matcher event does not carry the role/fee join. - Fee semantics are side-specific: per the on-chain
_chargeFeerules, the BUY-taker fee is denominated in outcome shares (not USDC); the SELL-taker fee is in USDC. The singlefeecolumn on the trade row tracks the SELL-side USDC fee only — the per-wallet view that REST returns readsCOALESCE(fee_ledger.amount, t.fee)to surface the correct number per role.
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, paginated by before) for the post-settlement
view — that includes the resolved fee, the application-level
marketId, and the vaultAddress. Combine the two: WS for latency,
REST for accounting.
If you need the per-side fee breakdown (both maker and taker rows for
the same trade), use GET /api/me/fees
instead — it returns one row per (tradeId, role) from the
fee_ledger.
vault_position_balance_changed (channel: vault_positions)
reason is the chain-event tag (ASCII bytes4). Common values:
"OUTF" (trade-side credit), "INFL" (non-trade inflow),
"SPLT" / "MERG" / "REDM" (split / merge / redeem), "XFER"
(direct transfer). New tags can land — treat unknown as generic.
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.