Skip to main content
The Monaco SDK provides a unified WebSocket client for real-time data feeds including order events, OHLCV candlestick updates, orderbook changes, and trade executions.

WebSocket Client

The SDK exposes a single WebSocket client with functional subscription methods:
Each method returns an unsubscribe function for easy cleanup.

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:
The auto-connect process:
  • 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:
2. Connection Recovery Handle connection errors explicitly:
3. Resource Management Disconnect when not in use to save resources:

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"

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 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) — fires on every transition, including "reconnecting".
  • 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, the orders snapshot frame, and the REST endpoints all carry the same counter, so keep applying events as they arrive and take the snapshot for an order when snapshot.version >= local.version, else keep local. Use >=, not > — on the stream equal versions mean the same sequencer step, not identical state (one step emits an OrderPlaced and 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 from sdk.profile.getUserTrades (execution-time ordered, so a gap fill is at the top whatever the order’s age — the order list is createdAt-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 with sdk.trading.getOrder(orderId), then apply the same version rule. Treat an absent version as unknown, never zero, and fall back to buffer-and-replay for those rows. The market-maker runbook has the full recipe. slowClient is true when the server shed the connection with close code 1013 (the client fell behind — expect a gap).
  • onError({ code, message, channel }) — fires for every Error frame the server sends; channel is 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, and connectionTimeoutMs toggle reconnection and tune the retry cap, backoff base, and per-attempt timeout.

Subscribe-time snapshots

Most channels deliver their current state once, as a Snapshot 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:
Five rules govern it:
  • 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 getOrder rather 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.
This is the cheaper half of reconnection and resync: for a channel below marked Available, the snapshot replaces the broad REST refetch that onResync would otherwise prompt. Orders can still require targeted getOrder calls for locally resting rows the persisted snapshot omits. A callback on a Not yet sent channel is simply never invoked — it is inert, not an error, and the live stream is unaffected. Registering one now is safe, and it starts working when the server arm ships provided that arm follows the convention every current one does: a flat array of the channel’s own payload shape. The SDK’s channel-coverage lock fails until a newly-shipped arm is reclassified, so that shape is confirmed rather than assumed. 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, fee):
The | null fields are always present on the wire and carry null when unset; clientOrderId, selfTradePreventionMode, parentOrderId and version are the ones the server omits outright. OrderSnapshotItem carries no postOnly: track that as order-level state from the OrderPlaced acknowledgement, since a snapshot row cannot tell you the flag either way. 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 code 1013 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, 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). 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, conditionalOrders, positions, instruments, account, twapOrders, 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 an Error 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:
  1. forgets the rejected session keypair, so a later connect() does not replay it;
  2. keeps your subscription handlers;
  3. reports "disconnected" through onStatusChange;
  4. calls onReauthenticationRequired({ code, message, closeCode }) once.
Recover by obtaining a fresh session: 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:
Supply the fresh session instead of calling 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 as Error 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:
2. Manual Connect for Control Use manual connection when you need explicit control:
3. Cleanup on Unmount Always disconnect when component unmounts:
4. Monitor Connection Status Check status before subscribing:

WebSocket Types

Orders Channel

Subscribe to real-time order events for your account. Requires authentication.

Event Timeline

Events are emitted in this order:
  1. OrderPlaced → Order validated, persisted, funds locked (taker only)
  2. Match Events → Emitted after OrderPlaced
    • Taker: OrderMatched / OrderPartiallyFilled / OrderFilled
    • Each Maker: OrderPartiallyFilled / OrderFilled
  3. OrderCancelled → For an IOC order’s unfilled remainder after a partial fill (an FOK that can only partially fill for want of liquidity is rejected, not cancelled — its candidate fills are discarded). Also, after the taker’s own fan-out, one terminal OrderCancelled per resting order swept by self-trade prevention — sent to that order’s owner, carrying terminalReason SELF_TRADE_PREVENTION and stpCounterpartyOrderId. A maker subscriber therefore sees a terminal frame for an order it never cancelled
  4. OrderRejected → For orders rejected after acceptance (an IOC/FOK that cannot fill for want of liquidity; a MARKET order whose liquidity is invalidated by maker-risk revalidation). An IOC/FOK stopped by self-trade prevention instead is OrderCancelled, even at zero fills. A plain MARKET submit with no liquidity is rejected before acceptance and emits nothing.
  5. OrderExpired → For GTC orders reaching expiration

Taker vs Maker

Events contain different fields based on your role:

Order Event Types

Every order event also carries data.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. 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 a resting child’s 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. 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:
When: Order validated, persisted, funds locked - ready for matching. Recipient: Taker only.

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.
An optional fifth 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
Subscribe to real-time account balance movements. Requires authentication.

Movement Event Structure

Example: Track Deposits

Example: Monitor Order Unlocks

A fill and its fee never arrive on this channel. Track a fill live through 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:
Learn more about useUserMovements →

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 calling sdk.ws.conditionalOrders(...).

Subscribe to Conditional Orders

Conditional Order Event Structure

