Skip to main content
Real-time data streaming via WebSocket. Get instant updates for orders, orderbook changes, candlestick data, trade executions, balance changes, and account movements.

Overview

The Monaco Protocol WebSocket API provides real-time streaming of multiple data types. Unlike REST API endpoints that require polling, WebSocket connections give you instant updates as events occur.

Available Channels

Balances vs. Movements
  • Use balances when you need your UI or system to reflect the current balance state for each asset, updated whenever a balance changes (e.g., lock/unlock, deposit, withdrawal, trade).
  • Use movements when you need a stream of individual balance-affecting events (ledger-style entries) for reconciliation or auditing — deposits, withdrawals, funding settlements, and the balance a cancelled or expired order unlocks. A fill’s ledger entries are not streamed here: read them from movement history (GET /api/v1/accounts/movements), and take the live signal for a fill off balances.

Key Features

  • Automatic reconnection - Full-jitter exponential backoff, unlimited retries by default, with an onResync hook after each reconnect
  • Heartbeat/keepalive - Runs at two layers, both handled automatically — see Heartbeat and keepalive
  • Session-key authentication - Authenticated channels are secured by ed25519 session-key signing, handled automatically by the SDK
  • Functional API - Clean subscription pattern with unsubscribe functions
  • TypeScript support - Fully typed event interfaces

Connection Setup

SDK Initialization

The WebSocket URL is automatically resolved from the network parameter — no manual configuration needed:

Connecting

The WebSocket auto-connects when the SDK is initialized. You can also manually connect:

Default Settings

  • Auto-reconnect: Enabled
  • Reconnect backoff: Full-jitter exponential backoff, base 1s, capped at 30s
  • Max reconnect attempts: Unlimited by default (set maxReconnectAttempts for a finite cap)
  • Connection timeout: 10 seconds (connectionTimeoutMs)
  • Heartbeat interval: 15 seconds (client-sent JSON Ping; the server currently sends a transport-level ping about every 25 seconds — see Heartbeat and keepalive)

Reconnection and resync

sdk.ws auto-reconnects with full-jitter exponential backoff (base 1s, capped at 30s) and, by default, retries indefinitely. onStatusChange reports "reconnecting" while backing off, and heartbeats are handled automatically (see Heartbeat and keepalive). After every automatic reconnect, onResync fires after the SDK sends authentication/subscription requests. It does not wait for their acknowledgements or subscribe snapshots, and performs no REST refetches automatically. The application owns reconciliation, including channels without subscribe snapshots. A receiver gap owned by any of the connection’s subscriptions closes the whole connection with 1013 (see Slow-client disconnect); slowClient is true for that close code. Merge orders by (id, version), not updatedAt. Persisted snapshot absence does not prove a locally resting order terminated: persistence can lag a newer event. Retain the row and resolve it with getOrder, then apply the version rule. An absent version is unknown, not zero; use buffer-and-replay recovery for those rows. See the SDK lifecycle guidance and market-maker recovery recipe for version ties, delayed events, and missed executions. Available history has pagination/retention limits; reconnect does not guarantee complete reconstruction of a gap. Error frames reach no subscription handler. The onError callback is how they reach your app. A per-channel failure (INVALID_SUBSCRIPTION, AUTH_REQUIRED, SUBSCRIPTION_LIMIT, SNAPSHOT_UNAVAILABLE) also carries channel, the channel string exactly as the client sent it (a string longer than 128 bytes, never a valid channel, is echoed as its first 128 UTF-8 bytes, cut at a character boundary): Subscribed / Unsubscribed acknowledgements list only the channels a request changed, so a rejected channel is left out of the ack and reported by its own Error frame, and channel pairs the two. Connection-wide failures carry no channel. Branch on code — refetch that channel’s REST snapshot on SNAPSHOT_UNAVAILABLE (the subscription is live and diffs flow, but the subscribe-time baseline is missing), re-authenticate on SESSION_INVALID (the SDK stops reconnecting after it, or after AUTH_FAILED, and calls onReauthenticationRequired — see session loss), and slow down on MESSAGE_RATE_LIMIT (a per-connection inbound cap was exceeded — the message rate, or the byte budget that refuses a burst of very large frames — and the connection closes with 1008; batch subscriptions, keep frames small, and back off before reconnecting). AUTH_REQUIRED needs one step more: the server rejected that subscribe without registering the channel, and the client still holds your handler, so it treats the channel as subscribed and sends no new Subscribe — re-subscribe after authenticating (release the last handler for the channel, then subscribe again). A code this SDK version does not recognize is delivered as-is rather than dropped, so log the default branch instead of ignoring it. Pass these options to the SDK as the ws config — the MonacoSDK constructor now forwards them to the WebSocket client it builds internally — or to the standalone createMonacoWebSocket(baseUrl, options) factory, which additionally accepts the session keypair:
The low-level createMonacoWebSocket(baseUrl, options) factory takes the same options (plus the session keypair) when you build a client directly:

