> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xmonaco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSockets

> Real-time order updates and market data streams

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:

```typescript theme={null}
sdk.ws.orders(pairId, tradingMode, handler)                         // REQUIRES AUTH
sdk.ws.userOrders(handler)                                          // REQUIRES AUTH
sdk.ws.orderbook(pairId, tradingMode, magnitude, qMode, handler)    // PUBLIC
sdk.ws.ohlcv(pairId, tradingMode, interval, handler, priceType?, onSnapshot?) // PUBLIC
sdk.ws.trades(pairId, handler, onSnapshot?)                         // PUBLIC
sdk.ws.movements(handler)                                           // REQUIRES AUTH
sdk.ws.balances(handler)                                            // REQUIRES AUTH
sdk.ws.conditionalOrders(handler, pairId?)                          // REQUIRES AUTH
sdk.ws.positions(handler, pairId?, onSnapshot?, onValuation?)      // REQUIRES AUTH
sdk.ws.liquidations(handler, pairId?)                               // REQUIRES AUTH
sdk.ws.account(handler)                                             // REQUIRES AUTH
sdk.ws.marketStats(pairId, handler)                                 // PUBLIC
sdk.ws.marketStatsAll(handler)                                      // PUBLIC
sdk.ws.instruments(handler, pairId?)                                // PUBLIC
sdk.ws.twapOrders(handler, pairId?)                                 // REQUIRES AUTH
sdk.ws.copy(handler, onSnapshot?)                                   // REQUIRES AUTH
```

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:

```typescript theme={null}
// Check connection status
const status = sdk.ws.getStatus(); // "connecting", "connected", "reconnecting" or "disconnected"

// Manual connect/disconnect
await sdk.ws.connect();
sdk.ws.disconnect();
```

### Auto-Connect Behavior

By default, the WebSocket connection is established automatically when you initialize the SDK:

```typescript theme={null}
const sdk = new MonacoSDK({
  walletClient,
  network: "staging",
  seiRpcUrl: "https://evm-rpc-testnet.sei-apis.com", // public testnet RPC — use a private provider in production
});

// WebSocket is already connecting in the background
// Connection errors are logged but don't block SDK initialization
```

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:

```typescript theme={null}
const sdk = new MonacoSDK({
  walletClient,
  network: "staging",
  seiRpcUrl: "https://evm-rpc-testnet.sei-apis.com",
});

// Disconnect auto-connection
sdk.ws.disconnect();

// Connect later when needed
await sdk.ws.connect();
```

**2. Connection Recovery**

Handle connection errors explicitly:

```typescript theme={null}
try {
  await sdk.ws.connect();
  console.log("WebSocket connected successfully");
} catch (error) {
  console.error("WebSocket connection failed:", error);
  // Implement retry logic or notify user
}
```

**3. Resource Management**

Disconnect when not in use to save resources:

```typescript theme={null}
// When user navigates away or logs out
sdk.ws.disconnect();

// Reconnect when needed
await sdk.ws.connect();
```

### Connection Methods

<ResponseField name="connect" type="() => 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.
</ResponseField>

<ResponseField name="disconnect" type="() => void">
  Disconnect from the WebSocket server.

  **Returns:** void (synchronous)

  **Note:** Does nothing if already disconnected. Clears all subscriptions.
</ResponseField>

<ResponseField name="getStatus" type="() => 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).
</ResponseField>

### Connection status

Pass `onStatusChange` in the SDK's `ws` config (or to `createMonacoWebSocket`) to hear every status change. The connection reports four statuses:

| Status | When it fires |
| - | - |
| `"connecting"` | A connection starts opening outside a retry cycle: the SDK's first connect at construction, a `connect()` after a terminal `"disconnected"`, or a fresh session's reconnect after [session loss](#session-loss-close-code-1008). It comes before any other status for that connection. |
| `"connected"` | The connection opened. |
| `"reconnecting"` | A retry is scheduled, once per retry. It covers both a first connect that failed and a working connection that was lost. Retries do not report `"connecting"`: the status stays `"reconnecting"` until a retry connects, the retries run out, or you call `disconnect()`. |
| `"disconnected"` | Terminal: a clean close, an abnormal close with `autoReconnect: false`, retries exhausted (only with a finite `maxReconnectAttempts`), session loss, or `disconnect()`. |

Each call also receives an `info` object:

* `attempt`: the retry the status refers to. It is `0` for `"connecting"` and `"connected"` and `1`, `2`, … for `"reconnecting"`. For `"disconnected"` it is the number of retries scheduled in the outage before it, counting one still in backoff when you call `disconnect()`, and `0` after 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 last `disconnect()`.
* `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:

```typescript theme={null}
const sdk = new MonacoSDK({
  walletClient,
  network: "staging",
  seiRpcUrl: "https://evm-rpc-testnet.sei-apis.com",
  ws: {
    onStatusChange: (status, { attempt, hasConnected, nextRetryDelayMs }) => {
      if (status === "reconnecting" && !hasConnected) {
        showBanner(`Can't reach Monaco (attempt ${attempt}), retrying in ${Math.round((nextRetryDelayMs ?? 0) / 1000)}s`);
      } else if (status === "reconnecting") {
        showBanner(`Connection lost, reconnecting (attempt ${attempt})`);
      } else if (status === "connected") {
        hideBanner();
      }
    },
  },
});
```

A handler that takes only `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](#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](#subscribe-time-snapshots) 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](/developers/market-maker-runbook#4-reconnect-and-resync) 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](#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)](#session-loss-close-code-1008).
* `autoReconnect`, `maxReconnectAttempts`, `reconnectBaseDelayMs`, and `connectionTimeoutMs` toggle reconnection and tune the retry cap, backoff base, and per-attempt timeout. The retry cap counts per outage: a `connect()` after the retries run out starts a fresh budget.

### 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.

```typescript theme={null}
const unsubscribe = sdk.ws.balances(
  (event) => applyBalanceUpdate(event.data),   // live diffs
  (balances) => setBalances(balances),         // complete current state, once
);
```

`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:

```typescript theme={null}
// All pairs, with a baseline
sdk.ws.positions(handler, undefined, (positions) => setPositions(positions));

// One pair, with a baseline
sdk.ws.positions(handler, pair.id, (positions) => setPositions(positions));
```

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.

<Note>
  **One baseline per subscription, not per call.** The client opens one wire subscription per channel however many handlers you register on it, and the server snapshots only genuinely new subscriptions. A second call made while that subscription's first snapshot is still pending joins the handler set and receives the upcoming frame. A call added after the frame arrived has no cached baseline and waits until the next reconnect. `instruments` is the exception — it caches its baseline and replays it locally, inactive markets included, so the replay matches what a real snapshot would carry. Subscribe once per channel and fan out in your own code, or read the baseline over REST for an extra consumer added later.
</Note>

This is the cheaper half of [reconnection and resync](#reconnection-and-resync): for every channel below, 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.

| Channel | `onSnapshot` receives | Status |
| - | - | - |
| `orders` / `userOrders` | `OrderSnapshotItem[]` — every resting order | Available |
| `balances` | `UserBalanceEventData[]` — every asset held | Available |
| `conditionalOrders` | `ConditionalOrderEventData[]` — every live conditional order | Available |
| `positions` | `PositionEventData[]` — every open position | Available |
| `instruments` | `InstrumentEventData[]` — the full config of every listed market, inactive ones flagged `isActive: false` | Available |
| `account` | `AccountEventData[]` — the margin health of every margin account you own **in the authenticated application**, including flat ones; rows omit `sequence`. NOT a user-wide replacement set: live delivery is per user and wider, so merge per `marginAccountId` | Available |
| `liquidations` | `LiquidationEventData[]` — every in-flight (`UNRESOLVED`) liquidation record you own; account-scoped, so each row's `positionId`/`tradingPairId`/`liquidationPrice`/`markPrice` is `undefined` (the SDK parses the wire `null` to `undefined`) and `riskBucketState` is `Liquidating`. A pair-scoped `liquidations:{pair}` subscription baselines empty — use the bare `liquidations` channel | Available |
| `twapOrders` | `TwapOrderEventData[]` — every live (`PENDING`/`ACTIVE`) TWAP parent | Available |
| `copy` | `CopyRelationshipEventData[]` — every live (`ACTIVE`/`CLOSE_ONLY`) follow, each with `change: "snapshot"` | Available |
| `marketStats` | `MarketStatsData[]` — one element, that market's latest complete stats frame; the same shape as the live per-market event | Available |
| `marketStatsAll` | `MarketStatsSummary[]` — one summary per active market with retained data, deterministic by trading-pair id; the same shape as an element of the live all-markets frame's `markets` list | Available |
| `trades` | `TradeEventData[]` — the market's newest 50 trades, newest first; the same shape as the live event's `data`. A recent-history baseline, not complete state: it can overlap the first live trades or miss one executed just before you subscribed, so dedupe on `tradeId` | Available |
| `ohlcv` | `OHLCVSnapshotCandle[]` — the subscribed series' newest 350 candles, oldest first; the live `Candlestick` without `s`. A recent-history baseline, not complete state: its newest candle can be partial or missing, so upsert live candles by `T`. A `"*"` price type gets none | Available |

`orderbook` and `movements` take no `onSnapshot`: they stream events rather than state. For `orderbook` the first live frame is already the full book; for `movements`, use the REST history read.

Every row is the channel's own terminal-state payload — the same shape as its live event's `data` — with one exception. `orders` snapshot rows describe a **resting order**, not a change, so `OrderSnapshotItem` carries `status` and the cumulative fill totals and carries none of the per-fill metadata (`tradeId`, `executionPrice`, `lastFillQuantity`, `role`, `tradesCount`, and the `fee`, `monacoFee` and `builderFee` fields with their `Raw` twins):

```typescript theme={null}
interface OrderSnapshotItem {
  orderId: string;
  userId: string;
  applicationId: string;
  tradingPairId: string;
  tradingPair: string;              // symbol, e.g. "BTC/USDC"
  tradingMode: string;              // "SPOT" | "MARGIN"
  side: string;                     // "BUY" | "SELL"
  orderType: string;                // "LIMIT" | "MARKET"
  price: string | null;             // null for a market order
  quantity: string;
  quantityRaw: string;
  filledQuantity: string;
  filledQuantityRaw: string;
  remainingQuantity: string | null;
  remainingQuantityRaw: string | null;
  status: string;                   // a resting status
  timeInForce: string | null;       // null on a MARKET order, which has none
  averageFillPrice: string | null;  // null before the first fill
  createdAt: string;                // ISO 8601 timestamp (UTC)
  updatedAt: string | null;
  marginAccountId: string | null;
  positionId: string | null;
  leverage: string | null;
  reduceOnly: boolean;
  postOnly?: boolean;               // `true` when resting post-only; omitted, never `false`, otherwise
  expirationDate?: string;          // custom GTC expiry (RFC 3339), as `getOrder` returns it; omitted when none
  triggerPrice?: string;            // trigger price of an order a conditional placed; omitted otherwise
  positionSide?: string;            // deprecated derived label, as `getOrder` returns it; `side` is authoritative
  clientOrderId?: string;           // omitted, not null, when the order carried none
  selfTradePreventionMode?: SelfTradePreventionMode | (string & {});  // the mode REQUESTED on the order; omitted when it carried none
  parentOrderId?: string;           // the TWAP parent of a child order, as `getOrder` returns it; omitted for any other order
  version?: number;                 // sequencer step that last wrote this order; omitted when unknown
  origin?: OrderOrigin | (string & {});  // what placed the order: "USER", "LIQUIDATION", "ADL", "COPY" or "SYSTEM"
}
```

The `| null` fields are always present on the wire and carry `null` when unset; `postOnly`, `expirationDate`, `triggerPrice`, `positionSide`, `clientOrderId`, `selfTradePreventionMode`, `parentOrderId`, `version` and `origin` 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](#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`, `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](/reference/websockets#slow-client-disconnect-close-code-1013)).

The server sends the same `1013` close when events may have been lost before they reached it (an engine drop under load, a failed publish, or its NATS connection dropping or falling behind). Every socket on that server holding a subscription is closed, whatever its channels, and recovers through the same reconnect and `onResync({ slowClient: true })` path.

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. The `trades` snapshot is the market's newest 50 trades, merged with live trades by `tradeId`, and the `ohlcv` snapshot is the series' newest 350 candles, upserted with live candles by `T`; neither is a complete set. Other channels have no subscribe snapshot: explicitly refresh the relevant REST state/history and reconcile events arriving during that read. Quiet keys may never produce a fresh live baseline; a flat margin account is covered by the `account` snapshot, which includes it even though the live producer emits for it only when its collateral moves. See [the protocol recovery contract](/reference/websockets#slow-client-disconnect-close-code-1013) 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. When the account is deactivated it sends `ACCOUNT_DEACTIVATED` instead — on the handshake (the connection then closes with no status code, seen as `1005`) or on revalidation (it closes with `1008`). Branch on the error `code`, not the close code, which is transport detail. Reconnecting with the same session would only be rejected again, so after any of these the SDK does **not** reconnect automatically. It:

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`:

```typescript theme={null}
const sdk = new MonacoSDK({
  ...,
  ws: {
    onReauthenticationRequired: ({ code }) => {
      // SESSION_INVALID: revoked/expired session. AUTH_FAILED: handshake rejected.
      // ACCOUNT_DEACTIVATED: the account is deactivated; a new login is refused too.
      console.warn("WebSocket session lost:", code);
      if (code === "ACCOUNT_DEACTIVATED") return; // terminal: surface it, do not sign in again
      void sdk.login(clientId);
    },
    onResync: ({ code }) => {
      void reconcileFromRest(code);
    },
  },
});
```

Supply the fresh session instead of calling `connect()` yourself first: a socket opened without a session re-sends every held channel unauthenticated, and the server rejects the user-specific ones with `AUTH_REQUIRED`. The SDK re-sends those once the socket authenticates (see `AUTH_REQUIRED` below), but until then they deliver nothing.

`ACCOUNT_DEACTIVATED` is not fixed by signing in again: `sdk.login` from the same wallet is refused with `403` and the same `serverCode`. Surface it to the user rather than retrying.

The 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](/reference/websockets#inbound-message-rate-close-code-1008), which carries `MESSAGE_RATE_LIMIT` rather than `SESSION_INVALID`, and a `1013` [slow-client disconnect](#slow-client-disconnect-close-code-1013).

### Subscription acknowledgements

Calling a channel helper only **requests** the channel. The server answers each `Subscribe` with a `Subscribed` frame naming the channels that request added, and each `Unsubscribe` with an `Unsubscribed` frame naming the ones it removed. A channel the server rejected is left out of the ack and reported by its own `Error` frame instead. `sdk.ws` tracks both sides per channel string (the exact string a helper subscribes to, such as `trades:<tradingPairId>`):

```typescript theme={null}
sdk.ws.getSubscriptionState(`trades:${pairId}`);
// "pending" until the Subscribed ack names it, then "subscribed"

const stop = sdk.ws.onSubscriptionChange(({ channel, state }) => {
  // "subscribed": the server confirmed a channel you still want.
  // "unsubscribed": the server confirmed an unsubscribe.
  console.log(channel, state);
});
```

| State | Meaning |
| - | - |
| `"idle"` | Not wanted, and not held by the server |
| `"pending"` | Wanted; its `Subscribe` has not been acknowledged yet |
| `"subscribed"` | Wanted, and the server confirmed it |
| `"unconfirmed"` | Wanted but not held: the server rejected it (its `Error` reached `onError`), or the connection that held it closed |
| `"unsubscribing"` | Released; the server holds it until its `Unsubscribe` is acknowledged |

A channel rejected with `AUTH_REQUIRED` stays wanted and is re-sent once when the connection next authenticates. After a reconnect, every channel starts unconfirmed and the client re-sends everything you still want, so a confirmation never outlives the connection that made it. Releasing a channel before its `Subscribed` ack arrives never reports it as subscribed. The React hooks' `subscribed` flag still means the subscription was requested, not confirmed.

### Server error frames

The server reports failures 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`.

```typescript theme={null}
const sdk = new MonacoSDK({
  ...,
  ws: {
    onError: ({ code, message, channel }) => {
      switch (code) {
        case "SNAPSHOT_UNAVAILABLE":
          // The subscription is live and diffs flow, but this channel's
          // subscribe-time baseline is missing — refetch just that one from
          // REST.
          if (channel) void refetchSnapshot(channel);
          break;
        case "SESSION_INVALID":
          // The connection is closing and will not reconnect on its own;
          // onReauthenticationRequired follows. Recover there.
          break;
        case "AUTH_REQUIRED":
          // The channel was subscribed before authenticating, so the server
          // rejected it without registering it. The SDK keeps your handler
          // and re-sends the Subscribe once the connection authenticates, so
          // authenticate and leave the subscription alone.
          void reauthenticate();
          break;
        default:
          console.error("ws error", code, message, channel);
      }
    },
  },
});

const unsubscribeOrders = sdk.ws.orders(pairId, "SPOT", onOrder);
```

| Code | `channel` | Meaning |
| - | - | - |
| `AUTH_REQUIRED` | Yes | A user-specific channel was subscribed before authenticating. The subscription is **not** registered; the SDK re-sends it once the connection authenticates |
| `AUTH_FAILED` | — | The `Authenticate` handshake was rejected; the connection closes after this frame and the SDK does not reconnect — see [Session loss](#session-loss-close-code-1008) |
| `ACCOUNT_DEACTIVATED` | — | The account is deactivated. Sent on the `Authenticate` handshake (the connection then closes with no status, seen as `1005`) or on session revalidation (closes with `1008`); the SDK does not reconnect and calls `onReauthenticationRequired` — see [Session loss](#session-loss-close-code-1008) |
| `CONNECTION_LIMIT` | — | The per-user concurrent-connection cap was reached; this connection closes after the frame |
| `SUBSCRIPTION_LIMIT` | Yes | The per-connection subscription-cardinality cap was reached adding `channel` |
| `INVALID_SUBSCRIPTION` | Yes | A subscribe or unsubscribe request named a channel that was malformed or failed validation |
| `INVALID_MESSAGE` | — | An inbound frame could not be parsed, or an unsupported frame type was sent |
| `SESSION_INVALID` | — | A previously-authenticated session became invalid (revoked, expired, or the account was gated); the connection closes with `1008` and the SDK does not reconnect — see [Session loss](#session-loss-close-code-1008) |
| `SNAPSHOT_UNAVAILABLE` | Yes | The subscribe-time snapshot for `channel` could not be read; the subscription is still active |
| `MESSAGE_RATE_LIMIT` | — | A per-connection inbound cap was exceeded: the message rate (default 20 messages/s sustained, burst 1,200; operator-configurable) or the byte budget, which refuses a burst of very large frames; the connection closes with `1008` after the frame. Batch subscriptions and back off before reconnecting |

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:

```typescript theme={null}
// Auto-connect errors are logged but don't throw
const sdk = new MonacoSDK({ ... });
// Even if WebSocket fails, SDK methods work normally

// Manual connect errors can be caught
try {
  await sdk.ws.connect();
} catch (error) {
  if (error.message.includes("connection refused")) {
    console.error("WebSocket server unavailable");
  } else if (error.message.includes("timeout")) {
    console.error("Connection timeout - check network");
  } else {
    console.error("WebSocket error:", error);
  }
}
```

### Best Practices

**1. Let Auto-Connect Work**

In most cases, let the SDK handle connection automatically:

```typescript theme={null}
// ✅ Simple and works in most cases
const sdk = new MonacoSDK({ ... });
sdk.ws.orders(pairId, "SPOT", handler);
```

**2. Manual Connect for Control**

Use manual connection when you need explicit control:

```typescript theme={null}
// ✅ Useful for apps with conditional real-time features
const sdk = new MonacoSDK({ ... });
sdk.ws.disconnect();  // Disable auto-connect

if (userWantsRealtime) {
  await sdk.ws.connect();
  sdk.ws.orders(pairId, "SPOT", handler);
}
```

**3. Cleanup on Unmount**

Always disconnect when component unmounts:

```typescript theme={null}
// ✅ React example
useEffect(() => {
  sdk.ws.connect();
  return () => sdk.ws.disconnect();
}, []);
```

**4. Monitor Connection Status**

Check status before subscribing:

```typescript theme={null}
// ✅ Wait for connection before subscribing
if (sdk.ws.getStatus() !== "connected") {
  await sdk.ws.connect();
}
sdk.ws.orders(pairId, "SPOT", handler);
```

### WebSocket Types

```typescript theme={null}
/** Connection status */
type WebSocketStatus = "connected" | "connecting" | "disconnected" | "reconnecting";

/** Orderbook price quotation mode */
type OrderbookQuotationMode = "BASE" | "QUOTE";
// BASE:  Prices in quote currency per base (e.g., USD per BTC for BTC/USD)
// QUOTE: Prices in base currency per quote (e.g., BTC per USD for BTC/USD)

/** Valid magnitude values for orderbook grouping */
const ALL_MAGNITUDES = [0.0001, 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000];
```

## Orders Channel

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

```typescript theme={null}
// Authenticate first
await sdk.login(clientId);

// Get trading pair
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

// Subscribe to order events
const unsubscribe = sdk.ws.orders(
  pair.id,       // Trading pair ID (UUID)
  "SPOT",        // Trading mode
  (event) => {
    console.log("Event type:", event.eventType);
    console.log("Order ID:", event.orderId);
  }
);

// Later, to unsubscribe:
unsubscribe();
```

### 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 for a market, `IOC` or `FOK` remainder (or a margin limit remainder) the price band cut short, carrying `terminalReason` `PRICE_BAND`, or `SLIPPAGE_TOLERANCE` when the order's own `slippageToleranceBps` was tighter. Also for a resting perp order a mark move left past the [perp price band](/trading-mechanics/perps#perp-price-band), carrying `terminalReason` `PRICE_BAND`. Also, when a risk bucket enters `PENDING_LIQUIDATION`, one terminal `OrderCancelled` per resting order in that risk bucket, carrying `terminalReason` `LIQUIDATION`. And after the taker's own fan-out, **one terminal `OrderCancelled` per resting order swept by [self-trade prevention](/developers/trade#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](/developers/trade#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:

| Field | Taker | Maker |
| - | - | - |
| `role` | `"taker"` | `"maker"` |
| `tradesCount` | Number of fills | Always `1` |
| `referencePrice` | Yes | `null` |
| `bestPrice` / `worstPrice` | Yes | `null` |
| `actualSlippageBps` | Yes | `null` |

### 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](/developers/market-maker-runbook#4-reconnect-and-resync).

Event value fields typed as `KnownAlias | (string & {})` preserve unfamiliar server strings; keep a `default` branch when narrowing them. Each named event variant keeps its closed `eventType` discriminant.

Every order event's `data` also carries an optional `clientOrderId?: string` (wire `client_order_id`) when the order was placed with one — echoed on placement, taker and maker fills, cancellation, rejection and expiry, so an event can be matched to the request that placed the order. A maker fill carries the resting order's own handle. It is omitted when the order carried none.

Every order event's `data` also carries `origin?` (wire `origin`) — what placed the order: `"USER"`, `"LIQUIDATION"`, `"SYSTEM"`, `"ADL"` or `"COPY"` — and, on a `"COPY"` order, `copyRelationshipId?: string` (wire `copy_relationship_id`), the follow the [copy trading](/developers/copy-trading) engine placed it for (the same id as `Follow.followId` and the `copy` channel's `relationshipId`). It always agrees with `getOrder`: an order the account placed says `"USER"` on every frame (its `OrderPlaced`, its fills as taker and as maker, its cancels, partial cancels, rejection and expiry), a copy order's events carry `"COPY"`, a liquidation slice's events carry `"LIQUIDATION"`, and an ADL close's fill event carries `"ADL"`. No order is placed as `"SYSTEM"` today. An absent `origin` means **not reported** (a server predating the field), never `"USER"`.

`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](#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:

```typescript theme={null}
import type {
  OrderRole,
  OrderSide,
  OrderStatus,
  OrderTerminalReason,
  OrderType,
  SelfTradePreventionMode,
  TimeInForce,
  TradingMode,
} from "@0xmonaco/types";
```

<Tabs>
  <Tab title="OrderPlaced">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderPlaced";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        applicationId?: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        orderType: OrderType | (string & {});
        side: OrderSide | (string & {});
        price?: string;
        quantity: string;
        timeInForce?: TimeInForce | (string & {}) | null; // null on a MARKET order's events and on a maker fill
        status: OrderStatus | (string & {});
        clientOrderId?: string;      // correlation handle echoed on every order event (wire `client_order_id`)
        postOnly?: boolean;          // `true` when admitted post-only (wire `post_only`); omitted, never `false`, otherwise
        marginAccountId?: string;    // margin orders only (wire `margin_account_id`)
        leverage?: string;           // margin orders placed with a leverage (wire `leverage`)
        reduceOnly?: boolean;        // every margin order, `false` included (wire `reduce_only`); omitted for spot
        positionId?: string;         // the position the order is bound to at admission (wire `position_id`); omitted while unbound
        // Only on the replacement of a partly filled order (an edit): the fill
        // it carried over, so `quantity` is the order's total and `status` is
        // PARTIALLY_FILLED, as REST reports it. Omitted on any other OrderPlaced.
        filledQuantity?: string;     // wire `filled_quantity`
        filledQuantityRaw?: string;  // RAW precision (wire `filled_quantity_raw`)
        averageFillPrice?: string;   // the carried fill's average (wire `average_fill_price`)
      }
    }
    ```

    **When:** Order validated, persisted, funds locked - ready for matching.
    **Recipient:** Taker only.
  </Tab>

  <Tab title="OrderMatched">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderMatched";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        // Same taker payload as OrderFilled: the engine builds one struct and
        // picks the event type from the resulting status. Because this event
        // carries only that one shape, the keys below are exact — a key is
        // either always sent, or always sent carrying `null`. Of these execution
        // fields, only the two last-fill keys are ever absent.
        role: OrderRole | (string & {}); // emitted as "taker" on OrderMatched
        tradesCount: number;
        filledQuantity: string;       // cumulative order fill (wire `filled_quantity`)
        filledQuantityRaw: string;    // RAW (integer, base-unit) precision
        remainingQuantity: string;
        remainingQuantityRaw: string; // RAW precision
        fee: string;              // total fee, normalized decimal QUOTE units: monacoFee + builderFee
        feeRaw: string;           // `fee` in RAW quote-unit precision (wire `fee_raw`)
        monacoFee?: string;       // Monaco's component of `fee` (wire `monaco_fee`)
        monacoFeeRaw?: string;    // `monacoFee` in RAW quote-unit precision (wire `monaco_fee_raw`)
        builderFee?: string;      // the builder fee component of `fee`; "0" for a maker (wire `builder_fee`)
        builderFeeRaw?: string;   // `builderFee` in RAW quote-unit precision (wire `builder_fee_raw`)
        // Widened, like every value field the parser hands through: an
        // unrecognized status is delivered rather than dropping the frame, so
        // match it with a `default` branch.
        status: OrderStatus | (string & {});
        // Present but `null` when the event covers no trade — branch on null,
        // do not test for the key being absent.
        tradeId: string | null;
        executionPrice: string | null;
        executedAt: string | null;    // RFC3339 with `Z`
        averageFillPrice: string | null; // the order's average across every fill, an edit's carried fills included
        // Slippage info. Present, `null` when unset.
        referencePrice: string | null;
        bestPrice: string | null;
        worstPrice: string | null;
        actualSlippageBps: number | null;
        maxSlippageBps: number | null;
        // Optional incremental-fill keys: omitted when this event filled
        // nothing incrementally.
        lastFillQuantity?: string;    // this event's increment (wire `last_fill_quantity`)
        lastFillQuantityRaw?: string; // RAW (integer, base-unit) precision
      }
    }
    ```

    **When:** Order matched with counterparty but not yet filled.
    **Recipient:** Taker only.
  </Tab>

  <Tab title="OrderFilled">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderFilled";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        role: OrderRole | (string & {});
        status: OrderStatus | (string & {});
        // Execution details
        tradeId: string;
        executionPrice: string;
        filledQuantity: string;        // cumulative order fill (wire `filled_quantity`) — same on every `role`
        filledQuantityRaw?: string;    // RAW (integer, base-unit) precision
        lastFillQuantity?: string;     // incremental fill from THIS event (wire `last_fill_quantity`); omitted when no fill
        lastFillQuantityRaw?: string;  // RAW precision
        remainingQuantity: string;  // Should be "0"
        remainingQuantityRaw?: string; // RAW precision
        postOnly?: boolean;         // both roles, and only when admitted post-only (wire `post_only`); omitted, never `false`
        fee: string;                   // Total fee for the fills THIS event covers, in decimal quote units; negative = maker rebate. monacoFee + builderFee
        feeRaw?: string;               // `fee` in RAW quote-unit precision, scaled by 10^quoteDecimals (wire `fee_raw`)
        monacoFee?: string;            // Monaco's component of `fee` (wire `monaco_fee`)
        monacoFeeRaw?: string;         // `monacoFee` in RAW quote-unit precision (wire `monaco_fee_raw`)
        builderFee?: string;           // The builder fee component of `fee`; "0" for a maker (wire `builder_fee`)
        builderFeeRaw?: string;        // `builderFee` in RAW quote-unit precision (wire `builder_fee_raw`)
        executedAt: string;         // RFC3339 with `Z`, e.g. "2026-01-02T03:04:05Z"
        // Aggregate fields
        tradesCount: number;
        averageFillPrice: string;      // the order's average across every fill (both roles), as REST reports it; this trade's price is `executionPrice`
        // Slippage (taker only, null for maker)
        referencePrice?: string;
        bestPrice?: string;
        worstPrice?: string;
        actualSlippageBps?: number;
        maxSlippageBps?: number;
      }
    }
    ```

    **When:** Order completely filled.
    **Recipient:** Both taker and maker.
  </Tab>

  <Tab title="OrderPartiallyFilled">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderPartiallyFilled";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        role: OrderRole | (string & {});
        status: OrderStatus | (string & {});
        // Execution details
        tradeId: string;
        executionPrice: string;
        filledQuantity: string;        // cumulative order fill (wire `filled_quantity`) — same on every `role`
        filledQuantityRaw?: string;    // RAW (integer, base-unit) precision
        lastFillQuantity?: string;     // incremental fill from THIS event (wire `last_fill_quantity`); omitted when no fill
        lastFillQuantityRaw?: string;  // RAW precision
        remainingQuantity: string;
        remainingQuantityRaw?: string; // RAW precision
        postOnly?: boolean;         // both roles, and only when admitted post-only (wire `post_only`); omitted, never `false`
        fee: string;                   // Total fee for the fills THIS event covers, in decimal quote units; negative = maker rebate. monacoFee + builderFee
        feeRaw?: string;               // `fee` in RAW quote-unit precision, scaled by 10^quoteDecimals (wire `fee_raw`)
        monacoFee?: string;            // Monaco's component of `fee` (wire `monaco_fee`)
        monacoFeeRaw?: string;         // `monacoFee` in RAW quote-unit precision (wire `monaco_fee_raw`)
        builderFee?: string;           // The builder fee component of `fee`; "0" for a maker (wire `builder_fee`)
        builderFeeRaw?: string;        // `builderFee` in RAW quote-unit precision (wire `builder_fee_raw`)
        executedAt: string;         // RFC3339 with `Z`, e.g. "2026-01-02T03:04:05Z"
        // Aggregate fields
        tradesCount: number;
        // NOTE: no `totalFilled` on a fill payload — the cumulative fill is
        // `filledQuantity` and this event's increment is `lastFillQuantity`
        // `totalFilled` appears only on OrderCancelled and
        // OrderRejected.
        averageFillPrice?: string;     // the order's average across every fill (both roles), as REST reports it; this trade's price is `executionPrice`
        // Slippage (taker only, null for maker)
        referencePrice?: string;
        bestPrice?: string;
        worstPrice?: string;
        actualSlippageBps?: number;
        maxSlippageBps?: number;
      }
    }
    ```

    **When:** Order partially filled (more quantity remaining).
    **Recipient:** Both taker and maker.
  </Tab>

  <Tab title="OrderCancelled">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderCancelled";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        reason: string;
        filledQuantity?: string;      // cumulative order fill at cancellation (wire `filled_quantity`)
        filledQuantityRaw?: string;   // RAW (integer, base-unit) precision
        totalFilled?: string;         // legacy alias of `filledQuantity`
        totalFilledRaw?: string;      // RAW (integer, base-unit) precision
        remainingQuantity?: string;
        remainingQuantityRaw?: string; // RAW precision
        tradesCount?: number;
        averageFillPrice?: string;    // VWAP of the cancelled order (wire `average_fill_price`)
        cancelledAt?: string;         // cancellation timestamp, RFC3339 with `Z` (wire `cancelled_at`)
        postOnly?: boolean;           // `true` when the cancelled order was resting post-only (wire `post_only`); omitted, never `false`
        terminalReason?: OrderTerminalReason | (string & {});  // stable machine-readable cause (wire `terminal_reason`)
        selfTradePreventionMode?: SelfTradePreventionMode | (string & {});  // the mode REQUESTED on this order (wire `self_trade_prevention_mode`); absent when it carried none
        parentOrderId?: string;       // the TWAP parent of a child order (wire `parent_order_id`); omitted for any other order
        stpCounterpartyOrderId?: string;  // self-trade-prevention counterparty (wire `stp_counterparty_order_id`); present ONLY with terminalReason `SELF_TRADE_PREVENTION`
      }
    }
    ```

    **When:** Order cancelled by user, an `IOC` order's remainder cancelled after a partial fill, a market, `IOC` or `FOK` remainder (or a margin limit remainder) the price band cut short (`terminalReason` `PRICE_BAND`, or `SLIPPAGE_TOLERANCE` when the order's own `slippageToleranceBps` was tighter), a resting perp order a mark move left past the band (`PRICE_BAND`), a cancellation caused by [self-trade prevention](/developers/trade#self-trade-prevention), or a resting order the engine cancelled because its risk bucket entered liquidation or because auto-deleveraging closed the position a reduce-only order was written for. An `FOK` that cannot fill completely for want of liquidity is **rejected** instead — its candidate fills are discarded and it emits `OrderRejected` — but an `FOK` that meets one of your own orders under `CANCEL_TAKER` or `SKIP` is **cancelled**, arriving here as a zero-fill `OrderCancelled` rather than a rejection. (`CANCEL_BOTH` is the one that still rejects at placement.)
    **Recipient:** Order owner.
    **Reasons:**

    * `"IOC order: remaining quantity cancelled after partial fill"`
    * `"Market order: remaining quantity cancelled due to slippage limit"`
    * `self_trade_prevention` — paired with `terminalReason` `SELF_TRADE_PREVENTION`
    * `LIQUIDATION` — paired with `terminalReason` `LIQUIDATION`: the order's risk bucket entered liquidation, which cancels every resting order it holds
    * `AUTO_DELEVERAGING` — paired with `terminalReason` `AUTO_DELEVERAGING`: auto-deleveraging closed the position this reduce-only order was written for
    * A price-band cut carries `terminalReason` `PRICE_BAND` (the exchange's protective band stopped the order, or a mark move left a resting perp order past it) or `SLIPPAGE_TOLERANCE` (the order's own `slippageToleranceBps` did); match on `terminalReason`, not the free-form `reason`

    **Self-trade prevention.** `stpCounterpartyOrderId` and `terminalReason: "SELF_TRADE_PREVENTION"` always travel together, so the presence of the key is the whole signal — absent means this was not an STP cancellation. On a resting order swept by an arriving order of the same wallet family it names the **arriving** order; on a taker whose remainder was cancelled instead it names the **first own resting order** the walk reached. An STP cancellation is a real terminal `CANCELLED` even with zero fills, unlike an accepted zero-fill `IOC`/`FOK`, which is `REJECTED`. A plain zero-fill `MARKET` submit is refused before acceptance and emits neither frame.
  </Tab>

  <Tab title="OrderPartiallyCancelled">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderPartiallyCancelled";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;          // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        quantity: string;             // reduced TOTAL: quantity = filledQuantity + remainingQuantity
        status: OrderStatus | (string & {});          // non-terminal — the order is STILL RESTING
        filledQuantity: string;       // cumulative fill, unchanged by the reduction
        filledQuantityRaw: string;    // RAW (integer, base-unit) precision
        remainingQuantity: string;    // remaining after the reduction (the requested target)
        remainingQuantityRaw: string; // RAW precision
        reason: string | null;        // cancellation reason; null when none (the SDK cancelOrder request takes no reason input)
        reducedAt: string;            // reduction timestamp, RFC3339 with `Z` (wire `reduced_at`)
        clientOrderId?: string | null;
        postOnly?: boolean;           // `true` when the reduced order is resting post-only (wire `post_only`); omitted, never `false`
        stpCounterpartyOrderId?: string;  // declared for a future reducing mode; NOTHING emits it on this event today
      }
    }
    ```

    **When:** A `cancelOrder(orderId, { remainingQuantityTarget })` partial cancel reduces a resting order in place. Self-trade prevention cancels a resting order **in full**, so it never produces this event — `stpCounterpartyOrderId` is declared here for a future reducing mode and is absent on every frame today. **Not a terminal event** — the order keeps its queue position and still rests; a later fill or `OrderCancelled` follows eventually.
    **Recipient:** Order owner.
  </Tab>

  <Tab title="OrderRejected">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderRejected";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        reason: string;
        status: OrderStatus | (string & {});
        totalFilled: string;
        totalFilledRaw: string;        // RAW (integer, base-unit) precision
        remainingQuantity: string;
        remainingQuantityRaw: string;  // RAW (integer, base-unit) precision
      }
    }
    ```

    **When:** An order that was *accepted* is then rejected by the match — an `IOC`
    that cannot fill at all, a `FOK` that cannot fill completely, or a `MARKET`
    order whose apparent liquidity is invalidated by maker-risk revalidation after
    acceptance. All of these are liquidity outcomes. An order stopped instead by
    [self-trade prevention](/developers/trade#self-trade-prevention) is
    `OrderCancelled` even with zero fills — the one exception being a `FOK` under
    `CANCEL_BOTH`, which is refused at placement and emits nothing. Such a request returns `2xx` with `matchResult.status` `REJECTED`,
    and emits `OrderPlaced` followed by `OrderRejected`, so a success response does
    not mean it executed.

    This event does **not** cover rejections raised *before* acceptance — a
    validation failure, a post-only order that would cross, a `clientOrderId`
    conflict, or a plain `MARKET` submit with no liquidity inside the protective
    band, which is rejected at pricing before a sequence number is allocated. Those
    fail the request itself and emit nothing.
    **Recipient:** Order owner.
    **Reasons:**

    * `"IOC order rejected: no immediate fill available"`
    * `"FOK order rejected: cannot be fully filled immediately"`
    * `"FOK order rejected: partial fill not acceptable, must be fully filled"`
    * `"Market order rejected: no liquidity available"` — reachable only when maker-risk
      revalidation invalidates apparent liquidity after acceptance, not from a plain
      market submit
  </Tab>

  <Tab title="OrderExpired">
    ```typescript theme={null}
    {
      orderId: string;
      eventType: "OrderExpired";
      timestamp: string;
      data: {
        orderId: string;
        version?: number;          // opaque same-order revision; absent means unknown
        userId: string;
        tradingPairId: string;
        tradingPair: string;         // wire `trading_pair` — NOT `symbol`
        tradingMode: TradingMode | (string & {});
        // Always sent, `null` rather than absent when there is no value —
        // the emitter writes every key and maps an empty Option to null.
        filledQuantity: string | null;
        filledQuantityRaw: string | null;    // RAW (integer, base-unit) precision
        remainingQuantity: string | null;
        remainingQuantityRaw: string | null; // RAW (integer, base-unit) precision
        averageFillPrice: string | null;
        reason: string;
        postOnly?: boolean;          // `true` when the expired order was resting post-only (wire `post_only`); omitted, never `false`
      }
    }
    ```

    **When:** GTC order reaches expiration date (default 90 days).
    **Recipient:** Order owner.
    **Reasons:**

    * `"GTC order expired after 90 days"`
    * `"GTC order reached custom expiration date"`
  </Tab>
</Tabs>

### Edited Orders

Editing a partly filled order keeps its fills: the replacement (a new order id) carries them, and its frames report the order as `getOrder` 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.

```typescript theme={null}
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