Neither the live frames nor the subscribe-time snapshot carries triggeredOrder, the summary of what the triggered order has done — it is a REST-only field on getConditionalOrder, listConditionalOrders and the conditional detail of a listing row. triggeredOrderId is the id alone, and no current frame on this channel carries it either: the live reason: "triggered" event is emitted from the conditional’s pre-trigger model, so it announces the transition without the id it produced, and the subscribe-time snapshot lists only the non-terminal states (PENDING_PARENT, ACTIVE, TRIGGERING), never a TRIGGERED row. Neither a live frame nor a reconnect will hand you the close’s identity — 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 present on REST reads and subscribe-time snapshots only; live conditional events never carry it. It is an opaque, compare-only revision for the same conditional order, with absent meaning unknown rather than zero. Seed from REST or the subscription snapshot and compare versioned snapshots with snapshot.version >= local.version; apply live events as state updates without version ranking. 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:
  • reason is free-form on the wire. Its canonical live values are created, activated, cancelled, parent_cancelled, triggered, failed, and oco_cancelled, plus armed and ratcheted on a TRAILING_STOP row (tracking began; the trigger moved — each ratcheted frame carries the new triggerPrice and watermarkPrice) — exported as ConditionalOrderEventReason for narrowing — and the subscribe-time snapshot reuses the same shape with snapshot. Any value the server adds arrives unchanged.
  • timeInForce is the full TimeInForce enum, not the GTC/IOC subset 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.
  • orderType is LIMIT or MARKET in 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. STOPLOSS rather than STOP_LOSS).

Lifecycle and recipients

The triggered order itself emits regular OrderEvent messages on the orders / userOrders channels. To follow a TP/SL all the way through to fill, subscribe to both channels. 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 trailing tradingPairId to filter to one pair; omit it to stream every pair. Detected lag closes the whole socket with 1013 under the slow-client rule. There is no wire sequence number — treat updatedAt as monotonic per positionId, ignore an event older than the state you already hold, and refetch a snapshot on reconnect (see Reconnect and resync).

Subscribe to Positions

Position Event Structure

A 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. 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 0 sentinel to an absent key before it reaches the wire, so a positions payload never carries a literal 0.
  • On REST/gRPC, where the field is a required int64 and cannot be omitted, it is the literal 0.
Normalize the two together and fall back to a full field comparison rather than ranking either as the oldest revision. An absent version never tells you whether anything was persisted. A live frame is unranked whenever the frame is not a faithful projection of the row persisted at that sequence — which covers three different situations, only one of which means no row was written:
  • The emit path wrote no position row at that sequence, so there is no versioned row to point at.
  • The path wrote the row but re-reads live risk state after its command was sequenced, so a later command could overtake the read. This is the funding and margin_added/margin_reduced case: the mutation did advance the persisted version.
  • The frame is a different projection of the row than persistence stores. Terminal frames are this case: they report size 0, and LIQUIDATED for a liquidation or ADL, while the stored row keeps its historical size and records CLOSED.
In the last two cases a REST read or a fresh snapshot ranks the mutation normally — only the live frame omits the version.
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. 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. In practice that is the fills that leave a position open, built from the matching engine’s own captured post-command state.Two kinds of frame are deliberately unranked:
  • Frames that re-read live risk state after their command was already sequenced, because a later command can overtake that read and the frame would then carry one command’s state under another’s version. That is the funding frames and the margin_added/margin_reduced collateral-transfer frames. Ranking them needs an atomic post-settlement capture, tracked separately.
  • Terminal frames — a full close, liquidation or ADL — because they project the row differently from persistence: the frame reports size 0, and LIQUIDATED for a liquidation or ADL, while the stored row keeps its historical size and records CLOSED. Ranking two different contents under one version would make your final state depend on which arrived first.
In both cases the mutation still advances the persisted version, so a REST read or a fresh snapshot ranks it — only the live frame is unranked.
A position closed or liquidated by an order fill (a user closing their own position, or a fill that flattens a counterparty) emits a terminal event carrying the final state (status CLOSED or LIQUIDATED, size 0) before the position is dropped, so a subscriber sees the close rather than the position going silent. Positions closed by the liquidation worker or auto-deleveraging now also emit a terminal LIQUIDATED event — reason "liquidation" for a maintenance-margin liquidation and "adl" for an auto-deleverage close — and a counterparty whose position survives or is only partially reduced by such a close receives a live OPEN refresh. A funding settlement emits a position_update with reason "funding" carrying the post-settlement state (status OPEN, unchanged size), so a live position change can be attributed to funding rather than a trade.

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 trailing tradingPairId 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

An episode ends with a terminal alert whichever way it resolves. When a breached risk bucket heals back over its maintenance requirement — on a liquidation step, or on an oracle mark, a funding settlement, or an auto-deleveraging close that lifts it — its record closes and you receive one alert with status: "COMPLETED", accountState: "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, 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.
Detected lag closes the whole socket with 1013 under the slow-client rule. Each frame carries the account’s full current health, so consume it as a terminal-state snapshot per 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 both equity 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 optional tradingPairId. 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

Reasons:

Subscribe-time snapshot

Pass an onSnapshot 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:
Each row is the same shape as a live frame with 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. 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 — its fills flow through the standard Orders Channel above. The persisted child order records the parent’s ID in its parentOrderId field, so a parent’s children can be tied together in order history (the field is on the order read model, not on the live fill event).

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. 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 (a market that never publishes stays unseeded). 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 — with changedFields 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

The perp-only leverage and margin-rate bounds are carried in every frame’s full config but are payload only — an edit to those margin parameters alone does not itself emit a frame. 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. 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:
For an 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.