Heartbeat and keepalive

Connection liveness runs at two independent layers. The SDK handles both for you — you never send or answer a ping yourself — but the distinction matters when building a client directly against the raw protocol:
  • Transport-level ping (server → client). The server sends a WebSocket ping control frame about every 25 seconds so proxies do not close an idle connection. Your WebSocket implementation (browser, ws, etc.) replies with a pong frame automatically; this never surfaces as an application message and there is nothing to handle in your code.
  • Application-level Ping/Pong (client → server). The SDK sends a JSON { "type": "Ping" } every 15 seconds and the server replies { "type": "Pong" }, which the SDK consumes silently. This is a client-initiated check.
  • Security note. Ping/Pong is a liveness signal only — its payloads are unsigned and non-transactional. It does not authenticate a user action, extend or refresh a session, or provide replay protection. User authentication is the signed Authenticate handshake, and it is not renewed by heartbeat traffic.
A JSON Ping only travels client → server, and Pong only travels server → client. The server does not send a JSON { "type": "Ping" } — server-initiated liveness is the transport-level ping frame above. If you build your own raw client, send { "type": "Ping" } on your own timer and treat { "type": "Pong" } as the reply; do not wait for a server-sent JSON ping.

WebSocket Types

Order Events

Order events require authentication. Call sdk.login(clientId) before subscribing. Once a session is active, the SDK authenticates the WebSocket for you — it sends a signed Authenticate handshake on connect and re-authenticates automatically after any reconnect. Public channels (orderbook, candlesticks, trades) need no authentication.

Subscribe to Orders

Subscribe to All User Orders

Use userOrders() to receive events for ALL your orders across all trading pairs:
Use sdk.ws.orders(pairId, mode, handler) for pair-specific subscriptions, or sdk.ws.userOrders(handler) for all orders.

Event Types

Taker vs Maker Events

Order events contain different fields depending on whether you’re the taker (initiating the trade) or maker (resting order on the book):

Illustrative Event Structure

