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.
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 })— 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).autoReconnect,maxReconnectAttempts,reconnectBaseDelayMs, andconnectionTimeoutMstoggle reconnection and tune the retry cap, backoff base, and per-attempt timeout. The retry cap counts per outage: aconnect()after the retries run out starts a fresh budget.
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, filtered to the active markets 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.
orderbook, ohlcv, trades and movements take no onSnapshot: they stream events rather than state. For orderbook the first live frame is already the full book; for the others, use the REST history reads.
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 and version 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, copy, and single-market marketStats subscriptions own every gap, while other channels own only gaps reaching events published after their Subscribed acknowledgement (see the reference).
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 NATS events. Consume subscribe snapshots where supported (balances, orders, userOrders, conditionalOrders, positions, instruments, account, liquidations, twapOrders, copy, marketStats, marketStatsAll), reconciling complete sets and per-key revisions. Other channels 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 never emits for it. See the protocol recovery contract for snapshot limits, missing terminal updates, and additive-history gaps. Public trades and movements 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. Reconnecting with the same session would only be rejected again, so after either 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, the server rejects the user-specific ones with AUTH_REQUIRED, and authenticating on that open socket afterwards does not resubscribe them (see AUTH_REQUIRED below).
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.
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, when a risk bucket entersPENDING_LIQUIDATION, one terminalOrderCancelledper resting order in that risk bucket, carryingterminalReasonLIQUIDATION. 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 may also carry origin? (wire origin) — what placed the order: "USER", "LIQUIDATION", "SYSTEM", "ADL" or "COPY" — and, on a "COPY" order, copyRelationshipId?: string (wire copy_relationship_id), the follow the copy trading engine placed it for (the same id as Follow.followId and the copy channel’s relationshipId). An absent origin means not reported, never "USER": producers fill it as they adopt it, and send "USER" explicitly once they do.
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:OHLCV Channel
Subscribe to real-time candlestick updates. No authentication required.priceType argument ("trade" | "mark" | "*") 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), and "*" receives both. 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 argument appends a fifth token to the ohlcv channel string, so existing four-token subscriptions are unchanged and never receive the mark series. When you pass "*", read event.priceType (known series "trade" and "mark", with unfamiliar strings passed through) on each frame to tell the two streams apart.
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.Trade Event Structure
Movements Channel
New in v0.5.4 - Real-time balance movement tracking
Movement Event Structure
Example: Track Deposits
Example: Monitor Order Unlocks
sdk.ws.orders (the lifecycle event) and sdk.ws.balances (the resulting balance — a versioned spot row for a spot leg, an unversioned margin-collateral frame for a perp fill), 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, fundingPaid, feesPaid and cumFees are REST-only, so a consumer that needs any of those still reads REST.
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 — the snapshot is a database baseline 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. Two cases are served from the persistence clock instead and differ by the persistence lag: a terminal row — the live frame carries the engine’s close instant while a terminal REST read carries the stored one — and any position read after a matching-engine restart. 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:
isolatedMargin gets its own column because it is a versioned field that the subscribe-time snapshot does not read from the versioned row: the snapshot overlays it from an independent live matching-engine read, so it can reflect a later command than the row’s version names. Take it from the newest payload rather than skipping it on an equal version, or you can retain stale backing. Sourcing it atomically with the row is tracked separately.
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.
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, an isolated position’s direct margin add or reduction, and a copy-position margin add. Each is built from captured post-command state. A copy-position margin add refreshes both the isolated child and every open position in its copy cross risk bucket at the same version.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; copy follow creation or allocation growth and copy stop return refresh the copy cross 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 rolling out per environment; where they are not yet enabled the 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 copy-trading bucket, for example). 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.
Copy Channel
Stream your copy trading follows as a follower: acopy_relationship_update frame on every change to one of your follows, and a copy_skip frame for every leader fill a follow could not mirror. Requires authentication. There is no per-pair variant, and a leader receives nothing on this channel. In React, use useCopyEvents.
version is the durable-log sequence of the step that produced it: for one follow, higher is newer, so ignore a frame older than the one you hold. status is ACTIVE, CLOSE_ONLY or STOPPED (terminal); change names why the frame was sent (created, updated, close_only, stopped, skip_recorded, reactivated, or snapshot). A skip’s reason is INSUFFICIENT_MARGIN, BELOW_MIN, LEVERAGE_MISMATCH, MARKET_STATE, LEADER_EQUITY or NO_LIQUIDITY; only INSUFFICIENT_MARGIN advances consecutiveSkips. The label fields are typed KnownAlias | (string & {}), so keep a default branch.
cumNetRealized and hwm are the follow’s realized PnL net of fees since startedAt and its high-water mark — the basis for the leader’s profit share. They are not the copy account’s balance.
The snapshot carries live follows only and no skips, so a later snapshot (after a reconnect) is the complete live set: replace your map with it. Read sdk.copyTrading.getFollow(followId) for a follow’s persisted recent skips.
Copy Event Structure
Account Channel
Stream account-level margin health for the authenticated user — equity, collateral, free collateral, margin ratio, and maintenance requirement, 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.
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, which the live producer never emits for because it enumerates only accounts holding an open position or a resting order reserve. 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
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. - 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.
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.
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 current active-pair list arrives before the live diffs, so a subscriber always starts from a complete baseline. 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 and drop markets delisted while the socket was down. 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
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.