const unsubscribe = sdk.ws.orderbook(
  pair.id,       // Trading pair ID
  "SPOT",        // Trading mode
  1,             // Magnitude (price grouping)
  "BASE",        // Quotation mode: "BASE" or "QUOTE"
  (event) => {
    console.log("Best bid:", event.bestBid);
    console.log("Best ask:", event.bestAsk);
    console.log("Bids:", event.bids);
    console.log("Asks:", event.asks);
  }
);

// Cleanup
unsubscribe();
```

### Magnitude Parameter

The magnitude parameter groups orderbook levels by price intervals:

| Magnitude | Description |
| - | - |
| `0.0001` | Group by \$0.0001 |
| `0.001` | Group by \$0.001 |
| `0.01` | Group by \$0.01 |
| `0.1` | Group by \$0.1 |
| `1` | Group by \$1 |
| `10` | Group by \$10 |
| `100` | Group by \$100 |
| `1000` | Group by \$1,000 |
| `10000` | Group by \$10,000 |

## OHLCV Channel

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

```typescript theme={null}
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

const unsubscribe = sdk.ws.ohlcv(
  pair.id,    // Trading pair ID
  "SPOT",     // Trading mode
  "1m",       // Interval: "1m", "5m", "15m", "1h", "4h", "1d"
  (event) => {
    const candle = event.candlestick;
    console.log("Open:", candle.o);
    console.log("High:", candle.h);
    console.log("Low:", candle.l);
    console.log("Close:", candle.c);
    console.log("Volume:", candle.v);
  }
);