This combined field overview uses the SDK’s camelCase names; raw wire payloads use snake_case. It is not an exact per-variant type: required fields and nullability differ by event and role. See the typed order-event variants, especially OrderMatched and OrderExpired, before treating a key as optional or non-null. data.version?: number is an opaque, compare-only revision shared with REST and subscription snapshots. Compare only the same order; gaps are normal, and absent means unknown rather than zero. A snapshot wins when snapshot.version >= local.version, including ties, because several live events can share one step. Known value aliases below allow unfamiliar strings and need a default branch; concrete event variants retain their closed discriminants.
A cancellation caused by self-trade prevention carries terminalReason SELF_TRADE_PREVENTION, reason self_trade_prevention, and stpCounterpartyOrderId. The three always travel together, so the presence of stpCounterpartyOrderId is the whole signal; absent means this was not an STP cancellation. It names the arriving order on a swept resting order, and the first own resting order on a cancelled taker remainder. Such a cancellation is a real terminal CANCELLED even with zero fills. An accepted IOC/FOK that filled nothing for want of liquidity is REJECTED instead, arriving as OrderRejected; a plain zero-fill MARKET submit is refused before acceptance and emits nothing at all, so on this channel the contrast that matters is OrderCancelled versus OrderRejected. An edited order keeps the fills it had before the edit: replacing a partly filled order places a new order id that carries them, and its frames report the order as REST does. Its OrderPlaced carries the order’s total quantity, the carried fill in filledQuantity (and filledQuantityRaw), that fill’s averageFillPrice, and status PARTIALLY_FILLED; the OrderPlaced of any other order omits those three fields and says SUBMITTED. 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 (filledQuantity / totalFilled; these two payloads carry no average), while lastFillQuantity (and executionPrice on a maker fill) describe only this event’s fills. The original order’s OrderCancelled, with terminalReason REPLACED, arrives first. This applies to single and batch edits alike. A child order a TWAP parent places is an ordinary order on this channel, and every one of its frames carries parentOrderId:
  • A taker slice (a TAKER slot, or a PASSIVE parent’s deadline sweep or crossing take) sends OrderPlaced, then its taker fill, then an OrderCancelled for the remainder when the price band stops it short. Each resting order it fills receives its maker fill exactly as it would from any other taker. Its fills reach the other channels as any taker’s do: one public trade per fill on trades, the orderbook update and the live candle on ohlcv, and balance and position updates for the owner and for every resting order it filled, including the closing position update when the slice closes a position.
  • A passive child sends OrderPlaced when it rests and a maker fill each time another order trades against it. It sends OrderCancelled when it leaves the book: with terminalReason REPLACED when a reprice or the slot’s deadline sweep replaces it, and USER_REQUESTED when the parent is cancelled or reaches its end time. On a reprice, the old child’s OrderCancelled arrives before the new child’s OrderPlaced. Each time a passive child rests or leaves the book, the orderbook channel gets an update, and when a spot child leaves the book its owner gets the same user_movement frame for the released lock that cancelling the order by hand sends.
Every frame one TWAP step produces carries that step’s version. A slice that places nothing, because it was refused or found no liquidity inside its band, sends no order frame for the child itself; the parent’s own progress arrives on the twap_orders channel (sdk.ws.twapOrders) either way. 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 use this channel too, and each frame carries 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 OrderCancelled with terminalReason LIQUIDATION.
  • 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 OrderCancelled with terminalReason AUTO_DELEVERAGING; the counterparty was deleveraged, not liquidated.
  • Liquidation orders. The engine’s liquidation order sends its owner OrderPlaced, then its taker fill, then an OrderCancelled for any remainder. A liquidation order the book could not fill at all sends OrderPlaced then OrderRejected instead, 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 OrderFilled per 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 being OrderFilled. A position closed partly at the bankruptcy price and partly at the mark price therefore receives an OrderPartiallyFilled and then an OrderFilled. These frames arrive only when the whole ADL episode ends, not as each step executes. The frame still carries the version of the step that filled it, so apply it with the usual >= rule. If the matching engine restarts during an episode, those frames are not sent; read the orders from REST.
A resting order that a liquidation order cancels, for self-trade prevention, a failed risk re-check, or because the fill closed the position it reduced, receives an OrderCancelled frame in the same step.

Example: Handle Order Events

Orderbook Updates

Subscribe to real-time orderbook depth updates. No authentication required.

Subscribe to Orderbook

Orderbook Event Structure

OHLCV Candlestick Data

Subscribe to real-time candlestick updates. No authentication required.

Subscribe to OHLCV

OHLCV Event Structure

Trade Events

Subscribe to real-time trade executions. No authentication required.

