WebSocket Client
The SDK exposes a single WebSocket client with functional subscription methods:Connection
The WebSocket auto-connects when the SDK is initialized. You can also manually manage the connection:Auto-Connect Behavior
By default, the WebSocket connection is established automatically when you initialize the SDK:- Runs asynchronously without blocking SDK initialization
- Logs connection errors to console but doesn’t throw
- Allows you to call SDK methods immediately while connection is established
- Automatically retries if initial connection fails
Manual Connection Management
You may want to manually control the WebSocket connection in these scenarios: 1. Delayed Connection If you don’t need real-time data immediately:Connection Methods
() => Promise<void>
Manually connect to the WebSocket server.Returns: Promise that resolves when connected, rejects on error.Note: Does nothing if already connected. Safe to call multiple times.
() => void
Disconnect from the WebSocket server.Returns: void (synchronous)Note: Does nothing if already disconnected. Clears all subscriptions.
() => WebSocketStatus
Get current connection status.Returns: One of:
"connected", "connecting", "disconnected", "reconnecting". Inside onStatusChange, it returns the status being reported. See Connection status.Connection status
PassonStatusChange in the SDK’s ws config (or to createMonacoWebSocket) to hear every status change. The connection reports four statuses:
Each call also receives an
info object:
attempt: the retry the status refers to. It is0for"connecting"and"connected"and1,2, … for"reconnecting". For"disconnected"it is the number of retries scheduled in the outage before it, counting one still in backoff when you calldisconnect(), and0after a clean close of a working connection.hasConnected: whether the client has reached the server at least once since it was created or since the lastdisconnect().nextRetryDelayMs: on"reconnecting"only, the delay in milliseconds before that retry opens its connection.
"reconnecting" with hasConnected: false means the client has never reached the server. With the default unlimited retries it keeps trying, so use info to tell that apart from a lost connection:
status still works. An error thrown by the handler, or a rejection from an async handler, is logged to the console and does not affect the connection.
Reconnection and resync
sdk.ws auto-reconnects with full-jitter exponential backoff (base 1s, capped at 30s) and retries indefinitely by default. getStatus() returns "reconnecting" while backing off and while a retry’s connection is opening, and Ping/Pong heartbeats are handled automatically. A connection that stays open but stops delivering data is treated as lost: after idleTimeoutMs (default 30s) with no frame at all, not even a Pong, the SDK closes it and reconnects, and the reopen fires onResync.
To react to reconnects, pass a ws config to the SDK — new MonacoSDK({ ..., ws: { onStatusChange, onResync } }). The constructor forwards it to the WebSocket client it builds internally, so you no longer need to drop to the low-level createMonacoWebSocket(baseUrl, options) factory (which remains available and additionally takes the session keypair). The same fields are accepted either way:
onStatusChange(status, info)— fires on every status change, with the retry attempt and whether the client has ever connected. See Connection status.onResync({ code, slowClient, reason, channels })—reasonandchannelsare set only for a scoped resync, where the socket stayed open and onlychannelsneed refetching. Otherwise it fires after each automatic reconnect, after sending authentication/subscription requests, without waiting for acknowledgements or snapshots. A channel with a subscribe-time snapshot can deliver a fresh baseline over the socket on reconnect, so the REST refetch below is for the channels that have none — and the same(id, version)rule reconciles either source. Refetch REST snapshots and merge them into local state on(id, version): order events, theorderssnapshot frame, and the REST endpoints all carry the same counter, so keep applying events as they arrive and take the snapshot for an order whensnapshot.version >= local.version, else keep local. Use>=, not>— on the stream equal versions mean the same sequencer step, not identical state (one step emits anOrderPlacedand then its fills; a batch shares one step across all items), and the REST row is that step’s final state. Two things the resting snapshot cannot tell you, because they stopped resting during the gap: fills, which you recover fromsdk.profile.getUserTrades(execution-time ordered, so a gap fill is at the top whatever the order’s age — the order list iscreatedAt-ordered and would bury it); and cancels or expiries, signalled by an order being absent from the snapshot that local state still thinks is resting. Snapshot absence is not a tombstone — persistence may instead lag a newer local resting event — so retain the local row and resolve it withsdk.trading.getOrder(orderId), then apply the same version rule. Treat an absentversionas unknown, never zero, and fall back to buffer-and-replay for those rows. The market-maker runbook has the full recipe.slowClientistruewhen the server shed the connection with close code1013(the client fell behind — expect a gap).onError({ code, message, channel })— fires for everyErrorframe the server sends;channelis present only on a per-channel failure. See Server error frames.onReauthenticationRequired({ code, message, closeCode })— fires once when the connection lost its authentication for good and the SDK stopped reconnecting. See Session loss (close code 1008).scopedResync— whether to offer the server theresynccapability (defaulttruewhenonResyncis set, never without it). See Scoped resync without a reconnect.autoReconnect,maxReconnectAttempts,reconnectBaseDelayMs,connectionTimeoutMs, andidleTimeoutMstoggle reconnection and tune the retry cap, backoff base, per-attempt timeout, and how long an open connection may stay silent before it is replaced. The retry cap counts per outage: aconnect()after the retries run out starts a fresh budget.
Heartbeat and keepalive
The SDK handles liveness at both layers; you never send or answer a ping yourself.- Transport ping (server → client). The server sends a WebSocket ping control frame about every 25 seconds so proxies keep the connection open. Your WebSocket implementation answers it on its own; it never surfaces as a message.
- JSON
Ping/Pong(client → server). The SDK sends{"type":"Ping"}every 10 seconds (a third ofidleTimeoutMs, at most 15 seconds) and consumes the server’s{"type":"Pong"}. Browsers hide the transport ping from JavaScript, so this is how the SDK notices a dead connection: if nothing arrives foridleTimeoutMs(default 30 seconds), not even aPong, it closes the socket, reconnects and firesonResync. Each idle window carries at least two Pings, so one lostPongdoes not drop a live connection.
Ping; a raw client sends its own on a timer and treats Pong as the reply. Heartbeats are liveness only: unsigned and non-transactional, they authenticate nothing and do not refresh or extend a session, which only the signed Authenticate handshake establishes. Each connection attempt times out after 10 seconds by default.
Subscribe-time snapshots
Most channels deliver their current state once, as aSnapshot frame the server injects after the subscribe ack and before that subscription’s first live event. Pass an onSnapshot callback to receive it as one array — a complete baseline, straight off the socket, with no REST read.
onSnapshot is always the last argument, and always optional — existing calls that omit it are unaffected. On a channel that also takes a tradingPairId filter, it follows the filter:
- The array is the whole channel, once. It is not fanned through the event handler — snapshot rows and live events are different shapes, so a handler that reduced both would apply the same state twice.
- Empty means empty. A user with no open positions gets
onSnapshot([]), not a skipped call, so you can clear stale state rather than guess. - It fires again after every reconnect. The resubscribe is a new subscription server-side, so a fresh snapshot follows. Apply it according to that channel’s semantics: complete maps such as positions or instruments can replace their prior set; the persisted resting-orders snapshot must be version-ranked against held events, with omitted resting rows resolved by
getOrderrather than deleted on absence alone. - It is scoped to the exact channel you subscribed to. Two pair-scoped subscriptions each receive only their own baseline.
- It is withheld rather than truncated. If any row of the frame cannot be parsed, nothing is delivered and your existing state stands. The array is contractually complete, so a short one would have you delete whatever the unparseable row described — and a resting order or an unchanged market has no later event to undo that.
One baseline per subscription, not per call. The client opens one wire subscription per channel however many handlers you register on it, and the server snapshots only genuinely new subscriptions. A second call made while that subscription’s first snapshot is still pending joins the handler set and receives the upcoming frame. A call added after the frame arrived has no cached baseline and waits until the next reconnect.
instruments is the exception — it caches its baseline and replays it locally, inactive markets included, so the replay matches what a real snapshot would carry. Subscribe once per channel and fan out in your own code, or read the baseline over REST for an extra consumer added later.onResync would otherwise prompt. Orders can still require targeted getOrder calls for locally resting rows the persisted snapshot omits.
movements and marketLiquidations take no onSnapshot: they stream updates rather than state. Use the matching REST history read.
Every row is the channel’s own terminal-state payload — the same shape as its live event’s data — with one exception. orders snapshot rows describe a resting order, not a change, so OrderSnapshotItem carries status and the cumulative fill totals and carries none of the per-fill metadata (tradeId, executionPrice, lastFillQuantity, role, tradesCount, and the fee, monacoFee and builderFee fields with their Raw twins):
| null fields are always present on the wire and carry null when unset; postOnly, expirationDate, triggerPrice, positionSide, clientOrderId, selfTradePreventionMode, parentOrderId, version, origin, placementTakerFeeBps and placementMakerFeeBps are the ones the server omits outright. Each of them is the value getOrder returns for the same order, so a row read on reconnect equals the REST row. positionId is the order’s position binding: a resting close or reduce-only order placed against an open position carries it from admission, before it fills, as described under the order events below.
version is the one field here that is not resting state. It is the same counter the live order events and the REST endpoints carry, and it is what a reconnecting client ranks this frame against — compare with >=, not >, and treat an absent value as unknown rather than zero. See reconnection and resync for the full rule.
Slow-client disconnect (close code 1013)
Every broadcast-ring gap that one of the socket’s subscriptions owns closes the whole socket with code1013 and reason slow consumer: stream gapped, resync required, including sockets holding only positions, balances, account, instruments, orderbook, ohlcv, or market-stat subscriptions. The ring is not key-aware: a lost terminal or isolated-key update need not have a later replacement. There is no separate JSON error frame for lag. A gap closes the socket only if one of its subscriptions owns it: orders, userOrders, conditionalOrders, twapOrders, positions, balances, account, instruments, liquidations, and single-market marketStats subscriptions own every gap, while other channels own only gaps reaching events published after their Subscribed acknowledgement (see the reference).
The server sends the same 1013 close when events may have been lost before they reached it (an engine drop under load, a failed publish, or its upstream event feed dropping or falling behind). Every socket on that server holding a subscription is closed, whatever its channels, and recovers through the same reconnect and onResync({ slowClient: true }) path.
Scoped resync without a reconnect
When you pass anonResync callback, sdk.ws offers the server the resync capability by default (scopedResync: true in the ws config; set false to opt out). A client without onResync never offers it and keeps the close-and-reconnect recovery, since it would have nowhere to report the channels. For a loss the server can attribute to one publisher (an engine drop, a failed publish, an engine restart), it then keeps the socket open instead of closing it:
- Channels it can repair in place (
instrument,ohlcv,orderbook, engine-sourcedtrades) get a fresh snapshot a few seconds later, delivered to youronSnapshothandler exactly like the subscribe-time one. Their live events wait behind it and may restate rows it already contains, so apply them idempotently. Fortradesandohlcv, merge the snapshot and keep newer rows rather than replacing them. - Every other affected channel is reported through the same
onResynccallback, once per burst, after the delay the server asked for:onResync({ code: 1013, slowClient: true, reason: "resynced", channels }). Refetch onlychannels; your subscriptions stay active and live events keep flowing. A handler written for reconnects, which ignoresreason, still works: it refetches everything. - A repaired channel with any subscriber that has no
onSnapshothandler is reported the same way, since the repair (aSnapshotframe markedresynced: true) would otherwise never reach your code. Such repairs are reported as they arrive.
depth, bbo and market-data trades repair themselves, and market_stats is self-healing, so none of them is reported. A loss the server cannot attribute (its upstream event feed dropping, falling behind or resubscribing), and your own connection falling behind, still close the socket with 1013 as above.
With automatic reconnect enabled, sdk.ws opens a new socket, sends its authentication/subscription requests, and invokes onResync with slowClient: true. The callback does not wait for Authenticated, Subscribed, or Snapshot frames. Reconciliation is application code: the SDK does not automatically refetch REST state.
A reconnect starts a new live stream; it does not replay missed events. Consume subscribe snapshots where supported (balances, orders, userOrders, conditionalOrders, positions, instruments, account, liquidations, twapOrders, marketStats, marketStatsAll, orderbook), reconciling complete sets and per-key revisions. The trades snapshot is the market’s newest 50 trades, merged with live trades by tradeId, and the ohlcv snapshot is the series’ newest 350 candles, upserted with live candles by T; neither is a complete set. Other channels, including marketLiquidations, have no subscribe snapshot: explicitly refresh the relevant REST state/history and reconcile events arriving during that read. Quiet keys may never produce a fresh live baseline; a flat margin account is covered by the account snapshot, which includes it even though the live producer emits for it only when its collateral moves. See the protocol recovery contract for snapshot limits, missing terminal updates, and additive-history gaps. Public trades, movements, and market-liquidation events have no universal WS replay cursor or guarantee of complete recovery beyond their REST history/pagination limits.
Session loss (close code 1008)
When the server finds that an authenticated connection’s session was revoked or expired (or the account was gated), it sends anError frame with code SESSION_INVALID and closes with 1008. When it rejects the Authenticate handshake itself, it sends AUTH_FAILED and closes. When the account is deactivated it sends ACCOUNT_DEACTIVATED instead, and when the session’s wallet is on the OFAC sanctions list it sends ACCOUNT_RESTRICTED, in both cases — on the handshake (the connection then closes with no status code, seen as 1005) or on revalidation (it closes with 1008). Branch on the error code, not the close code, which is transport detail. Reconnecting with the same session would only be rejected again, so after any of these the SDK does not reconnect automatically. It:
- forgets the rejected session keypair, so a later
connect()does not replay it; - keeps your subscription handlers;
- reports
"disconnected"throughonStatusChange; - calls
onReauthenticationRequired({ code, message, closeCode })once.
sdk.login(...) (or setSessionKeypair on a client from createMonacoWebSocket) reconnects, authenticates with the new session, resubscribes every channel you still hold, and then fires onResync with the lost connection’s close code so you can reconcile what was missed (also when autoReconnect is off). If you already supplied a new session before the close arrived — for example from onError — the SDK keeps it and reconnects with it instead of calling onReauthenticationRequired:
connect() yourself first: a socket opened without a session re-sends every held channel unauthenticated, and the server rejects the user-specific ones with AUTH_REQUIRED. The SDK re-sends those once the socket authenticates (see AUTH_REQUIRED below), but until then they deliver nothing.
ACCOUNT_DEACTIVATED is not fixed by signing in again: sdk.login from the same wallet is refused with 403 and the same serverCode. Surface it to the user rather than retrying. The same holds for ACCOUNT_RESTRICTED: signing in again is refused with ADDRESS_RESTRICTED.
The server also rejects a handshake with AUTH_FAILED when it could not verify the session because of an internal error; that case stops reconnecting too, and calling login again (or re-applying the same session with setSessionKeypair) retries it. Every other abnormal close keeps the backoff reconnect — including a 1008 for exceeding the inbound message rate, which carries MESSAGE_RATE_LIMIT rather than SESSION_INVALID, and a 1013 slow-client disconnect.
Subscription acknowledgements
Calling a channel helper only requests the channel. The server answers eachSubscribe with a Subscribed frame naming the channels that request added, and each Unsubscribe with an Unsubscribed frame naming the ones it removed. A channel the server rejected is left out of the ack and reported by its own Error frame instead. sdk.ws tracks both sides per channel string (the exact string a helper subscribes to, such as trades:<tradingPairId>):
On an open socket, the channels you subscribe in the same tick (a page mounting
instruments, trades and orderbook together, say) go out at the end of that tick as one Subscribe, in call order, and are "pending" from the call. A channel released in the same tick it was requested is never sent at all. A channel rejected with AUTH_REQUIRED stays wanted and is re-sent once when the connection next authenticates. After a reconnect, every channel starts unconfirmed and the client re-sends everything you still want, so a confirmation never outlives the connection that made it. Releasing a channel before its Subscribed ack arrives never reports it as subscribed. The React hooks’ subscribed flag still means the subscription was requested, not confirmed.
Server error frames
The server reports failures asError frames carrying a machine-readable code and a human-readable message. They match no subscription, so they reach no channel handler — the ws config’s onError callback is how they reach your app.
A per-channel failure also carries channel: the channel string the SDK sent, exactly as sent (a string longer than 128 bytes, never a valid channel, is echoed as its first 128 UTF-8 bytes, cut at a character boundary). The Subscribed / Unsubscribed acknowledgements list only the channels a request changed, so a rejected channel is missing from the ack and reported by its own Error frame instead; channel is how you pair the two. The SDK sends every held channel in one Subscribe on reconnect, so it is also how you tell which one failed and act on that channel alone. Connection-wide failures have no channel.
Always keep a
default branch: a code added server-side after your SDK version is delivered as-is rather than dropped.
Error Handling
WebSocket connection errors are handled gracefully:Best Practices
1. Let Auto-Connect Work In most cases, let the SDK handle connection automatically:WebSocket Types
Orders Channel
Subscribe to real-time order events for your account. Requires authentication.Event Timeline
Events are emitted in this order:- OrderPlaced → Order validated, persisted, funds locked (taker only)
- Match Events → Emitted after OrderPlaced
- Taker:
OrderMatched/OrderPartiallyFilled/OrderFilled - Each Maker:
OrderPartiallyFilled/OrderFilled
- Taker:
- OrderCancelled → For an
IOCorder’s unfilled remainder after a partial fill (anFOKthat can only partially fill for want of liquidity is rejected, not cancelled — its candidate fills are discarded). Also for a market,IOCorFOKremainder (or a margin limit remainder) the price band cut short, carryingterminalReasonPRICE_BAND, orSLIPPAGE_TOLERANCEwhen the order’s ownslippageToleranceBpswas tighter. Also for a resting perp order a mark move left past the perp price band, carryingterminalReasonPRICE_BAND. Also, when a risk bucket entersPENDING_LIQUIDATION, one terminalOrderCancelledper resting order in that risk bucket, carryingterminalReasonLIQUIDATION. Also, in liquidation zero mode, one terminalOrderCancelledfor a resting order whose fill as the maker would have left its risk bucket below zero equity and worse off, carryingterminalReasonBANKRUPTCY_PRICE— sent to that order’s owner. And after the taker’s own fan-out, one terminalOrderCancelledper resting order swept by self-trade prevention — sent to that order’s owner, carryingterminalReasonSELF_TRADE_PREVENTIONandstpCounterpartyOrderId. A maker subscriber therefore sees a terminal frame for an order it never cancelled - OrderRejected → For orders rejected after acceptance (an IOC/FOK that cannot fill for want of liquidity; a
MARKETorder whose liquidity is invalidated by maker-risk revalidation). An IOC/FOK stopped by self-trade prevention instead isOrderCancelled, even at zero fills. A plainMARKETsubmit with no liquidity is rejected before acceptance and emits nothing. - OrderExpired → For GTC orders reaching expiration
Taker vs Maker
Events contain different fields based on your role:Order Event Types
Every order event also carriesdata.version?: number, the same opaque, compare-only revision as REST reads and the orders subscription snapshot. Compare only states of the same order: higher means newer, gaps are normal, and absent means unknown, never zero. Merge a snapshot when snapshot.version >= local.version; it wins ties because several live events can share one sequencer step. See the reconciliation recipe.
Event value fields typed as KnownAlias | (string & {}) preserve unfamiliar server strings; keep a default branch when narrowing them. Each named event variant keeps its closed eventType discriminant.
Every order event’s data also carries an optional clientOrderId?: string (wire client_order_id) when the order was placed with one — echoed on placement, taker and maker fills, cancellation, rejection and expiry, so an event can be matched to the request that placed the order. A maker fill carries the resting order’s own handle. It is omitted when the order carried none.
Every order event’s data also carries origin? (wire origin) — what placed the order: "USER", "LIQUIDATION", "SYSTEM" or "ADL". It always agrees with getOrder: an order the account placed says "USER" on every frame (its OrderPlaced, its fills as taker and as maker, its cancels, partial cancels, rejection and expiry), a liquidation slice’s events carry "LIQUIDATION", and an ADL close’s fill event carries "ADL". No order is placed as "SYSTEM" today. An absent origin means not reported (a server predating the field), never "USER".
Every order event’s data also carries placementTakerFeeBps? and placementMakerFeeBps? (wire placement_taker_fee_bps / placement_maker_fee_bps): the Monaco taker and maker fee rates fixed when the order was placed, in basis points as decimal strings such as "4.5" and "-0.1" (a negative maker rate is a rebate). They are the values getOrder returns for the same order; a maker fill carries the maker order’s own rates. Both are omitted, never "0", for an order placed before rates were stored.
selfTradePreventionMode? is a common order-event field, not a cancellation-only one: the requested override rides the OrderPlaced acknowledgement, fills in both roles, OrderRejected, OrderCancelled, OrderPartiallyCancelled, OrderExpired and the orders snapshot frame. It echoes what the request carried and is absent when it carried none — it is never the resolved wallet or platform default, so absence does not mean CANCEL_MAKER was not applied.
A TWAP child order reports parentOrderId?: string (wire parent_order_id), the same id getOrder returns, so you can group a TWAP’s children from the socket alone. It rides every frame of the child: OrderPlaced, taker fills and remainder cancels, maker fills, OrderCancelled, OrderPartiallyCancelled, OrderExpired and its orders snapshot row. It is omitted for an order that is not a TWAP child, and by servers that predate the field. Which frames each kind of child sends is described under TWAP Orders Channel.
A margin order reports positionId?: string (wire position_id), the same id getOrder returns as positionId. An order placed while its margin account (cross) or risk bucket (isolated) already holds an open position in the pair is bound to that position when it is admitted, so a resting close or reduce-only order names its position before it ever fills. An order into a flat account or bucket is unbound until its first fill binds it, and that fill’s frames carry it. If the bound position closes and a later fill opens a new one, the frames follow the new binding. It rides OrderPlaced, taker fills and remainder cancels, maker fills (the maker’s own binding), OrderCancelled, OrderPartiallyCancelled, and OrderExpired. Those live events omit it while the order is unbound, always for spot, and on servers that predate the field. The orders snapshot differs: its positionId: string | null key is always present and is null for an unbound or spot order. A margin order’s OrderPlaced also carries marginAccountId, leverage and reduceOnly as getOrder reports them; reduceOnly is present on every margin order, false included, and all three are omitted for spot.
A margin order’s events also carry marginAccountId?: string (wire margin_account_id) on CommonOrderEventData: the margin account the order trades on — the default one or a sub-account — so an owner of several accounts can route events per account from the socket alone. It rides OrderPlaced, fills in both roles, remainder cancels and OrderPartiallyCancelled; a maker fill carries the maker’s account, not the taker’s. It is omitted for spot and on servers that predate the field.
An order admitted post-only likewise reports postOnly?: boolean (wire post_only) on its OrderPlaced acknowledgement, its fills in both roles, OrderPartiallyCancelled, OrderCancelled and OrderExpired — so a market maker driving state off this channel can confirm the flag was honoured without a round trip to getOrder, and keeps it across an in-place reduction rather than losing it mid-lifecycle. The maker fill matters most: an admitted post-only order can never produce a taker fill (a crossing one is refused at admission and emits nothing), so every fill a post-only order ever has is a maker fill — which now carries the flag. Two frames never carry it, and neither is reachable by a post-only order: OrderRejected, which only describes a MARKET order whose apparent liquidity is invalidated by maker-risk revalidation after acceptance, or an IOC/FOK unfilled for want of liquidity (one stopped by self-trade prevention is cancelled, not rejected — and a plain MARKET submit with no liquidity is refused before acceptance and emits nothing at all); and the OrderCancelled that reports the remainder of an IOC or market order after a partial fill, which is a different payload from the resting-order cancellation named above and declares no postOnly at all. Post-only is refused on MARKET and IOC/FOK alike, so a post-only order reaches neither. The key is only ever present as true; an order that is not post-only omits it rather than sending postOnly: false, exactly as the REST read model does, so data.postOnly ?? false reads the same as REST on every carrying variant.
Absent means “not post-only” — or an order placed before the flag was persisted, and a maker fill emitted by a server predating the maker-fill support — never “the flag was lost”. For those pre-field orders, the order-level value learned from the OrderPlaced acknowledgement (or a REST read) remains the authority; for everything since, ?? false is safe on any carrying variant, whichever event arrived last.
The structural snippets in this section assume these type imports:
- OrderPlaced
- OrderMatched
- OrderFilled
- OrderPartiallyFilled
- OrderCancelled
- OrderPartiallyCancelled
- OrderRejected
- OrderExpired
Edited Orders
Editing a partly filled order keeps its fills: the replacement (a new order id) carries them, and its frames report the order asgetOrder does. Its OrderPlaced carries the order’s total quantity, the carried fill in filledQuantity, that fill’s averageFillPrice, and status PARTIALLY_FILLED. Its fill frames report the total quantity, the cumulative filledQuantity and the averageFillPrice across every fill, carried ones included, and a remainder OrderCancelled or an OrderRejected reports the same total and cumulative fill (these two payloads carry no average); lastFillQuantity (and executionPrice on a maker fill) cover only that event. The original order’s OrderCancelled (terminalReason REPLACED) arrives first. Single and batch edits behave the same.
Orderbook Channel
Subscribe to orderbook updates. No authentication required.Magnitude Parameter
The magnitude parameter groups orderbook levels by price intervals:Orderbook Event Structure
Each live event restates the whole book, so replace your book with whichever event arrived last.Book sequence
When the server renders the channel from the market-data service, grouping covers the whole book rather than the top 20 levels, and each event and snapshot row carriesbookSeq and prevBookSeq. Every event restates the whole book, so ignore an event whose bookSeq is not past the last one you applied; a higher prevBookSeq needs no action. Both fields are absent when the server renders from the matching engine. sequence changes basis with the source and can go down, so never compare it across a snapshot or a reconnect (see the reference).
For the exact, ungrouped book, use the Depth Channel.
Depth Channel
sdk.ws.depth keeps a local copy of one market’s exact order book, up to 1000 levels per side, from the depth channel. No authentication required.
- The subscribe-time snapshot is the base. Frames that arrive before it are buffered, frames it already covers are dropped, and each later frame is applied only when it links to the book.
onBookreceives the whole book after the snapshot and after every applied frame.- When a frame does not link, a side of an incomplete snapshot thins below half its depth, or the connection drops, the SDK discards the book, calls
onResync, and rebuilds it from a fresh snapshot. Repeated rebuilds back off from 500 ms up to 15 s.onBookis called again once the new snapshot arrives. - On
SNAPSHOT_UNAVAILABLEit callsonResync("snapshot_unavailable")and stays subscribed: the server sends the snapshot when the book can be read. A trading pair ID with no active market in that mode never heals, so ifonBooknever fires, check the ID and mode. - Every
sdk.ws.depthcall for the same market and mode shares one subscription and one book. Each listener gets its own copy. - A trading pair ID that is not a UUID, or a mode other than
"SPOT"or"MARGIN", throws synchronously.
LocalBookOptions, the optional last argument:
stale means the book is exact as of bookSeq but was published more than about two seconds behind the exchange. A market maker would typically widen or pause quotes while it is set.
The frame shapes (DepthSnapshot, DepthDelta) are exported from @0xmonaco/types for callers that handle the raw channel themselves. React apps can use useDepth.
BBO Channel
sdk.ws.bbo keeps one market’s best bid and ask, with their sizes, from the bbo channel. It uses the same snapshot handoff and options as sdk.ws.depth. Each frame restates the whole top of book, so any newer frame replaces it.
useBbo.
OHLCV Channel
Subscribe to real-time candlestick updates. No authentication required.priceType argument ("trade" | "mark" | "reference" | "*") selects the price series. Omitting it — or passing "trade" — subscribes to the trade-derived candles exactly as above; "mark" subscribes to the close-only mark-price series for margin pairs (each candle’s open = high = low = close = the minute’s closing mark, volume 0); "reference" subscribes to the reference series, full OHLC of the mark price over each bucket, with volume and trade count always 0, for margin pairs, for charting markets with little trading; and "*" receives every series. The mark series is published on the 1m interval only — a "mark" or "*" subscription at any other interval succeeds but receives no mark frames. The reference series is published on every interval for margin pairs. The argument appends a fifth token to the ohlcv channel string, so existing four-token subscriptions are unchanged and never receive the mark or reference series. When you pass "*", read event.priceType (known series "trade", "mark" and "reference", with unfamiliar strings passed through) on each frame to tell the streams apart.
onSnapshot argument receives the subscribed series’ newest 350 candles, oldest first, once per subscription (and again after each reconnect) — the same candles as sdk.market.getCandlesticks with its default limit, including flat candles for periods with no trades. Each row is an OHLCVSnapshotCandle: the live Candlestick without s, with i set to the interval you subscribed to. Pass undefined for priceType to keep the trade series:
1m candle is the last closed minute and longer intervals can lag, so its newest candle can be partial or missing. A live candle is the full current state of its period, so upsert by T. A "*" price type gets no snapshot (one frame carries one series). An unknown pair, a tradingMode that is not the market’s, and "mark" or "reference" on a spot market get onSnapshot([]), as does "reference" on a margin market with no reference history yet. "mark" snapshots are served on every interval, though live mark candles stream on 1m only; "reference" snapshots and live candles both cover every interval.
OHLCV Event Structure
priceType identifies a concrete series, never the "*" subscription selector. Legacy frames may omit it. Keep a default branch for an unfamiliar series, interval, or trading mode.
Candlestick Structure
Trades Channel
Subscribe to trade executions. No authentication required.onSnapshot receives TradeEventData[]: the market’s newest 50 trades, newest first, once per subscription (and again after each reconnect). It is a recent-history baseline, not a gap-free replay — a trade can arrive both in the snapshot and as an early live event, and one executed just before you subscribed can be in neither — so dedupe on tradeId. Older history comes from the REST trades read.
Trade Event Structure
Sequenced trades
When the server servestrades from the market-data service, each event carries seq, matchIndex, prevSeq and prevMatchIndex, and onSnapshot receives a second argument whose cursor names the trade the live events continue from (null when the first live trade is the base). (seq, matchIndex) is the trade’s key. The helper then:
- drops a trade at or below the snapshot cursor or the last trade it delivered;
- delivers a trade that does not link to the last one, and reports it to
onGapfirst. The server forwards only linking trades, so a gap means a frame was lost on this client.
tradeId and treat the fields as optional. See the reference for the snapshot’s known limits.
Market Liquidations Channel
Stream public per-fill liquidation events for one margin pair. No authentication is required. This market-wide feed is separate from the authenticatedliquidations channel, which sends risk alerts for your account.
sdk.market.listLiquidationEvents with an inclusive startTime at the latest event timestamp and an exclusive current endTime, follow nextPageToken until exhausted, and deduplicate by eventId. The REST event-history endpoint returns newest-first pages, so the first request without a cursor returns the latest rows.
useLiquidationFeed performs the initial history read and reconnect backfill for you.
Balances Channel
Subscribe to changes in your spot balances. Requires authentication. Usebalances to keep each asset’s current spot balance up to date; use movements for the individual ledger entries behind those changes.
balance_update carries one spot balance row. Margin collateral is not a spot balance and never arrives here: a deposit routed into margin collateral, a perpetual fill and its fee or rebate, a funding settlement, a liquidation and an auto-deleveraging close send no balance_update. Follow margin collateral and equity on the account channel.
Balance Event Structure
Update Reasons
BalanceUpdateReason also lists margin_deposit, funding, liquidation and adl so existing code that names them compiles; the server does not send them. Match reason with a default branch.
Only the resting part of a spot order is locked. A market, IOC or FOK order never rests, so it sends no lock or unlock; neither does a refused order or a margin order, which reserves margin collateral instead of spot balance.
A lock or unlock frame carries the row’s state after the whole step, with that step’s version, and its referenceId is the order. One step sends one frame per row:
- An order that fills and rests its remainder sends only
trade, which already carries the locked amount. A step that fills on a row it also releases sendstradethe same way. - A replace on the same row sends one frame,
lockwhen the replacement rests, with the final state. - A batch create or batch cancel (cancel-all included) sends one frame per row with its final state;
referenceIdis the last order in the batch that changed the row. - After an engine restart, a fee-lock reconcile re-sizes a row’s lock across all its resting orders and sends no
referenceId; orders it cancels as unfundable are covered by that frame.
user_movement frame on the movements channel.
For a genuine spot row, version is the durable revision scoped to (user, application, token); REST, the subscribe-time snapshot and a live event for identical state carry the same value. Only a positive version is rankable: apply a higher one, treat an equal one as a duplicate, and ignore a lower one for the whole row. 0 means the same as absent — unranked — so compare fields instead and apply an unversioned frame fail-open. A snapshot can carry a persisted 0. Never order balance state by updatedAt or arrival order. A collateral transfer that moves the spot row sends a versioned event for that leg; a transfer between a margin account and one of its risk buckets moves no spot balance and sends no event.
React apps can use useUserBalances.
Movements Channel
New in v0.5.4 - Real-time balance movement tracking
Movement Event Structure
Movement Types
entryType and transactionType are free-form and not normalized to one casing, so compare them case-insensitively. Deposit, withdrawal and funding frames use lowercase (entryType credit / debit; transactionType deposit, withdrawal, funding). An order unlock uses uppercase: entryType UNLOCK and transactionType TRADE, the same types movement history gives that row, with referenceType Order, referenceId the order, and a description that starts with OrderCancelled, OrderPartiallyCancelled or OrderExpired. Its id is the id of the same release’s movement-history row, so a client reading both deduplicates by id. A TRADE frame on this channel is always an UNLOCK.
Example: Track Deposits
Example: Monitor Order Unlocks
sdk.ws.orders (the lifecycle event) and the resulting state (sdk.ws.balances carries a versioned spot row for each spot leg; a perp fill sends no balance frame, and its collateral arrives on sdk.ws.account), and read its ledger entries — including fee and rebate rows — from movement history with sdk.profile.getPaginatedUserMovements().
React Hook
For React applications, use the dedicated hook:Conditional Orders Channel
Stream TP/SL conditional-order lifecycle events. Requires authentication. Authentication is checked at subscribe time — unauthenticated subscribe attempts are rejected. Ensure the SDK is authenticated before callingsdk.ws.conditionalOrders(...).
Subscribe to Conditional Orders
Conditional Order Event Structure
triggeredOrder, the summary of what the triggered order has done — it is a REST-only field on getConditionalOrder and listConditionalOrders. triggeredOrderId is the id alone: the live reason: "triggered" update carries it with state: "TRIGGERED" and triggeredAt, the same values REST returns. The subscribe-time snapshot lists only the non-terminal states (PENDING_PARENT, ACTIVE, TRIGGERING), never a TRIGGERED row, so a trigger that fires while you are disconnected is not replayed on reconnect: read it from REST, along with the close’s status, filled quantity and average fill price. The close’s own reverse pointer, Order.conditionalOrderId, is REST-only on the same terms: no order event and no order snapshot row carries it either, so a close first seen over the socket acquires it on the next REST read. See Trigger Price Is Not Fill Price.
Conditional-order version is on every live update, on the subscribe-time snapshot and on REST reads, always the revision of the row the update describes. It is opaque and compare-only, for the same conditional order, with absent meaning unknown rather than zero. When both sides carry one, merge with incoming.version >= local.version: >= because one step can write several conditional orders at one version (a trigger and its OCO sibling), so equal means “same step”. Never compare an absent version numerically (42 >= undefined is false): a versioned row supersedes an unversioned one, and two unversioned rows compare on updatedAt. Do not compare versions across different orders.
Value unions are what the server sends today, not a guarantee
@0xmonaco/core parses this frame leniently: an unrecognized orderType, timeInForce, state, side, positionSide, conditionType, triggerSource, associationType, or reason is passed through as-is rather than rejected. Throwing inside the message handler would drop the whole update with nothing surfaced to the consumer, and a dropped frame is the worse failure — the position, liquidation, and market-stats parsers already make the same trade. Only eventType stays strict, so a mis-routed frame is still rejected.
Match these fields with a default branch rather than relying on exhaustiveness:
reasonis free-form on the wire. Every transition sends exactly one update. The canonical livereasonvalues arecreated,activated(aPENDING_PARENTorder wentACTIVEwhen its entry order filled),triggered,oco_cancelled(its linked sibling triggered),parent_cancelled(its entry order was cancelled, expired, replaced away or swept before it filled),position_closed(its position closed),cancelled(a user cancel, a new full-close order superseding it, or a trailing stop refused because the position already has one, with the reason infailureReason),reparented(aPENDING_PARENTorder moved onto its entry order’s replacement:parentOrderIdchanges,statedoes not),failed,expired, plusarmedandratchetedon aTRAILING_STOProw (tracking began; the trigger moved, eachratchetedframe carrying the newtriggerPriceandwatermarkPrice). They are exported asConditionalOrderEventReasonfor narrowing, and the subscribe-time snapshot reuses the same shape withsnapshot. Any value the server adds arrives unchanged.timeInForceis the fullTimeInForceenum, not theGTC/IOCsubset the creation paths validate: it mirrors an unconstrained server column that maps exhaustively over every variant, so a value written by another path reaches the wire as-is.orderTypeisLIMITorMARKETin practice — both creation paths reject anything else — but that rests on validation rather than an exhaustive mapping, so a relaxed validation would surface the raw variant (e.g.STOPLOSSrather thanSTOP_LOSS).
Lifecycle and recipients
The triggered order itself emits regular
OrderEvent messages on the orders channel. To follow a TP/SL all the way through to fill, subscribe with either sdk.ws.orders for its pair or sdk.ws.userOrders for all pairs.
For higher-level usage, see Order Management and sdk.positions.attachPositionTpSl.
Positions Channel
Stream live position state — size, mark price, unrealized PnL, liquidation price, isolated margin, and leverage. Requires authentication; the signed session-key handshake is sent before the subscribe. Pass an optional trailingtradingPairId to filter to one pair; omit it to stream every pair.
The channel carries two kinds of frame: lifecycle position_update frames, delivered to the handler, and periodic position_valuation frames, delivered only to the optional onValuation callback.
Detected lag closes the whole socket with 1013 under the slow-client rule. Reconcile lifecycle frames on version per positionId (see the merge rule below), never on updatedAt, and refetch a snapshot on reconnect (see Reconnect and resync).
Subscribe to Positions
Position Event Structure
position_update frame now carries a position’s scope and margin requirements, so the common case — knowing which risk bucket a position sits in, whether it is cross or isolated, and what it requires at the current mark — no longer needs a REST read. positionId, marginAccountId, tradingPairId, riskBucketId, marginMode, status, side, size, entryPrice and isolatedMargin are the same values on the subscribe-time snapshot, on the live frames, and on GET /api/v1/positions.
The frame is still not a superset of the REST position. indexPrice, realizedPnl, netRealizedPnl, exitPrice, feesPaid and cumFees are REST-only, so a consumer that needs any of those still reads REST.
fundingPaid is the position’s lifetime funding since it opened (positive = paid, negative = received). On an open position it is the funding accrued on the size still open plus the funding its partial closes settled. Every live frame carries it — fills, funding settlements, margin changes, and terminal frames, whose value is the closed position’s whole-life total and matches fundingPaid on a REST read of the closed position. A snapshot row carries the value the position’s live frames carry. It is absent, never zero, when the value cannot be served whole: the position’s earlier reductions are not fully accounted for, its history is still catching up, or the frame comes from an older server. Keep the value you already hold for that positionId rather than clearing it. position_valuation frames do not carry it.
The six re-derived values (markPrice, unrealizedPnl, liquidationPrice, leverage, maintenanceMarginRequired, initialMarginRequired) are recomputed per payload rather than read from the versioned row, so take them from the newest frame, as the merge rule below already requires. Five of them are priced at whichever mark the answering surface holds, so a snapshot row and a live frame can differ by one oracle tick. A snapshot row for a position the matching engine holds is built from the engine’s live state and priced at the engine’s mark, like the live frames; a row the engine does not serve is read from the database and prices its whole row from a single mark, which keeps each row internally consistent. leverage is the exception: it is opening notional over posted margin, which no reprice touches, and it is in this group because the live surfaces derive it from engine state while the persisted row serves its stored leverage column, so the two can disagree at an unchanged version.
A position with no risk bucket — one opened before risk buckets existed — reports neither riskBucketId nor marginMode. Do not infer either field from the other: they are resolved from different sources — the id from the position, the mode from the matching engine when it holds the position and from the risk-bucket record otherwise — so an id can arrive with no mode, on a terminal row whose record is unavailable, on a read taken while the engine is unreachable, or from a producer older than the field. Treat a missing marginMode as unknown and keep the mode you already hold; defaulting it to CROSS renders an isolated position with the wrong backing.
updatedAt is when the position was last mutated — a fill, collateral transfer, funding settlement or close — carried from the producer’s own record of that mutation. Two frames for a position nothing has changed repeat one timestamp, and a valuation-only oracle reprice does not move it. It is display metadata: reconcile on version, never on updatedAt.
openedAt is when the position opened — the fill that took its exposure from zero to non-zero. It rides the snapshot rows and the live frames, terminal frames included, and never moves for the life of positionId: time held is now - openedAt while open and updatedAt - openedAt on the terminal frame. A flip closes the position and opens a new one with a new positionId and its own openedAt. It is absent when the server does not know it — a terminal frame whose close it could not attach the instant to, or a frame from an older server — and is never filled with the time the frame was sent; keep the value you already hold for that positionId. Clamp a computed time held at zero: the value can sit slightly ahead of your clock, and after a server restart it can move by the server’s own clock drift. It is not versioned: take it from the newest payload — frame or REST read — rather than skipping it on an equal version, because a one-off server correction can move it earlier without a version change.
The match across surfaces is exact for an open position. A terminal REST read carries the instant the matching engine emitted the close, which can differ slightly from the live frame’s close instant; older terminal rows may instead carry the persistence clock and trail the close by the persistence lag. Any position read after a matching-engine restart is also served from the persistence clock and differs by that lag. Treat it as accurate to that lag.
One further case is not bounded by that lag. While the matching engine is unreachable, a REST read of an open position falls back to that row’s lifecycle timestamps — close, then last funding, then open — so a position filled repeatedly since it opened reports its opening instant until funding settles. A live position_update frame is unaffected, being built from engine state, so a REST value older than a frame you already hold is expected in that window and the frame is the fresher one.
version is the position’s opaque, non-negative durable row revision. The subscribe-time snapshot and a ranked live frame for identical position state carry the same value, so a client reconciles on positionId + version. Never use updatedAt, arrival order, or receiver backlog to establish ordering. Not every live frame is ranked — see the note below for which are, and treat an unranked frame as unranked rather than assuming it is comparable with a snapshot row.
The merge rule is per field group, not per frame — a frame is never discarded wholesale:
A snapshot row wins an equal-version tie because it is built from live matching-engine state under the version of the persisted row, and persistence can lag the engine: its fields can reflect a later command than its version names. Skipping them on an equal version would keep the values the reconnect was meant to correct.
Note the asymmetry between the Equal and Lower rows. An equal version means the same command, so its valuation fields are a legitimately newer sample of the same position state. A lower version means an older command, so nothing it carries is newer — including its mark. Arrival order is only a fallback where there is no version to compare at all; it must never override a positive version, for the same reason the first paragraph rules it out as an ordering signal.
Discarding a whole frame on an equal version is the one mistake this field is likely to cause: it freezes displayed unrealized PnL until the position’s next real mutation, which for an idle position is the next hourly funding settlement.
Only a POSITIVE version is rankable, and “unranked” has two wire spellings that mean exactly the same thing:
- On the WebSocket — live frames and the subscribe-time snapshot alike — the key is absent. The snapshot maps the persisted
0sentinel to an absent key before it reaches the wire, so apositionspayload never carries a literal0. - On REST/gRPC, where the field is a required int64 and cannot be omitted, it is the literal
0.
- The emit path wrote no position row at that sequence, so there is no versioned row to point at.
- The frame came from a producer that predates position versions.
funding movements) are sent at a paced rate after the settlement, so on a large book they can arrive several seconds later, after frames for later fills and in any order across channels — rank by version, and never reopen a position you hold as terminal (a position closed before its account’s frames are sent gets no funding position frame); terminal frames retain lifetime reduced quantity (the sum of every reducing execution), lifetime allocated initial margin, and the exact CLOSED/LIQUIDATED classification. A terminal frame remains unranked after a restart when legacy reduction history lacks size or margin fields needed to calculate complete lifetime values. Incomplete size history carries only the known partial total. This is an explicit compatibility fallback, not version 0.
The version advances on sequenced mutations: open, add, reduce, partial and full close, funding, margin transfer, liquidation and ADL — exactly when the row’s versioned content changed at that sequence, including a change that landed and was undone within one persistence batch. A sequenced command carries its whole margin account’s open positions, but a position the command left unchanged keeps its version (and its
updatedAt): an untouched sibling of a filled position does not move. A ranked live frame is stamped with its command’s sequence, so a frame for such a sibling can carry a higher version than the REST row while describing identical content — rank it as newer and apply it; nothing changes. Advancing still never misses a change: every mutation of the row’s versioned fields stamps a new version.maintenanceMarginRequired and initialMarginRequired sit in the valuation column for the same reason markPrice does: both are computed from the current mark, so they move on an oracle tick with no mutation behind them.A valuation-only oracle reprice — the overwhelming majority — persists nothing and advances no version, which is the whole point of the carve-out; the new figures reach clients through position_valuation frames instead. A state-transitioning oracle command (a fresh maintenance-margin breach, or a recovery out of one) does persist the affected account’s snapshot, so any open position whose versioned content it changed is restamped like any other sequenced mutation. Both cases are safe to merge with the rules above; the distinction matters only if you are reasoning about how often the version moves.Which live frames are ranked. A frame carries a version only when it is a faithful projection of the row persisted at that sequence. That includes fills, non-zero funding settlements, terminal closes/liquidations/ADL with complete lifetime margin history, and an isolated position’s direct margin add or reduction. Each is built from captured post-command state.These frames are deliberately unranked:- Frames whose command did not write position rows at that sequence. A parent-to-cross-risk-bucket
ALLOCATEorRELEASErefreshes every open position in that risk bucket. These carrymargin_addedormargin_reducedbut no version. - A terminal frame after restart when pre-upgrade reduction history has an incomplete close payload. The frame still reports the terminal status and known values, but omits
versionso partial lifetime size or margin cannot compete with REST’s fallback at an equal version. Reconcile it by full field comparison; never coerce the missing version to0.
IN/OUT, a plain isolated-risk-bucket ALLOCATE with no position collateral delta, and any selected risk bucket with no open positions send no position frame. Transfer frames are built only after the command succeeds and are released only after its durable acknowledgement.Position Valuation Frames
A mark move changes an open position’s mark price, unrealized PnL, liquidation price and margin requirements without any lifecycle event behind it.position_valuation frames refresh those five figures for every open position about every five seconds, on the same positions and positions:{tradingPairId} channels, to the same owner. They are not yet enabled on any network; until they are, the positions channel carries lifecycle frames only.
- Valuation only. A valuation carries no
size,side,status,reason,updatedAtorversion, and never opens, closes, resizes or reopens a position. Lifecycle stays onposition_update. - Apply it only to a position you hold as
OPEN, and only whenvaluedAtis later than that position’supdatedAt. Valuations are spread over each interval, so one can arrive after a newerposition_updatefor the same position — a fill, a close or a funding settlement that happened after the server read it.valuedAtandupdatedAtcome from the same server clock, so a valuation whosevaluedAtis not later than theupdatedAtyou hold predates that state: ignore it, and the next valuation brings current figures. Ignore a valuation for an unknown or terminalpositionIdtoo. The server also withholds a valuation read before aposition_updateit has already sent for that position, including updates such as a risk bucket transfer that do not moveupdatedAt, so the rule is a backstop. Under these rules a late valuation can neither resurrect, close nor roll back a position. - Same figures as a lifecycle frame. The five figures come from the same derivation as a
position_updateframe for that position at the same state; apply them like that frame’s valuation fields. They carry no version, so they never rank against a lifecycle frame’s versioned fields. - Cadence. A position’s frame is sent the first time it is read, then only when one of the five figures changed, and otherwise periodically, normally at least once a minute. A position closed in between gets no final valuation: its terminal
position_updateis the last word. liquidationPriceisnullwhen none can be derived, for example an isolated risk bucket shared by several positions or one whose collateral basis is not positive. Keep the key; it is always present.valuedAtis when the server read the position. It is not the oracle’s publish time and notupdatedAt, which moves only on a mutation.
handler never receives a valuation, and without onValuation the SDK ignores them silently. SDK versions released before onValuation log one parse error per valuation frame and keep the socket open, so upgrade before relying on the channel staying quiet.
A position closed or liquidated by an order fill emits a terminal event whose retained size, lifetime isolatedMargin, status and—when history is complete—version match the persisted terminal row. size is lifetime reduced quantity: a position opened at 10, reduced by 4, then closed by 6 reports 10, not the exposure before the final close. Detect closure from status, never from size === "0". Positions closed by the liquidation worker or auto-deleveraging do the same: liquidation victims, backstop exits and the bankrupt ADL side report LIQUIDATED; an ADL counterparty flattened while absorbing the deleverage reports CLOSED; reason remains "liquidation" or "adl". A surviving or partially reduced counterparty receives an OPEN refresh. A non-zero funding settlement emits a ranked position_update with reason "funding" carrying the command’s captured post-settlement state; zero payments emit no position frame.
Which positions a fill’s frames cover. A fill sends a live OPEN frame for every open position in each risk bucket it touched, on both sides of the trade: the taker’s bucket, through the position its order is bound to (the order’s positionId), and each maker’s bucket, through the position that maker’s resting order is bound to. A cross risk bucket’s positions share its collateral, so a trade in one pair moves every other position’s liquidationPrice in that bucket, and each of them, in any pair, gets a frame. An isolated risk bucket holds a single position, so it gets that one frame. Positions in the account’s other risk buckets are not re-sent, including a position in the same pair held in a different bucket. A position is sent at most once per fill even when it is on both sides, and every frame carries the fill’s version. A position whose content the fill did not change can therefore arrive with a higher version than its REST row, which the version rule above already covers. Each frame names its own tradingPairId, so a positions:{tradingPairId} subscription receives the frames for positions in its pair even when the trade was in another pair. Liquidation slices and auto-deleveraging follow the same rule for the liquidated account’s risk bucket and each counterparty’s risk bucket. A resting placement that trades nothing sends at most one frame, for the position the order is bound to.
Liquidations Channel
Stream liquidation alerts — the authenticated account crossed a maintenance threshold and a liquidation record was opened or advanced. Requires authentication; the signed session-key handshake is sent before the subscribe. Pass an optional trailingtradingPairId to filter to one pair; omit it to receive account-scoped alerts (which arrive on the bare liquidations channel) as well as pair-scoped ones.
Lag policy is Disconnect: a subscriber that falls behind has its whole connection closed with 1013 (try again later), taking every other subscription on that socket down with it — see Slow-client disconnect. There is no wire sequence number, so treat a 1013 close as the gap signal and resync by refetching a snapshot — see Reconnect and resync.
Subscribe to Liquidations
Liquidation Event Structure
status: "COMPLETED", riskBucketState: "Normal", and the episode’s liquidationId. The same COMPLETED / Normal pair also closes an episode that was liquidated to completion and left the account healthy, so the pair marks the episode’s end, not a recovery — do not infer from it whether a liquidation executed. Let the terminal alert supersede any UNRESOLVED snapshot row for the same liquidationId. See Recovered episodes.
Account Channel
Stream account-level margin health for the authenticated user — equity, collateral, free collateral, margin ratio, maintenance requirement, initial margin required, withdrawable collateral, and realized PnL, per margin account. Requires authentication; the signed session-key handshake is sent before the subscribe.The server-side producer that emits live
account_update frames now runs on every network, mainnet included. Enablement is a deployment switch rather than a delivery guarantee, so confirm you are receiving frames before you build on a live stream. The subscribe-time snapshot below is served by the WebSocket API rather than that producer, so it arrives regardless. See the WebSocket reference.marginAccountId, never as an additive delta. Dedupe LIVE frames per margin account by updatedAt, newest wins; use sequence only to break ties between frames carrying the same updatedAt (those come from the same producer run). A snapshot row is not ranked this way — any live frame supersedes it, see Account Snapshot. Never treat sequence as a cross-restart order: it resets to 0 when the producer restarts, which can happen while your socket stays connected. It may also skip values because publishing and delivery are at-most-once. A sequence gap alone is not a replay cursor; an explicit 1013 close still requires reconciliation of potentially missing account keys. A subscribe-time snapshot arrives ahead of the live frames, so no REST call is needed for a baseline — see Account Snapshot below.
Flat accounts
Live frames flow on a ~2s tick for every account holding an open position or a resting order reserve. When its last position and reserve clear, one final frame carries the released state, and after that a flat account sends no keepalive. It still gets a frame after its collateral moves: a transfer between the spot wallet and margin, an on-chain deposit into margin or a withdrawal from it, a transfer between your margin accounts, closing a sub-account, or an insurance payout. Moves that land before the producer’s next tick collapse into one frame carrying the latest state, and that frame normally arrives on the next tick, within ~2s of the move. The producer reads at most 512 flat accounts per tick, so a burst wider than that spreads over the following ticks. The frame carries the account’s current collateral and uses the same per-accountsequence as every other live frame, so the dedupe rule above applies unchanged. For a flat account with no open risk bucket, equity and freeCollateral equal collateral, and maintenanceRequirement and marginRatio are "0". On-chain deposits and withdrawals also appear on the movements channel.
Subscribe to Account Updates
Account Snapshot
On subscribe, before any live frame, the server sends one row per margin account you own in the application you authenticated under — including flat accounts, whose live frames are rare: the producer’s periodic frames cover only accounts holding an open position or a resting order reserve, and a flat account gets a live frame only when its collateral moves (see Flat accounts). Each row carries that account’s current persisted health. An account that is flat and otherwise empty — no positions and no open risk bucket — reports its deposited collateral as bothequity and freeCollateral, with a zero maintenanceRequirement and a zero marginRatio. An account that is flat but still holds an open risk bucket keeps that bucket’s allocated collateral and unsettled realized PnL, so its equity need not equal its collateral. Owning no margin account delivers an empty array rather than no callback at all, so “you have none” is distinguishable from “this channel has no snapshot”.
A live frame always supersedes a snapshot row for the same marginAccountId, whatever the timestamps say. Use updatedAt to order live frames against each other — not to rank a snapshot row against one. The snapshot is a PostgreSQL-backed baseline stamped when the state was persisted, while a live frame is stamped when the engine sampled it; under persistence lag a later-written row can carry older state, so the two clocks are not comparable. Treat the snapshot as a seed that the first live frame for that account replaces. That includes frames emitted before your subscription activated: the connection buffers them while the snapshot read runs and delivers them afterwards, so one can supersede a newer snapshot row. A later frame normally corrects it within the ~30s keepalive; the exception is an account that went flat in that window whose final frame never reached you, which leaves a stale active account until you reconnect and re-seed. A durable per-account version would fence this — until it exists, reconnecting is the recovery.
The live stream is slightly wider than the snapshot: account_update frames are delivered per user, so if the same wallet trades through more than one application you also receive live frames for those accounts. Each frame is full current health, so such an account baselines itself from its next frame (within the ~30s keepalive) even though the snapshot does not list it.
A snapshot row is the live payload with sequence omitted. The counter belongs to the matching engine’s producer process and the WebSocket API cannot mint one; a 0 would be worse than absent, because it is indistinguishable from a restarted producer’s first frame. Its absence costs you nothing, because a snapshot row is never ranked against a live frame by updatedAt or sequence in the first place — any live frame for that account supersedes it.
Account Event Structure
initialMarginRequired, withdrawableCollateral, and realizedPnl are the values the margin-account summary returns for the account’s parent row, on live frames and snapshot rows alike. Every current frame and snapshot row carries all three. They are optional only so a frame from an older server still parses. Treat undefined as unknown and keep your last value, never as zero.
TWAP Orders Channel
Stream live progress for your TWAP parent orders. Requires authentication. Subscribe to every TWAP parent you own, or scope to one market with the optionaltradingPairId. The channel is private — only the owner receives events — and delivers the full parent snapshot on every lifecycle transition (creation, each placed or skipped slice, cancellation, completion, the passive-child mutations of a PASSIVE parent, and the activation or expiry of a conditional one), tagged with the reason for the update, so a client renders live progress without polling.
Subscribe to TWAP Orders
TWAP Order Event Structure
Subscribe-time snapshot
Pass anonSnapshot callback — the third argument, after the optional tradingPairId — to receive the current baseline as one TwapOrderEventData[], injected ahead of the live frames so no REST read is needed to seed state:
reason: "snapshot". The baseline carries only the owned non-terminal parents — PENDING (awaiting its start window, or, for a conditional parent, still awaiting its price trigger — which can be indefinite) and ACTIVE (triggered/started and slicing) — optionally scoped to the filtered pair; an owner with no live parents receives onSnapshot([]), never a skipped call. Terminal parents (COMPLETED / CANCELLED) are history and never appear — read those from listTwapOrders. The shared rules — one baseline per wire subscription, all-or-nothing delivery, and a fresh baseline after every reconnect — are covered under Subscribe-time snapshots.
Merge live frames and snapshot rows for the same twapOrderId by version:
accept >=, ignore a lower version, and let a snapshot win an equal-version
tie. Versions are opaque and may skip. An absent version means unknown legacy
state, not zero; fall back to a full-field comparison or REST reconciliation.
Never compare versions across different TWAP parents.
Treat the baseline as a seed, not an authoritative replacement set. Like the persisted orders snapshot it is PostgreSQL-backed, so a parent that reached a terminal state just before you connected can still arrive as ACTIVE/PENDING if that transition has not yet been persisted — and its terminal event, sent before this subscription existed, does not re-fire. Absence from the baseline is likewise not a tombstone. So merge the snapshot into local state rather than replacing it wholesale (the setTwapParents above is the first-connection seed), and reconcile a parent that then stays live with no further frames against the REST TWAP read.
Each child order is a regular order, so its lifecycle flows through the standard Orders Channel above, and every one of its frames names the parent in parentOrderId (the same field the order read model carries):
- A taker slice (a
TAKERslot, or aPASSIVEparent’s deadline sweep or crossing take) sendsOrderPlaced, then its taker fill, then anOrderCancelledfor the remainder when the price band stops it short. Each resting order it fills receives its maker fill exactly as it would from any other taker. Its fills reach the other channels as any taker’s do: one public trade per fill ontrades, theorderbookupdate and the live candle onohlcv, and balance and position updates for the owner and for every resting order it filled, including the closing position update when the slice closes a position. - A passive child sends
OrderPlacedwhen it rests and a maker fill each time another order trades against it. It sendsOrderCancelledwhen it leaves the book: withterminalReasonREPLACEDwhen a reprice or the slot’s deadline sweep replaces it, andUSER_REQUESTEDwhen the parent is cancelled or reaches its end time. On a reprice, the old child’sOrderCancelledarrives before the new child’sOrderPlaced, so tracking the child here resolves thechild_repricedambiguity above. Each time a passive child rests or leaves the book, theorderbookchannel gets an update, and when a spot child leaves the book its owner gets the sameuser_movementframe for the released lock that cancelling the order by hand sends.
version. A slice that places nothing sends no order frame for the child itself. A resting order that a TWAP slice cancels, for self-trade prevention, because it failed its risk re-check during the match, or because the slice closed the position it reduced, receives an OrderCancelled frame in the same step, as it would from any other order, even when the slice itself is refused. A spot order’s released lock also arrives on the movements channel.
Forced closes arrive on the same Orders Channel, each frame carrying the version of the step that produced it:
- Resting orders cancelled by a liquidation. When a risk bucket enters liquidation, each of its resting orders receives an
OrderCancelledwithterminalReasonLIQUIDATION. - Reduce-only orders cancelled by auto-deleveraging. When ADL fully closes a counterparty’s position, each of that risk bucket’s resting reduce-only orders on the pair receives an
OrderCancelledwithterminalReasonAUTO_DELEVERAGING; the counterparty was deleveraged, not liquidated. - Liquidation orders. The engine’s liquidation order sends its owner
OrderPlaced, then its taker fill, then anOrderCancelledfor any remainder. A liquidation order the book could not fill at all sendsOrderPlacedthenOrderRejectedinstead, or no frame if it was cancelled without a fill. Each resting order it fills receives its maker fill exactly as it would from any other taker. - Auto-deleveraging (ADL). The bankrupt account’s close order sends its owner one
OrderFilledper ADL step. Each account ADL closes against receives the fill frames for the close order recorded against its position, exactly as a resting order receives them from any other taker: one per trade, each at that trade’s price, the last beingOrderFilled. A position closed partly at the bankruptcy price and partly at the mark price therefore receives anOrderPartiallyFilledand then anOrderFilled. Those frames arrive only when the whole ADL episode ends, not as each step executes, and still carry theversionof the step that filled them, so the usual>=merge applies. If the matching engine restarts during an episode, they are not sent; read the orders withgetOrder.
OrderCancelled frame in the same step.
Market Stats Channel
Stream periodic market statistics. Public — no session handshake required. Detected lag closes the whole socket with 1013 under the slow-client rule. Each frame carries the market’s full current stats, so consume it as a terminal-state snapshot rather than a delta. Two subscriptions:sdk.ws.marketStats(pairId, handler) for one market’s full stats, and sdk.ws.marketStatsAll(handler) for a slower-cadence reduced summary of every published market in a single frame (a market that has not yet assembled its first frame is omitted; the subscribe-time snapshot carries the same set). Perp-only fields (index/mark price, open interest, funding) are omitted for spot markets and surface as undefined. Until a perp’s open interest is known, openInterest and openInterestHeadroom are undefined (never "0") while every other field still arrives; once known, every frame carries the most recent value. Every per-market frame carries marketStatus, the same label as that market’s all-markets row. Perp frames also carry fundingIntervalSeconds and lastFundingTime; lastFundingTime is a millisecond epoch number (REST getFundingState returns it as an RFC 3339 string) and is undefined until the market’s first settlement. premium is the open funding window’s running mean premium, and fundingRate is the estimated next-window rate (currentFundingRate is the last settled one): premium divided by 60 while no settlement is pending. fundingClampSmall and fundingClampBig both carry the largest rate a settlement can reach, "0.000125" by default. Each perp row of marketStatsAll also carries indexPrice, fundingRate, premium, and openInterestNotional: openInterest priced at the mark, falling back to the last trade price when there is no mark, in quote units. openInterestNotional is undefined (never "0") until both open interest and a price are known.
Both accept an optional onSnapshot that delivers the current baseline before the live frames: marketStats yields a one-element MarketStatsData[] with that pair’s latest complete frame, and marketStatsAll yields a MarketStatsSummary[] with one summary per active market with retained data, deterministic by trading-pair id. Prefer it on reconnect over waiting for the next periodic push. A per-market subscription whose market has not yet produced a frame — or on a transient read failure — receives a SNAPSHOT_UNAVAILABLE signal rather than a fabricated one; a later published live frame seeds it once the market produces one. marketStatsAll can likewise return SNAPSHOT_UNAVAILABLE for a brief window during initial startup or after a server restart (until the producer commits its first full generation), or on a transient snapshot read failure, rather than a partial list; handle the coded error as a resync signal (the next marketStatsAll push delivers the baseline) rather than assuming onSnapshot always fires. Where a deployment does not run the market-stats snapshot service, neither form sends a snapshot or a SNAPSHOT_UNAVAILABLE signal at all — the first live push seeds state, so never block on onSnapshot firing.
Subscribe to Market Stats
Market Stats Event Structures
Instruments Channel
Stream market lifecycle and configuration changes: new listings, delistings, and edits to tick size, quantity step, order-size bounds, or category. Public — no session handshake required. Detected lag closes the whole socket with 1013 under the slow-client rule. On reconnect, apply the new subscribe snapshot; if unavailable, reconcile over REST rather than waiting for another event from every market. Every frame carries the market’s full current configuration as a terminal-state snapshot — never an additive delta — withchangedFields naming which fields the mutation touched, for handlers that want to react selectively.
The list of every listed market arrives before the live diffs, so a subscriber always starts from a complete baseline. It includes inactive markets flagged isActive: false, so positions, orders and history on a delisted market can still resolve its metadata; filter on isActive for the tradable set. Markets not yet launched are not included. How it is delivered depends on whether you pass onSnapshot: with it, the list arrives as one InstrumentEventData[]; without it, as one listing event per market through the ordinary handler. Prefer onSnapshot if you keep a market map — it is the only form that tells you where the baseline ends, which is what lets you replace the map after a reconnect: a market delisted while the socket was down arrives flagged isActive: false, and a market no longer listed drops out. A subscriber joining a channel this client already holds is replayed the current baseline from a local cache instead, through whichever form it registered (the server snapshots only genuinely new wire subscriptions).
Omit tradingPairId to receive every market, or pass one to filter.
Subscribe to Instruments
Instrument Event Structure
riskTiers, ascending by tierLevel, in the same RiskTier shape and values as the REST perp config’s riskTiers; maxLeverage, initialMarginRate and maintenanceMarginRate are tier 1’s values. A margin-ladder edit that changes any tier, or the position cap that bounds the top tier, emits a config_change whose changedFields includes "risk_tiers" (plus the three tier-1 fields when tier 1 changed); saving an unchanged ladder emits nothing, and neither does a fee edit. riskTiers is optional on the type because a server that predates it omits it. baseIconUrl and quoteIconUrl apply to every market and are always present (unlike the perp-only fields, which are omitted for spot), each null when that asset has no icon — join a position’s tradingPairId to the instrument row to render both icons without a REST call. Every market’s frame also carries its asset identity (baseAssetId, quoteAssetId, baseToken, quoteToken, baseAssetName, quoteAssetName, baseDecimals, quoteDecimals), so you can label a market and scale its amounts without parsing symbol or calling REST; perp frames add minLeverage. These fields are optional on the type because a server that predates them omits them. halt / unhalt are reserved for a future market-regime producer and are not emitted by admin trading-pair mutations. For the raw wire protocol (snake_case keys, the Snapshot envelope), see the WebSocket reference.
Complete Example
React Integration
Best Practices
1. Use Trading Pair IDs
All WebSocket channels use trading pair IDs (UUIDs), not symbols:2. Always Clean Up
Store unsubscribe functions and call them when done:3. Authenticate for Orders
The orders channel requires authentication:4. Handle Reconnection
sdk.ws reconnects automatically (unlimited retries by default). Poll getStatus() if you need to surface it:
onResync hook and an onStatusChange callback, pass a ws config to the SDK (new MonacoSDK({ ..., ws })) or construct the client with createMonacoWebSocket — see Reconnection and resync.