// Cleanup
unsubscribe();
```

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.

```typescript theme={null}
// Mark-price candles for a margin pair
const unsubMark = sdk.ws.ohlcv(pair.id, "MARGIN", "1m", (event) => {
  console.log("mark close:", event.candlestick.c);
}, "mark");
```

An optional sixth `onSnapshot` argument receives the subscribed series' newest **350** candles, oldest first, once per subscription (and again after each reconnect) — the same candles as `sdk.market.getCandlesticks` with its default limit, including flat candles for periods with no trades. Each row is an `OHLCVSnapshotCandle`: the live `Candlestick` without `s`, with `i` set to the interval you subscribed to. Pass `undefined` for `priceType` to keep the trade series:

```typescript theme={null}
import type { OHLCVSnapshotCandle } from "@0xmonaco/types";

const candles = new Map<number, OHLCVSnapshotCandle>();

const unsubscribe = sdk.ws.ohlcv(
  pair.id,
  "SPOT",
  "1m",
  (event) => {
    // Upsert by period start: a live candle replaces the one with the same `T`.
    candles.set(event.candlestick.T, event.candlestick);
  },
  undefined,
  (snapshot) => {
    for (const candle of snapshot) candles.set(candle.T, candle);
  }
);
```

It is a recent-history baseline, not a gap-free replay. The snapshot reads recorded candles, which trail the live candles: the newest `1m` candle is the last closed minute and longer intervals can lag, so its newest candle can be partial or missing. A live candle is the full current state of its period, so upsert by `T`. A `"*"` price type gets no snapshot (one frame carries one series). An unknown pair, a `tradingMode` that is not the market's, and `"mark"` on a spot market get `onSnapshot([])`. `"mark"` snapshots are served on every interval, though live mark candles stream on `1m` only.

### OHLCV Event Structure

```typescript theme={null}
import type { Interval, TradingMode } from "@0xmonaco/types";

interface OHLCVEvent {
  tradingPairId: string;
  interval: Interval | (string & {});
  tradingMode: TradingMode | (string & {});
  priceType?: "trade" | "mark" | (string & {});
  candlestick: Candlestick;
}
```

`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

```typescript theme={null}
interface Candlestick {
  T: number;  // Period start timestamp (ms, UTC)
  t: number;  // Period end timestamp (ms, UTC)
  o: string;  // Open price
  h: string;  // High price
  l: string;  // Low price
  c: string;  // Close price
  v: string;  // Volume
  s: string;  // Symbol
  i: string;  // Interval
  n: number;  // Number of trades
  openInterestBase?: string | null;      // One-sided LONG open interest in base-asset units, sampled at or before the candle end
  openInterestNotional?: string | null;  // One-sided LONG quote notional, valued at that same sample's paired price (not the close)
}
```

## Trades Channel

Subscribe to trade executions. No authentication required.

```typescript theme={null}
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

const unsubscribe = sdk.ws.trades(
  pair.id,    // Trading pair ID
  (event) => {
    console.log("Trade ID:", event.data.tradeId);
    console.log("Price:", event.data.price);
    console.log("Quantity:", event.data.quantity);
    console.log("Maker side:", event.data.makerSide);
  },
  (trades) => {
    // Optional: the market's newest 50 trades, newest first
    console.log("Recent trades:", trades.length);
  }
);

// Cleanup
unsubscribe();
```

The optional `onSnapshot` receives `TradeEventData[]`: the market's newest 50 trades, newest first, once per subscription (and again after each reconnect). It is a recent-history baseline, not a gap-free replay — a trade can arrive both in the snapshot and as an early live event, and one executed just before you subscribed can be in neither — so dedupe on `tradeId`. Older history comes from the REST trades read.

### Trade Event Structure

```typescript theme={null}
import type { OrderSide, TradingMode } from "@0xmonaco/types";

interface TradeEvent {
  eventType: "trade";
  tradingPairId: string;
  tradingMode: TradingMode | (string & {});
  data: {
    tradeId: string;
    price: string;
    quantity: string;
    makerSide: OrderSide | (string & {});
    executedAt: string;
  };
}
```

## Movements Channel

<Note>
  **New in v0.5.4** - Real-time balance movement tracking
</Note>

Subscribe to real-time account balance movements. **Requires authentication.**

```typescript theme={null}
// Authenticate first
await sdk.login("your-client-id");

// Subscribe to movements
const unsubscribe = sdk.ws.movements((event) => {
  console.log("Movement ID:", event.data.id);
  // Live types: "deposit", "withdrawal", "funding" (lowercase) and the order
  // unlocks "OrderCancelled", "OrderPartiallyCancelled", "OrderExpired", whose
  // entryType is uppercase "CREDIT". Fills and fees never arrive here — read
  // them from movement history, and take a fill's live signal off `balances`.
  console.log("Transaction:", event.data.transactionType);
  console.log("Entry:", event.data.entryType);
  console.log("Token:", event.data.tokenAddress);
  console.log("Amount:", event.data.amount);
  console.log("Balance:", event.data.balanceBefore, "→", event.data.balanceAfter);
  
  // Check if related to an order
  if (event.data.referenceType === "order") {
    console.log("Order ID:", event.data.referenceId);
  }
  
  // Check for on-chain transaction
  if (event.data.txHash) {
    console.log("TX Hash:", event.data.txHash);
  }
});

// Cleanup
unsubscribe();
```