Subscribe to Trades

Trade Event Structure

Movement Events

New in v0.5.4 - Real-time balance movement tracking
Subscribe to real-time account ledger movements: deposits (spot and margin-routed), withdrawals, funding settlements, and the balance a cancelled or expired order unlocks. Fills and fees do not arrive on this channel — see the note below. Requires authentication.

Subscribe to Movements

Movement Event Structure

Fills do not arrive on this channel. Live movement frames cover deposits, withdrawals, funding settlements, and the unlocks a cancelled or expired order releases. A fill’s ledger entries are written durably and read back through movement history (GET /api/v1/accounts/movements); the live signal for a fill is a balance_update on the balances channel — a versioned spot row for a spot-owned leg, and for a margin fill an unversioned frame carrying the margin account’s post-fill collateral. Fee attribution on that path is conditional: the taker also gets a fee-reason frame only when a taker or application fee was actually charged, and an impacted margin maker gets a rebate-reason frame only when its maker fee is negative — a positive maker fee produces no separate frame, so do not wait for one.In that history, a populated balance snapshot describes a spot user_balances row, and only that. Two kinds of entry carry no spot row and report 0 in balanceBefore, balanceAfter, lockedBefore, and lockedAfter (and their raw forms). The first is any margin fill leg — a margin taker’s own debit and credit legs, and a margin maker’s — because that collateral settles through the risk engine rather than a spot row; a genuine spot maker matched by a margin taker is the exception and still reports its real snapshot. The second is a FUNDING row, which is derived at read time from the funding ledger and is a collateral delta rather than a token transfer. In both cases the zeros mean “no spot snapshot”, never “a spot balance of zero”. Read margin account state for collateral, and do not derive a position’s collateral change from these fields.
Important: Use decimals field to convert between human-readable and raw amounts:
  • amount: “1.5” (decimal, for display)
  • amountRaw: “1500000000” (raw, with decimals: 9)
  • Conversion: rawAmount = decimalAmount * 10**decimals

Movement Types

entryType and transactionType are free-form strings and are not normalized to one casing. The deposit, withdrawal and funding producers emit both fields lowercase (entryType credit / debit). The order-unlock producer emits entryType as uppercase CREDIT and transactionType in PascalCase (OrderCancelled, OrderPartiallyCancelled, OrderExpired). Normalize both fields before comparing. The values a live frame can carry: Entry Types:
  • credit / CREDIT - Funds added to available balance
  • debit - Funds removed from available balance
Transaction Types:
  • deposit - On-chain deposit confirmed (spot or margin-routed)
  • withdrawal - Withdrawal processed
  • funding - Funding payment settled against margin collateral (reconciles with the balance_update whose reason is funding)
  • OrderCancelled / OrderPartiallyCancelled / OrderExpired - Balance unlocked by a cancelled, partially cancelled, or expired order
Fill and fee entries are not among them: they exist in movement history (GET /api/v1/accounts/movements), never on this channel.

Example: Track Deposits

Example: Monitor Order Unlocks

For a fill, subscribe to balances instead — a settlement’s live signal is a balance_update with reason: "trade", and its ledger entry is read back from movement history.

React Hook

The SDK provides a dedicated React hook for movements with automatic deduplication:
Learn more about useUserMovements →

Balance Updates

Subscribe to real-time balance changes for your account. Requires authentication. Balance updates are triggered by:
  • Deposits to the vault
  • Withdrawals from the vault
  • Orders locking balance
  • Orders releasing locked balance
  • Trade executions affecting balance

Subscribe to Balance Updates

Event Structure

Update Reasons