### Movement Event Structure

```typescript theme={null}
interface UserMovementEvent {
  eventType: "movement";
  userId: string;
  data: {
    id: string;                    // Movement ID (UUID)
    entryType: string;             // free-form; "credit" | "debit" | "CREDIT" (order unlocks) — compare case-insensitively
    transactionType: string;       // free-form; live: "deposit" | "withdrawal" | "funding" | "OrderCancelled" | "OrderPartiallyCancelled" | "OrderExpired". History adds fill/fee types this channel never emits
    status?: string;               // Movement status: "completed" | "pending" | "failed" (wire `status`)
    token?: string;                // Token contract address (wire `token`; older payloads use the `token_address` alias)
    tokenAddress: string;          // Populated legacy alias of `token` (always set, for back-compat)
    assetId?: string;              // Asset registry ID, UUID (wire `asset_id`)
    symbol?: string;               // Token symbol (e.g., "USDC")
    decimals: number;              // Token decimals (e.g., 6 for USDC)
    amount: string;                // Movement amount (decimal string, normalized)
    amountRaw?: string;            // Raw amount (integer string, no decimals)
    balanceBefore?: string;        // Balance before movement (normalized)
    balanceBeforeRaw?: string;     // Balance before in raw format (wei)
    balanceAfter?: string;         // Balance after movement (normalized)
    balanceAfterRaw?: string;      // Balance after in raw format (wei)
    lockedBefore?: string;         // Locked balance before (normalized)
    lockedBeforeRaw?: string;      // Locked balance before in raw format (wei)
    lockedAfter?: string;          // Locked balance after (normalized)
    lockedAfterRaw?: string;       // Locked balance after in raw format (wei)
    referenceId?: string;          // Related entity ID (e.g., order UUID)
    referenceType?: string;        // Reference type ("order", "deposit", etc.)
    description?: string;          // Human-readable description
    txHash?: string;               // On-chain transaction hash (if applicable)
    blockNumber?: number;          // Block number, if on-chain (wire `block_number`)
    createdAt?: string;            // ISO 8601 timestamp (UTC)
  };
}
```

### Example: Track Deposits

```typescript theme={null}
sdk.ws.movements((event) => {
  if (event.data.transactionType === "deposit" && event.data.txHash) {
    console.log(`✅ Deposit confirmed: ${event.data.amount}`);
    console.log(`New balance: ${event.data.balanceAfter}`);
    console.log(`TX: ${event.data.txHash}`);
  }
});
```

### Example: Monitor Order Unlocks

```typescript theme={null}
sdk.ws.movements((event) => {
  // A cancelled, partially cancelled, or expired order releases its lock.
  if (event.data.transactionType.toLowerCase().startsWith("order")) {
    console.log(`Order ${event.data.referenceId}: ${event.data.amount} unlocked`);
  }
});
```

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:

```tsx theme={null}
import { useUserMovements } from "@0xmonaco/react";

function MovementsFeed() {
  const { movements, loading, subscribed } = useUserMovements({ maxMovements: 50 });
  
  return (
    <div>
      {movements.map(m => (
        <div key={m.data.id}>
          {m.data.transactionType}: {m.data.amount}
        </div>
      ))}
    </div>
  );
}
```

[Learn more about useUserMovements →](/sdk/react/hooks/use-user-movements)

## 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

```typescript theme={null}
// All conditional-order events for the authenticated user
const unsubscribe = sdk.ws.conditionalOrders((event) => {
  const { conditionalOrderId, state, reason, triggerPrice, triggeredOrderId } = event.data;
  console.log(`${conditionalOrderId} → ${state} (${reason})`);
});

// Scope to a single trading pair (optional second arg)
const unsubscribePair = sdk.ws.conditionalOrders(
  (event) => { /* ... */ },
  tradingPairId,
);

// Cleanup
unsubscribe();
```

### Conditional Order Event Structure

```typescript theme={null}
import type {
  ConditionalOrderConditionType,
  ConditionalOrderState,
  ConditionalOrderTriggerSource,
  OrderSide,
  OrderType,
  PositionSide,
  TimeInForce,
} from "@0xmonaco/types";

{
  eventType: "conditional_order_update";
  userId: string;
  data: {
    conditionalOrderId: string;
    version?: number;             // revision of this row, on every update; compare with >=
    tradingPairId: string;
    marginAccountId: string;
    positionId?: string;
    parentOrderId?: string;
    associationType?: "POSITION" | "PARENT_ORDER" | (string & {});
    linkedGroupId?: string;              // populated for OCO TP/SL pairs
    conditionType: ConditionalOrderConditionType | (string & {});
    triggerSource: ConditionalOrderTriggerSource | (string & {});
    triggerPrice: string;
    side: OrderSide | (string & {});
    positionSide: PositionSide | (string & {});
    orderType: OrderType | (string & {});
    limitPrice?: string;
    quantity?: string;
    slippageToleranceBps?: number;
    reduceOnly: boolean;
    timeInForce?: TimeInForce | (string & {});           // full enum, not just GTC/IOC — narrow before use
    state: ConditionalOrderState | (string & {});
    triggeredOrderId?: string;           // the close the trigger placed; set from the "triggered" update on
    triggeredAt?: string;
    activatedAt?: string;                // populated when parent-attached TP/SL becomes active
    cancelledAt?: string;
    expiresAt?: string;
    failureReason?: string;              // populated on FAILED and EXPIRED, and on a refused trailing stop
    trailBps?: number;                   // TRAILING_STOP only: retracement behind the watermark, in bps
    activationPrice?: string;            // TRAILING_STOP only: level that starts tracking; absent when armed immediately
    watermarkPrice?: string;             // TRAILING_STOP only: best mark since arming; triggerPrice trails it
    trailArmedAt?: string;               // TRAILING_STOP only: when tracking began; absent while awaiting activationPrice
    reason: string;                      // free-form; canonical values listed below
    createdAt?: string;                  // same string REST returns; always sent by current servers, absent only from an older deployment
    updatedAt: string;
  }
}
```

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` 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](/developers/trade#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:

* `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`). They are 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

| State | Meaning |
| - | - |
| `PENDING_PARENT` | Attached to a parent order that has not filled yet |
| `ACTIVE` | Created, waiting for trigger |
| `TRIGGERING` | Trigger condition met, underlying order being submitted |
| `TRIGGERED` | Underlying order submitted (now follows the standard order lifecycle via `userOrders` / `orders`) |
| `CANCELLED` | Cancelled by the user, by its OCO sibling triggering, with its entry order, by its position closing, by a superseding full-close order, or refused as a second trailing stop (`reason` says which) |
| `EXPIRED` | The trigger fired but the position it was attached to was no longer open, had been replaced, or had flipped side (see `failureReason`) |
| `FAILED` | Trigger fired but underlying order submission failed (see `failureReason`) |

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](/developers/trade#conditional-perps-orders) and [`sdk.positions.attachPositionTpSl`](/sdk/typescript/positions).

## 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.

The channel carries two kinds of frame: lifecycle `position_update` frames, delivered to the handler, and periodic [`position_valuation` frames](#position-valuation-frames), delivered only to the optional `onValuation` callback.

Detected lag closes the whole socket with 1013 under the [slow-client rule](#slow-client-disconnect-close-code-1013). 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](/developers/market-maker-runbook#4-reconnect-and-resync)).

### Subscribe to Positions

```typescript theme={null}
// All position updates for the authenticated user
const unsubscribe = sdk.ws.positions((event) => {
  const { positionId, side, status, size, markPrice, unrealizedPnl, liquidationPrice } = event.data;
  console.log(`${positionId} ${side} ${status} size=${size} mark=${markPrice} uPnL=${unrealizedPnl}`);
});

// Scope to a single trading pair (optional second arg)
const unsubscribePair = sdk.ws.positions((event) => { /* ... */ }, tradingPairId);

// Periodic valuations (optional fourth arg): apply them only to positions you hold as OPEN
const unsubscribeWithValuations = sdk.ws.positions(
  (event) => { /* lifecycle frames */ },
  undefined,
  undefined,
  (valuation) => {
    const held = positions.get(valuation.data.positionId);
    if (held?.status !== "OPEN") return; // unknown or terminal: ignore
    // read before the held state's last mutation: stale, ignore
    if (Date.parse(valuation.data.valuedAt) <= Date.parse(held.updatedAt)) return;
    Object.assign(held, {
      markPrice: valuation.data.markPrice,
      unrealizedPnl: valuation.data.unrealizedPnl,
      liquidationPrice: valuation.data.liquidationPrice ?? undefined,
      initialMarginRequired: valuation.data.initialMarginRequired,
      maintenanceMarginRequired: valuation.data.maintenanceMarginRequired,
    });
  },
);

// Cleanup
unsubscribe();
```

### Position Event Structure

```typescript theme={null}
import type { MarginMode, PositionEventSide, PositionEventStatus } from "@0xmonaco/types";

{
  eventType: "position_update";
  userId: string;
  data: {
    positionId: string;
    marginAccountId: string;
    tradingPairId: string;
    side: PositionEventSide | (string & {});
    status: PositionEventStatus | (string & {});  // SCREAMING_SNAKE_CASE, same casing as REST
    size: string;                 // absolute size (decimal string)
    entryPrice: string;           // decimal string
    markPrice?: string;           // decimal string
    unrealizedPnl?: string;       // decimal string
    liquidationPrice?: string;    // decimal string
    isolatedMargin: string;       // the position's backing (decimal string) — the same value the REST position's isolatedMargin serves
    leverage: string;             // decimal string
    riskBucketId?: string;        // risk bucket scoping the position — the same value REST's riskBucketId serves
    marginMode?: MarginMode | (string & {});  // "CROSS" | "ISOLATED"; never infer it from riskBucketId
    maintenanceMarginRequired?: string;  // required at markPrice (decimal string); "0" with no open exposure
    initialMarginRequired?: string;      // required at markPrice (decimal string); "0" with no open exposure
    tradeId?: string;             // trade that caused the update, present when a fill drove it
    orderId?: string;             // order that caused the update (falls back to the position's opening order)
    reason: string;               // "trade" | "close" | "liquidation" | "adl" | "funding" | "margin_added" | "margin_reduced"
    updatedAt: string;            // ISO 8601 (UTC) — when the position was last MUTATED, not when the frame was built
    openedAt?: string;            // ISO 8601 (UTC) — when the position OPENED; never moves; absent when unknown
    version?: number;             // opaque durable row revision; absent means unranked
    fundingPaid?: string;         // lifetime funding since open (decimal string); absent when unknown, never zero
  }
}
```

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`, `feesPaid` and `cumFees` are REST-only, so a consumer that needs any of those still reads REST.

`fundingPaid` is the position's lifetime funding since it opened (positive = paid, negative = received). On an open position it is the funding accrued on the size still open plus the funding its partial closes settled. Every live frame carries it — fills, funding settlements, margin changes, and terminal frames, whose value is the closed position's whole-life total and matches `fundingPaid` on a REST read of the closed position. A snapshot row carries the value the position's live frames carry. It is absent, never zero, when the value cannot be served whole: the position's earlier reductions are not fully accounted for, its history is still catching up, or the frame comes from an older server. Keep the value you already hold for that `positionId` rather than clearing it. `position_valuation` frames do not carry it.