Balance updates include a reason field indicating why the balance changed:
For a genuine spot user_balances row, version is the durable producer-owned revision scoped to the authenticated (user, application, token). REST, the subscribe-time snapshot, and a live event for identical state carry the same value. Only a POSITIVE version is rankable: apply a higher version, treat equal as an idempotent duplicate, and ignore a lower version for the whole row. 0 is the legacy sentinel and means exactly what an absent field means — unranked — so normalize the two together and fall back to a full field comparison. A snapshot can carry a persisted 0; ranking it as the oldest revision would suppress the unranked frames a rolling deploy produces. Never use updatedAt, arrival order, or receiver backlog to establish ordering.A collateral transfer that moves the spot wallet row emits a genuine versioned spot event for that leg. Margin/perpetual collateral frames (funding, liquidation, adl, margin fills, parent to risk-bucket allocations, and margin-routed deposit notifications) are not spot rows and intentionally omit version. Their wire identity has no margin-account ID, and some carry a notification amount rather than authoritative whole-row state. They therefore cannot be suppressed by a spot snapshot watermark. Handle an unversioned frame fail-open and refresh authoritative margin-account state; durable margin-collateral reconciliation remains separate work.

Example: Track Balance Changes by Reason

React Hook

The SDK provides a dedicated React hook for real-time balances:
Learn more about useUserBalances →

Conditional Order Updates (Perp TP/SL)

Stream lifecycle events for take-profit and stop-loss conditional orders. Requires authentication.

Subscribe

Event payload

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. @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, because throwing inside the message handler would drop the whole update. Only eventType stays strict. Match these fields with a default branch: reason is free-form on the wire. Every transition sends exactly one update. The canonical live reason values are created, activated (a PENDING_PARENT order went ACTIVE when 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 in failureReason), reparented (a PENDING_PARENT order moved onto its entry order’s replacement: parentOrderId changes, state does not), failed, expired, plus armed and ratcheted on a TRAILING_STOP row (tracking began; the trigger moved, each ratcheted frame carrying the new triggerPrice and watermarkPrice), plus snapshot on the subscribe-time baseline. timeInForce is the full time-in-force enum (GTC, IOC, FOK, GTD) rather than the GTC/IOC subset the creation paths validate. See Conditional Orders Channel. When a conditional triggers, the underlying order it submits flows through the regular orders channel (sdk.ws.orders per pair, or sdk.ws.userOrders for all pairs) — subscribe to it to track a TP/SL all the way through to fill. The reason: "triggered" update carries state: "TRIGGERED", triggeredOrderId and triggeredAt: the close the trigger placed, the same values REST returns. triggeredOrder, the summary of what that close has done, is never on this channel, and 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. The close’s own frames on orders / userOrders never carry conditionalOrderId. Read those from REST — GET /api/v1/orders/conditional/{conditionalOrderId} for the close’s outcome, GET /api/v1/orders or GET /api/v1/orders/{orderId} for the back-pointer. See Trigger Price Is Not Fill Price for why the trigger price and the fill price are independent numbers. For higher-level usage, see Order Management.

Connection Management

Check Connection Status

Disconnect

Complete Example

React Integration

Best Practices

1. Always Use Trading Pair IDs

2. Clean Up Subscriptions

3. Handle Reconnection

sdk.ws reconnects automatically (unlimited retries by default, full-jitter backoff). To observe status on the built-in client, read sdk.ws.getStatus() — it returns "reconnecting" while backing off:
For an onResync callback (refetch REST snapshots and reconcile after each reconnect) and an onStatusChange handler, pass them in the SDK’s ws config, or to createMonacoWebSocket(baseUrl, options) — see Reconnection and resync.

4. Authenticate for Orders Channel

WebSocket URLs

The SDK resolves the WebSocket URL from the network preset, so you normally don’t set it by hand. The staging and development presets now point at dedicated WebSocket ingresses (wss://ws-staging.apimonaco.xyz/ws, wss://ws-develop.apimonaco.xyz/ws); the previous wss://staging.apimonaco.xyz/ws and wss://develop.apimonaco.xyz/ws paths keep working during the migration window, so custom wsUrl overrides are unaffected.

Next Steps