The six re-derived values (`markPrice`, `unrealizedPnl`, `liquidationPrice`, `leverage`, `maintenanceMarginRequired`, `initialMarginRequired`) are recomputed per payload rather than read from the versioned row, so take them from the newest frame, as the merge rule below already requires. Five of them are priced at whichever mark the answering surface holds, so a snapshot row and a live frame can differ by one oracle tick. A snapshot row for a position the matching engine holds is built from the engine's live state and priced at the engine's mark, like the live frames; a row the engine does not serve is read from the database and prices its whole row from a single mark, which keeps each row internally consistent. `leverage` is the exception: it is opening notional over posted margin, which no reprice touches, and it is in this group because the live surfaces derive it from engine state while the persisted row serves its stored `leverage` column, so the two can disagree at an unchanged version.

A position with no risk bucket — one opened before risk buckets existed — reports neither `riskBucketId` nor `marginMode`. Do **not** infer either field from the other: they are resolved from different sources — the id from the position, the mode from the matching engine when it holds the position and from the risk-bucket record otherwise — so an id can arrive with no mode, on a terminal row whose record is unavailable, on a read taken while the engine is unreachable, or from a producer older than the field. Treat a missing `marginMode` as unknown and keep the mode you already hold; defaulting it to `CROSS` renders an isolated position with the wrong backing.

`updatedAt` is when the position was last **mutated** — a fill, collateral transfer, funding settlement or close — carried from the producer's own record of that mutation. Two frames for a position nothing has changed repeat one timestamp, and a valuation-only oracle reprice does not move it. It is display metadata: reconcile on `version`, never on `updatedAt`.

`openedAt` is when the position **opened** — the fill that took its exposure from zero to non-zero. It rides the snapshot rows and the live frames, terminal frames included, and never moves for the life of `positionId`: time held is `now - openedAt` while open and `updatedAt - openedAt` on the terminal frame. A flip closes the position and opens a new one with a new `positionId` and its own `openedAt`. It is absent when the server does not know it — a terminal frame whose close it could not attach the instant to, or a frame from an older server — and is never filled with the time the frame was sent; keep the value you already hold for that `positionId`. Clamp a computed time held at zero: the value can sit slightly ahead of your clock, and after a server restart it can move by the server's own clock drift. It is not versioned: take it from the newest payload — frame or REST read — rather than skipping it on an equal `version`, because a one-off server correction can move it earlier without a version change.

The match across surfaces is exact for an **open** position. A **terminal** REST read carries the instant the matching engine emitted the close, which can differ slightly from the live frame's close instant; older terminal rows may instead carry the persistence clock and trail the close by the persistence lag. Any position read after a matching-engine restart is also served from the persistence clock and differs by that lag. Treat it as accurate to that lag.

One further case is not bounded by that lag. While the matching engine is unreachable, a REST read of an open position falls back to that row's lifecycle timestamps — close, then last funding, then open — so a position filled repeatedly since it opened reports its opening instant until funding settles. A live `position_update` frame is unaffected, being built from engine state, so a REST value older than a frame you already hold is expected in that window and the frame is the fresher one.

`version` is the position's opaque, non-negative durable row revision. The subscribe-time snapshot and a **ranked** live frame for identical position state carry the same value, so a client reconciles on `positionId + version`. Never use `updatedAt`, arrival order, or receiver backlog to establish ordering. Not every live frame is ranked — see the note below for which are, and treat an unranked frame as unranked rather than assuming it is comparable with a snapshot row.

The merge rule is **per field group**, not per frame — a frame is never discarded wholesale:

| Incoming version vs. held | Versioned fields (`size`, `entryPrice`, `status`, `marginMode`, `isolatedMargin`, ...) | Valuation fields (`markPrice`, `unrealizedPnl`, `liquidationPrice`, `leverage`, `maintenanceMarginRequired`, `initialMarginRequired`) |
| - | - | - |
| Higher | apply | apply |
| Equal, live frame | skip — an idempotent redelivery | **apply** — they carry no version; five of them move on every oracle reprice, and `leverage` is derived per payload rather than served from the row |
| Equal, snapshot row | **apply** — see below | apply |
| Lower | skip | **skip** — a ranked frame can still arrive late, and taking its valuation fields would regress the displayed mark and PnL |
| Either side unranked | compare the versioned fields directly | apply the later-arriving payload |

A snapshot row wins an equal-version tie because it is built from live matching-engine state under the version of the persisted row, and persistence can lag the engine: its fields can reflect a later command than its version names. Skipping them on an equal version would keep the values the reconnect was meant to correct.

Note the asymmetry between the Equal and Lower rows. An equal version means the *same* command, so its valuation fields are a legitimately newer sample of the same position state. A lower version means an *older* command, so nothing it carries is newer — including its mark. Arrival order is only a fallback where there is no version to compare at all; it must never override a positive version, for the same reason the first paragraph rules it out as an ordering signal.

Discarding a whole frame on an equal version is the one mistake this field is likely to cause: it freezes displayed unrealized PnL until the position's next real mutation, which for an idle position is the next hourly funding settlement.

Only a POSITIVE version is rankable, and "unranked" has two wire spellings that mean exactly the same thing:

* On the **WebSocket** — live frames and the subscribe-time snapshot alike — the key is **absent**. The snapshot maps the persisted `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.** Current unranked frames cover two situations:

* The emit path wrote no position row at that sequence, so there is no versioned row to point at.
* The frame came from a producer that predates position versions.

Funding and terminal frames are normally ranked: funding carries exact post-settlement state captured inside its sequencer command, and zero payments emit no frame; a settlement's frames on every channel (position frames, `funding` balance frames and `funding` movements) are sent at a paced rate after the settlement, so on a large book they can arrive several seconds later, after frames for later fills and in any order across channels — rank by `version`, and never reopen a position you hold as terminal (a position closed before its account's frames are sent gets no funding position frame); terminal frames retain lifetime reduced quantity (the sum of every reducing execution), lifetime allocated initial margin, and the exact `CLOSED`/`LIQUIDATED` classification. A terminal frame remains unranked after a restart when legacy reduction history lacks size or margin fields needed to calculate complete lifetime values. Incomplete size history carries only the known partial total. This is an explicit compatibility fallback, not version `0`.

<Note>
  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](#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 `ALLOCATE` or `RELEASE` refreshes every open position in that risk bucket; copy follow creation or allocation growth and copy stop return refresh the copy cross risk bucket. These carry `margin_added` or `margin_reduced` but 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 `version` so 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 to `0`.

  Bucket-level transfer frames are unranked because the selected position rows did not advance at that sequence.

  Wallet-to-margin `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.
</Note>

### Position Valuation Frames

A mark move changes an open position's mark price, unrealized PnL, liquidation price and margin requirements without any lifecycle event behind it. `position_valuation` frames refresh those five figures for every open position about every five seconds, on the same `positions` and `positions:{tradingPairId}` channels, to the same owner. They are not yet enabled on any network; until they are, the `positions` channel carries lifecycle frames only.

```typescript theme={null}
import type { PositionValuationEvent } from "@0xmonaco/types";

{
  eventType: "position_valuation";
  userId: string;
  data: {
    positionId: string;
    marginAccountId: string;
    tradingPairId: string;
    markPrice: string;                  // decimal string
    unrealizedPnl: string;              // decimal string
    liquidationPrice: string | null;    // null when no liquidation price can be derived
    initialMarginRequired: string;      // required at markPrice (decimal string)
    maintenanceMarginRequired: string;  // required at markPrice (decimal string)
    valuedAt: string;                   // ISO 8601 (UTC) — when the server read the position
  }
}
```

* **Valuation only.** A valuation carries no `size`, `side`, `status`, `reason`, `updatedAt` or `version`, and never opens, closes, resizes or reopens a position. Lifecycle stays on `position_update`.
* **Apply it only to a position you hold as `OPEN`, and only when `valuedAt` is later than that position's `updatedAt`.** Valuations are spread over each interval, so one can arrive after a newer `position_update` for the same position — a fill, a close or a funding settlement that happened after the server read it. `valuedAt` and `updatedAt` come from the same server clock, so a valuation whose `valuedAt` is not later than the `updatedAt` you hold predates that state: ignore it, and the next valuation brings current figures. Ignore a valuation for an unknown or terminal `positionId` too. The server also withholds a valuation read before a `position_update` it has already sent for that position, including updates such as a risk bucket transfer that do not move `updatedAt`, 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_update` frame 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_update` is the last word.
* **`liquidationPrice` is `null`** when 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.
* **`valuedAt`** is when the server read the position. It is not the oracle's publish time and not `updatedAt`, 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 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](#slow-client-disconnect-close-code-1013). 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](/developers/market-maker-runbook#4-reconnect-and-resync).

### Subscribe to Liquidations

```typescript theme={null}
// All liquidation alerts for the authenticated user
const unsubscribe = sdk.ws.liquidations((event) => {
  const { liquidationId, status, riskBucketState, estimatedShortfall, message } = event.data;
  console.warn(`liquidation ${liquidationId} ${status}/${riskBucketState} shortfall=${estimatedShortfall}: ${message}`);
});

// Scope to a single trading pair (optional second arg)
const unsubscribePair = sdk.ws.liquidations((event) => { /* ... */ }, tradingPairId);

// Cleanup
unsubscribe();
```

### Liquidation Event Structure

```typescript theme={null}
{
  eventType: "liquidation_alert";
  userId: string;
  data: {
    liquidationId: string;
    marginAccountId: string;
    positionId?: string;          // present when the alert is position-scoped
    tradingPairId?: string;       // present when the alert is position-scoped
    status: string;               // liquidation record status (e.g. "UNRESOLVED", or "COMPLETED" on a terminal alert)
    riskBucketState: string;      // liquidated risk bucket's state (e.g. "PENDING_LIQUIDATION")
    liquidationPrice?: string;    // decimal string
    markPrice?: string;           // decimal string
    estimatedShortfall: string;   // decimal string
    message: string;              // human-readable alert message
    triggeredAt: string;          // ISO 8601 timestamp (UTC)
  }
}
```

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"`, `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](/reference/websockets#recovered-episodes).

## Copy Channel

Stream your [copy trading](/developers/copy-trading) follows as a **follower**: a `copy_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`](/sdk/react/hooks/use-copy-trading#usecopyevents).

```typescript theme={null}
const unsubscribe = sdk.ws.copy(
  (event) => {
    switch (event.eventType) {
      case "copy_relationship_update": {
        const { relationshipId, status, change, allocation, consecutiveSkips, version } = event.data;
        console.log(`follow ${relationshipId} ${status} (${change}) allocation=${allocation} skips=${consecutiveSkips} v=${version}`);
        break;
      }
      case "copy_skip": {
        const { relationshipId, tradingPairId, reason, requestedQuantity } = event.data;
        console.warn(`follow ${relationshipId} skipped ${requestedQuantity} on ${tradingPairId}: ${reason}`);
        break;
      }
    }
  },
  // Subscribe-time snapshot: every live follow, with change "snapshot".
  (follows) => setFollows(follows),
);

unsubscribe();
```

Each relationship frame carries the follow's **full current state**, so replace the stored entry rather than merging. `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

```typescript theme={null}
type CopyEvent =
  | {
      eventType: "copy_relationship_update";
      userId: string;                // the follower (you)
      data: {
        relationshipId: string;      // the follow id (Follow.followId over REST)
        leaderWallet: string;
        status: CopyRelationshipStatus | (string & {});
        marginModePolicy: CopyMarginModePolicy | (string & {});
        copyRiskBucketId: string;    // the copy account's cross risk bucket
        sizingMode: CopySizingMode | (string & {});
        sizingParam?: string;
        leverageMode: CopyLeverageMode | (string & {});
        fixedLeverage?: string;
        allocation: string;
        slPct?: string;
        copyExisting: boolean;
        tradingPairIds: string[];    // empty: every market the leader trades
        cumNetRealized: string;      // profit-share basis, not a balance
        hwm: string;
        consecutiveSkips: number;
        startedAt: string;
        stoppedAt?: string;
        stopReason?: CopyStopReason | (string & {});
        change: CopyRelationshipChange | (string & {});
        version?: number;
      };
    }
  | {
      eventType: "copy_skip";
      userId: string;
      data: {
        relationshipId: string;
        leaderWallet: string;
        leaderTradeId: string;
        leaderOrderId: string;
        tradingPairId: string;
        reason: CopySkipReason | (string & {});
        requestedQuantity: string;   // base units
        consecutiveSkips: number;
        detail?: string;
        version?: number;
      };
    };
```

## Account Channel

Stream account-level margin health for the authenticated user — equity, collateral, free collateral, margin ratio, maintenance requirement, initial margin required, withdrawable collateral, and realized PnL, per margin account. **Requires authentication**; the signed session-key handshake is sent before the subscribe.

<Note>
  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](/reference/websockets#account).
</Note>

Detected lag closes the whole socket with 1013 under the [slow-client rule](#slow-client-disconnect-close-code-1013). 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](#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](#account-snapshot) below.

### Flat accounts

Live frames flow on a \~2s tick for every account holding an open position or a resting order reserve. When its last position and reserve clear, one final frame carries the released state, and after that a flat account sends no keepalive. It still gets a frame after its collateral moves: a transfer between the spot wallet and margin, an on-chain deposit into margin or a withdrawal from it, a transfer between your margin accounts, closing a sub-account, a copy-trading allocation, or an insurance payout. Moves that land before the producer's next tick collapse into one frame carrying the latest state, and that frame normally arrives on the next tick, within \~2s of the move. The producer reads at most 512 flat accounts per tick, so a burst wider than that spreads over the following ticks. The frame carries the account's current collateral and uses the same per-account `sequence` as every other live frame, so the dedupe rule above applies unchanged. For a flat account with no open risk bucket, `equity` and `freeCollateral` equal `collateral`, and `maintenanceRequirement` and `marginRatio` are `"0"`. On-chain deposits and withdrawals also appear on the `movements` channel.

### Subscribe to Account Updates

```typescript theme={null}
const unsubscribe = sdk.ws.account(
  (event) => {
    const { marginAccountId, sequence, equity, freeCollateral, marginRatio, maintenanceRequirement, withdrawableCollateral } = event.data;
    console.log(`${marginAccountId} seq=${sequence} equity=${equity} free=${freeCollateral} ratio=${marginRatio} mmr=${maintenanceRequirement}`);
    // Absent means unknown: keep the last value rather than reading it as zero.
    if (withdrawableCollateral !== undefined) {
      console.log(`${marginAccountId} withdrawable=${withdrawableCollateral}`);
    }
  },
  // Subscribe-time snapshot: one row per margin account you own in THIS
  // application, ahead of the live frames (live delivery is per user and
  // wider, so do not treat an absent account as deleted). `sequence` is absent
  // on these rows, and any live frame supersedes one.
  (accounts) => {
    for (const account of accounts) {
      console.log(`baseline ${account.marginAccountId} equity=${account.equity} ratio=${account.marginRatio}`);
    }
  },
);

// Cleanup
unsubscribe();
```

### Account Snapshot

On subscribe, before any live frame, the server sends one row per margin account you own **in the application you authenticated under** — **including flat accounts**, whose live frames are rare: the producer's periodic frames cover only accounts holding an open position or a resting order reserve, and a flat account gets a live frame only when its collateral moves (see [Flat accounts](#flat-accounts)). Each row carries that account's current persisted health. An account that is flat **and otherwise empty** — no positions and no open risk bucket — reports its deposited collateral as 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

`initialMarginRequired`, `withdrawableCollateral`, and `realizedPnl` are the values the margin-account summary returns for the account's parent row, on live frames and snapshot rows alike. Every current frame and snapshot row carries all three. They are optional only so a frame from an older server still parses. Treat `undefined` as unknown and keep your last value, never as zero.

```typescript theme={null}
{
  eventType: "account_update";
  userId: string;
  data: {
    marginAccountId: string;          // margin account this update describes
    equity: string;                   // collateral + unrealized PnL + funding (decimal string)
    collateral: string;               // raw deposited collateral in USD (decimal string)
    freeCollateral: string;           // equity headroom for existing cross exposure (decimal string); NOT the withdrawal bound — that is withdrawableCollateral below
    marginRatio: string;              // maintenance requirement / equity (decimal string). "0" for an account with no open positions. Saturates to a maximum-distress sentinel (Decimal::MAX, ~7.9e28) whenever the ratio cannot be computed or represented — a maintenance requirement at zero/negative equity, or near-zero positive (dust) equity where the true ratio exceeds the decimal range — treat a value at/above the liquidation threshold as maximal distress and clamp it for display
    maintenanceRequirement: string;   // maintenance margin required to stay solvent (decimal string)
    initialMarginRequired?: string;   // initial margin your open positions and resting orders require (decimal string); placing or cancelling a resting order moves it
    withdrawableCollateral?: string;  // largest amount a withdrawal or transfer out of the margin account accepts (decimal string), floored at zero and rounded toward zero at 6 decimal places, so a transfer of exactly this amount is accepted
    realizedPnl?: string;             // realized PnL settled to the margin account plus net funding (decimal string); excludes realized PnL still held by an open risk bucket
    sequence?: number;                // per-account counter within one producer run, LIVE frames only: dedupe live frames by updatedAt (newest wins), sequence tie-breaks same-updatedAt frames. Resets to 0 on producer restart (socket may stay connected), MAY skip values — never a gap detector or cross-restart order. ABSENT on a subscribe-time snapshot row, which any live frame supersedes regardless of timestamps; treat absent as unknown, never as zero
    updatedAt: string;                // ISO 8601 timestamp (UTC)
  }
}
```

## TWAP Orders Channel

Stream live progress for your [TWAP parent orders](/trading-mechanics/twap-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

```typescript theme={null}
const unsubscribe = sdk.ws.twapOrders((event) => {
  const t = event.data;
  console.log(
    `${t.twapOrderId} ${t.state} ` +
    `executed=${t.executedQuantity}/${t.totalQuantity} ` +
    `slices=${t.slicesPlaced}/${t.plannedSlices} (${t.reason})`
  );
});

// Or scope to one market
const unsubPair = sdk.ws.twapOrders(handler, pair.id);

// Cleanup
unsubscribe();
```

### TWAP Order Event Structure

```typescript theme={null}
import type {
  OrderSide,
  TradingMode,
  TwapExecutionStyle,
  TwapOrderEventReason,
  TwapOrderState,
} from "@0xmonaco/types";

{
  eventType: "twap_order_update";
  userId: string;
  data: {
    twapOrderId: string;
    version?: number;              // opaque same-parent reconciliation revision; absent when unknown
    tradingPairId: string;
    side: OrderSide | (string & {});
    tradingMode: TradingMode | (string & {});
    totalQuantity: string;
    executedQuantity: string;
    executedNotional: string;
    averageFillPrice?: string;     // absent until the first fill
    plannedSlices: number;
    slicesPlaced: number;
    slicesSkipped: number;         // refusals, not slots — a PASSIVE slot can increment this AND slicesPlaced; skips never terminate the parent
    nextSliceAt?: string;
    state: TwapOrderState | (string & {});
    terminalReason?: string;       // e.g. end-of-window shortfall, or "trigger expired" (human-readable, not a parsing contract)
    executionStyle: TwapExecutionStyle | (string & {});
    triggerPrice?: string;         // conditional parents only
    triggerDirection?: "ABOVE" | "BELOW" | (string & {});
    conditionalExpiry?: string;    // deadline for the trigger to fire
    triggeredAt?: string;          // when the trigger fired
    reason: TwapOrderEventReason | (string & {});  // what triggered this frame — see Reasons below
    // plus config echoes (limitPrice, slippageToleranceBps, reduceOnly, margin context,
    // clientTwapId, randomize, startAt/endAt) and created/updated/cancelled/completed timestamps
  }
}
```

**Reasons:**

| `reason` | Meaning |
| - | - |
| `created` | The parent was accepted. |
| `slice_placed` | A slice reached the book as a taker — a `TAKER` slot that placed, plus a `PASSIVE` parent's deadline sweeps and crossing takes. A slot that placed nothing generally reports `slice_skipped` instead, so this is not every slot — with one exception: a child stopped by [self-trade prevention](/developers/trade#self-trade-prevention) under `CANCEL_TAKER` **or** `CANCEL_BOTH` comes back `CANCELLED` rather than rejected, and TWAP counts any non-`REJECTED` child as placed — so it increments `slicesPlaced`, not `slicesSkipped`. Its recorded fill is the child's actual `total_filled`, which is **not necessarily zero**: third-party fills made before the first self-touch stand, and only a self-touch before any fill leaves it at zero. |
| `slice_skipped` | A slice attempt was **refused** (no liquidity in band, parent limit through the market, halted market, failed admission). Never terminal — watch `slicesSkipped`. Note the counters are refusal and placement counts, not disjoint slot totals: a `PASSIVE` slot that already posted a maker child (counted in `slicesPlaced`, possibly with fills) and whose deadline sweep is then refused increments **both**, so `slicesPlaced + slicesSkipped` can exceed the slots consumed. |
| `passive_child_placed` | `PASSIVE` only: a post-only child now rests at the touch. |
| `child_repriced` | `PASSIVE` only: the reprice step ran. Usually the resting child moved atomically to the new touch — but the same reason is emitted by the cancel-only fallback, where the old child was cancelled and the repost did **not** happen, leaving no child resting and the slot open for the next attempt or the deadline sweep. The two cases are indistinguishable on this channel — `TwapOrderEventData` has no active-child field — so treat resting-child status as unknown here and track the child on the order channels instead. |
| `passive_fill` | `PASSIVE` only: maker fills on the resting child, folded into the parent — **except the fill that completes the parent**, which reports `completed` instead. A client waiting for a final `passive_fill` waits forever. |
| `triggered` | Conditional only: the trigger fired and the window armed. Read `startAt` / `endAt` / `triggeredAt` off this frame. |
| `trigger_expired` | Conditional only: `conditionalExpiry` passed with the trigger unfired. Terminal `CANCELLED`, nothing executed; the formatted detail `trigger expired` rides `terminalReason`. |
| `cancelled` | The owner cancelled. A `PASSIVE` parent's resting child is removed in the same step — earned fills stand, no final sweep. |
| `completed` | Terminal: the full quantity executed, or the window closed with the remainder reported as shortfall. Takes precedence over `passive_fill` when a maker fill is what completes the parent. |
| `snapshot` | Not a live mutation — the subscribe-time baseline row for an owned non-terminal (`PENDING`/`ACTIVE`) parent, delivered once through `onSnapshot`. `reason` is free-form on the wire, so `TwapOrderEventReason` narrows the live values above and `snapshot` arrives through the `(string & {})` widening. |

### 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:

```typescript theme={null}
sdk.ws.twapOrders(
  (event) => applyTwapUpdate(event.data),   // live diffs
  undefined,                                // all pairs (or pass a tradingPairId)
  (parents) => setTwapParents(parents),     // complete current state, once
);
```

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](#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](#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 `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`, so tracking the child here resolves the `child_repriced` ambiguity above. 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 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](#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 `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`. Those frames arrive only when the whole ADL episode ends, not as each step executes, and still carry the `version` of 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 with `getOrder`.

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.

## 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](#slow-client-disconnect-close-code-1013). 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. Each perp row of `marketStatsAll` also carries `indexPrice`, `fundingRate` (the estimated next-window rate; `currentFundingRate` is the last settled one), `premium`, and `openInterestNotional`: `openInterest` priced at the mark, falling back to the last trade price when there is no mark, in quote units. `openInterestNotional` is `undefined` (never `"0"`) until both open interest and a price are known.

Both accept an optional [`onSnapshot`](#subscribe-time-snapshots) 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

```typescript theme={null}
// Per-market: full stats for one trading pair
const unsubscribe = sdk.ws.marketStats(tradingPairId, (event) => {
  const { markPrice, openInterest, currentFundingRate, dailyPriceChangePercent } = event.data;
  console.log(`${event.tradingPairId} mark=${markPrice} OI=${openInterest} funding=${currentFundingRate} 24h%=${dailyPriceChangePercent}`);
});

// All markets: one summary row per market, per frame
const unsubscribeAll = sdk.ws.marketStatsAll((event) => {
  for (const m of event.markets) {
    console.log(`${m.symbol} ${m.marketStatus} last=${m.lastTradePrice} 24h%=${m.dailyPriceChangePercent}`);
  }
});

// Cleanup
unsubscribe();
unsubscribeAll();
```

### Market Stats Event Structures

```typescript theme={null}
import type { MarketStatus } from "@0xmonaco/types";

// Per-market (market_stats:<tradingPairId>)
{
  tradingPairId: string;
  data: {
    symbol: string;
    tradingPairId: string;
    indexPrice?: string;              // perp-only; omitted for spot
    markPrice?: string;               // perp-only; omitted for spot
    midPrice?: string;
    bestBidPrice?: string;
    bestAskPrice?: string;
    openInterest?: string;            // perp-only; gross (longs + shorts), base units; omitted for spot and until known
    openInterestLimit?: string;       // perp-only; USD notional OI cap, omitted when uncapped
    openInterestHeadroom?: string;    // perp-only; cap - gross OI x mark (>= 0), omitted when uncapped or until OI is known
    lastTradePrice?: string;
    dailyBaseTokenVolume?: string;
    dailyQuoteTokenVolume?: string;
    dailyPriceLow?: string;
    dailyPriceHigh?: string;
    dailyPriceChange?: string;
    dailyPriceChangePercent?: string;
    currentFundingRate?: string;      // perp-only; omitted for spot
    fundingRate?: string;             // perp-only; omitted for spot
    fundingTimestamp?: number;        // ms epoch; perp-only; omitted for spot
    premium?: string;                 // perp-only; omitted for spot
    baseInterestRate?: string;        // perp-only; omitted for spot
    fundingClampSmall?: string;       // perp-only; omitted for spot
    fundingClampBig?: string;         // perp-only; omitted for spot
    marketStatus?: MarketStatus | (string & {}); // same label as the all-markets row; always sent
    fundingIntervalSeconds?: number;  // whole seconds; perp-only; omitted for spot
    lastFundingTime?: number;         // ms epoch of the latest settlement; perp-only; omitted for spot and before the first settlement
  }
}

// All markets (market_stats)
{
  markets: Array<{
    tradingPairId: string;
    symbol: string;
    lastTradePrice?: string;
    markPrice?: string;               // perp-only; omitted for spot
    dailyPriceChange?: string;
    dailyPriceChangePercent?: string;
    dailyBaseTokenVolume?: string;
    dailyQuoteTokenVolume?: string;
    dailyPriceHigh?: string;
    dailyPriceLow?: string;
    openInterest?: string;            // perp-only; gross (longs + shorts), base units; omitted for spot and until known
    currentFundingRate?: string;      // perp-only; omitted for spot
    fundingTimestamp?: number;        // ms epoch; perp-only; omitted for spot
    marketStatus: MarketStatus | (string & {});
    indexPrice?: string;              // perp-only; omitted for spot
    fundingRate?: string;             // estimated next-window rate; perp-only; omitted for spot
    premium?: string;                 // perp-only; omitted for spot
    openInterestNotional?: string;    // gross, quote units; perp-only; omitted for spot and until open interest and a price are known
  }>;
}
```

## 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](#slow-client-disconnect-close-code-1013). 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 list of every listed market arrives **before** the live diffs, so a subscriber always starts from a complete baseline. It includes inactive markets flagged `isActive: false`, so positions, orders and history on a delisted market can still resolve its metadata; filter on `isActive` for the tradable set. Markets not yet launched are not included. How it is delivered depends on whether you pass [`onSnapshot`](#subscribe-time-snapshots): with it, the list arrives as one `InstrumentEventData[]`; without it, as one `listing` event per market through the ordinary handler. Prefer `onSnapshot` if you keep a market map — it is the only form that tells you where the baseline ends, which is what lets you replace the map after a reconnect: a market delisted while the socket was down arrives flagged `isActive: false`, and a market no longer listed drops out. A subscriber joining a channel this client already holds is replayed the current baseline from a local cache instead, through whichever form it registered (the server snapshots only genuinely new wire subscriptions).

Omit `tradingPairId` to receive every market, or pass one to filter.

### Subscribe to Instruments

```typescript theme={null}
// All markets
const unsubscribe = sdk.ws.instruments((event) => {
  const c = event.data;
  console.log(`${c.symbol} ${c.change} active=${c.isActive} tick=${c.tickSize} (${c.changedFields.join(", ")})`);
});

// Or scope to one market
const unsubPair = sdk.ws.instruments(handler, pair.id);

// With the baseline as one array, so the market map can be replaced wholesale
const unsubWithSnapshot = sdk.ws.instruments(handler, undefined, (markets) => {
  setMarketsByPair(Object.fromEntries(markets.map((market) => [market.tradingPairId, market])));
});

// Cleanup
unsubscribe();
```

### Instrument Event Structure

```typescript theme={null}
import type { InstrumentChangeKind, RiskTier } from "@0xmonaco/types";

{
  tradingPairId: string;
  data: {
    change: InstrumentChangeKind | (string & {});
    tradingPairId: string;
    symbol: string;                 // e.g. "BTC/USDC"
    isActive: boolean;              // whether the market is currently tradable
    tickSize: string;               // decimal string
    quantityStepSize: string;       // decimal string
    minOrderSize: string;           // decimal string
    maxOrderSize: string;           // decimal string
    marketType: string;             // e.g. "spot" or "margin"
    category: string;               // e.g. "crypto", "equities", "commodities"
    baseAssetId?: string;           // base asset UUID
    quoteAssetId?: string;          // quote asset UUID
    baseToken?: string;             // base ticker, e.g. "BTC"
    quoteToken?: string;            // quote ticker, e.g. "USDC"
    baseAssetName?: string;         // base asset display name
    quoteAssetName?: string;        // quote asset display name
    baseDecimals?: number;          // base asset decimals (integer)
    quoteDecimals?: number;         // quote asset decimals (integer)
    baseIconUrl?: string | null;    // base asset icon URL; null when none configured
    quoteIconUrl?: string | null;   // quote asset icon URL; null when none configured
    minLeverage?: string;           // perp-only; omitted for spot
    maxLeverage?: string;           // perp-only; omitted for spot
    initialMarginRate?: string;     // perp-only; omitted for spot
    maintenanceMarginRate?: string; // perp-only; omitted for spot
    riskTiers?: RiskTier[];         // perp-only margin ladder, ascending; omitted for spot
    changedFields: string[];        // snake_case wire names of the fields this mutation touched (empty on baseline frames)
    updatedAt: string;              // ISO 8601 timestamp (UTC)
  }
}
```

Perp frames carry the market's whole margin ladder as `riskTiers`, ascending by `tierLevel`, in the same `RiskTier` shape and values as the REST perp config's `riskTiers`; `maxLeverage`, `initialMarginRate` and `maintenanceMarginRate` are tier 1's values. A margin-ladder edit that changes any tier, or the position cap that bounds the top tier, emits a `config_change` whose `changedFields` includes `"risk_tiers"` (plus the three tier-1 fields when tier 1 changed); saving an unchanged ladder emits nothing, and neither does a fee edit. `riskTiers` is optional on the type because a server that predates it omits it. `baseIconUrl` and `quoteIconUrl` apply to every market and are always present (unlike the perp-only fields, which are omitted for spot), each `null` when that asset has no icon — join a position's `tradingPairId` to the instrument row to render both icons without a REST call. Every market's frame also carries its asset identity (`baseAssetId`, `quoteAssetId`, `baseToken`, `quoteToken`, `baseAssetName`, `quoteAssetName`, `baseDecimals`, `quoteDecimals`), so you can label a market and scale its amounts without parsing `symbol` or calling REST; perp frames add `minLeverage`. These fields are optional on the type because a server that predates them omits them. `halt` / `unhalt` are reserved for a future market-regime producer and are not emitted by admin trading-pair mutations. For the raw wire protocol (snake\_case keys, the `Snapshot` envelope), see the [WebSocket reference](/reference/websockets#instrument).

## Complete Example

```typescript theme={null}
import { MonacoSDK } from "@0xmonaco/core";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { seiTestnet } from "viem/chains";

async function setupRealtime() {
  const account = privateKeyToAccount("0x...");
  const walletClient = createWalletClient({
    account,
    chain: seiTestnet,
    transport: http()
  });

  const sdk = new MonacoSDK({
    walletClient,
    network: "staging",
    seiRpcUrl: "https://evm-rpc-testnet.sei-apis.com",
  });

  // Login for orders channel
  await sdk.login("your-client-id");

  // Get trading pair
  const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

  // Subscribe to all channels
  const unsubOrders = sdk.ws.orders(pair.id, "SPOT", (event) => {
    console.log("Order:", event.eventType, event.orderId);
  });

  const unsubOrderbook = sdk.ws.orderbook(pair.id, "SPOT", 1, "BASE", (event) => {
    console.log("Spread:", parseFloat(event.bestAsk) - parseFloat(event.bestBid));
  });

  const unsubOHLCV = sdk.ws.ohlcv(pair.id, "SPOT", "1m", (event) => {
    console.log("Price:", event.candlestick.c);
  });

  const unsubTrades = sdk.ws.trades(pair.id, (event) => {
    console.log("Trade:", event.data.price, event.data.quantity);
  });

  const unsubMovements = sdk.ws.movements((event) => {
    console.log("Movement:", event.data.transactionType, event.data.amount);
  });

  // Cleanup when done
  return () => {
    unsubOrders();
    unsubOrderbook();
    unsubOHLCV();
    unsubTrades();
    unsubMovements();
    sdk.ws.disconnect();
  };
}
```

## React Integration

```tsx theme={null}
import { useEffect, useState } from "react";
import { useMonacoSDK } from "@0xmonaco/react";
import type { OrderEvent } from "@0xmonaco/types";

function OrderFeed({ tradingPairId }: { tradingPairId: string }) {
  const { sdk } = useMonacoSDK();
  const [events, setEvents] = useState<OrderEvent[]>([]);

  useEffect(() => {
    if (!sdk) return;

    const unsubscribe = sdk.ws.orders(tradingPairId, "SPOT", (event) => {
      setEvents((prev) => [event, ...prev].slice(0, 50));
    });

    return () => unsubscribe();
  }, [sdk, tradingPairId]);

  return (
    <ul>
      {events.map((event, i) => (
        <li key={i}>
          {event.eventType}: {event.orderId}
        </li>
      ))}
    </ul>
  );
}
```

## Best Practices

### 1. Use Trading Pair IDs

All WebSocket channels use trading pair IDs (UUIDs), not symbols:

```typescript theme={null}
// Get the pair first
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");

// Use the UUID
sdk.ws.orders(pair.id, "SPOT", handler);
```

### 2. Always Clean Up

Store unsubscribe functions and call them when done:

```typescript theme={null}
const unsubscribe = sdk.ws.orders(pairId, "SPOT", handler);

// Later
unsubscribe();
```

### 3. Authenticate for Orders

The orders channel requires authentication:

```typescript theme={null}
await sdk.login(clientId);
const unsub = sdk.ws.orders(pairId, "SPOT", handler);
```

### 4. Handle Reconnection

`sdk.ws` reconnects automatically (unlimited retries by default). Poll `getStatus()` if you need to surface it:

```typescript theme={null}
setInterval(() => {
  if (sdk.ws.getStatus() !== "connected") {
    console.warn("Reconnecting...");
  }
}, 5000);
```

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](#reconnection-and-resync).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.