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

# Market-Maker Runbook

> Run an autonomous market maker on Monaco by pairing the MCP command/snapshot plane with the Core SDK WebSocket state plane.

An autonomous market maker manages resting quotes and open exposure continuously. Monaco exposes two planes for that job, and a safe market maker uses **both**:

* **Command / snapshot plane — the MCP server.** Placing, replacing, and cancelling orders, plus one-shot reads (`get_orders`, `get_positions`, `get_balances`, `get_orderbook`, market metadata). Every read is a point-in-time snapshot taken when you call it.
* **State plane — the Core SDK WebSocket.** Live streams for orders, orderbook, trades, balances, and movements. This is how you learn about fills, cancels, position and risk changes, and book movement *between* commands.

<Warning>
  The MCP server has **no streaming or subscription tools**. If you drive a market maker off intermittent MCP snapshots alone, you will miss fills, cancels, and book movement that happen between calls — leaving stale quotes and one-sided exposure. Always pair MCP commands with the Core SDK WebSocket state plane described here.
</Warning>

## The two planes

```
                 place / replace / cancel
   Market  ────────────────────────────────►  Monaco
   maker      MCP command plane (one-shot)     matching
    ▲                                           engine
    │        orders · orderbook · trades          │
    └──────────────────────────────────────────────┘
            Core SDK WebSocket state plane (live)
```

| Concern | Plane | How |
| - | - | - |
| Place / replace / cancel a quote | Command | MCP `place_limit_order`, `replace_order`, `cancel_order` (or SDK `sdk.trading.*`) |
| Close or flatten a position | Command | MCP `close_position`, `batch_close_all_positions` (or SDK `sdk.positions.*`) |
| One-shot snapshot of orders / positions / balances / book | Snapshot | MCP `get_orders`, `get_positions`, `get_balances`, `get_orderbook` (or SDK reads) |
| Fills, cancels, order-state changes (live) | State | `ws.orders(pairId, mode, handler)` / `ws.userOrders(handler)` |
| Book movement (live) | State | `ws.orderbook(pairId, mode, magnitude, quotationMode, handler)` |
| Trade prints (live) | State | `ws.trades(pairId, handler)` |
| Balance and margin-account movement (live) | State | `ws.balances(handler)`, `ws.movements(handler)` |

The rule of thumb: **command the exchange through MCP, but track its state through the WebSocket.** Never poll MCP reads in a tight loop to discover fills — subscribe instead. Authenticated reads draw a per-account [read budget](/developers/rate-limits#reads) (30 requests/s sustained, burst 100 by default), so a polling loop is metered as well as slow; stream events cost no read budget.

## Before you start

* Authenticate with a Monaco session key. Monaco is tokenless: your wallet authorizes an ed25519 session keypair, and every request is signed with the session key. See [Wallets & Auth](/developers/accounts/wallets).
* For the MCP server, run authenticated mode with `MONACO_CAPABILITY=market-maker` so order placement, replacement, and cancellation tools are registered. See [MCP Server](/sdk/mcp-server#capability-profiles).
* Start on `staging` (testnet). Fund a fresh wallet through the [faucet](/developers/faucet).
* Running in AWS? [PrivateLink](/developers/privatelink) connects your VPC privately to the gRPC trading API and WebSocket feeds, with 4× the public order budgets.
* Resolve the trading pair ID and market constraints (tick size, order-size limits) up front — see [Markets](/developers/markets) and [Orderbooks & Trades](/developers/orderbooks-trades).
* Know your order budgets. Creation and cancels are metered per account in order items — a batch of N costs N, cancels draw a larger budget of their own, and sub-accounts share a family budget with their master — and every gated response returns `x-ratelimit-*` headers to pace on (readable from a raw HTTP or gRPC client; the TypeScript SDK surfaces only `retryAfter` on a `429`). Quote with batch create and replace, cancel with batch cancel, and back off for `retryAfter` on a `429`. Reads have a separate per-account budget with no headers, another reason to track state over the WebSocket. See [Rate Limits](/developers/rate-limits).

The runnable examples below use the TypeScript SDK for both planes, because it is directly executable. Each command step notes the equivalent MCP tool an assistant would call.

## 1. Bootstrap

Authenticate, resolve the market, and take an initial snapshot of your open orders, positions, and balances. This snapshot is your starting truth; the state plane keeps it current from here.

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

const NETWORK = "staging";
const account = privateKeyToAccount(process.env.MM_PRIVATE_KEY as `0x${string}`);
const walletClient = createWalletClient({ account, chain: seiTestnet, transport: http() });

const sdk = new MonacoSDK({
  walletClient,
  network: NETWORK,
  seiRpcUrl: "https://evm-rpc-testnet.sei-apis.com", // use a private provider in production
});

// Session-key auth: the wallet authorizes an ed25519 session keypair.
const authState = await sdk.login(process.env.MONACO_CLIENT_ID as string);

// Resolve the market you will quote (perps use MARGIN mode).
const pair = await sdk.market.getTradingPairBySymbol("BTC/USDC");
if (!pair) throw new Error("BTC/USDC not found"); // getTradingPairBySymbol returns undefined for an unknown symbol
const mode = "MARGIN"; // "SPOT" for spot markets

// Initial snapshot — equivalent MCP tools: get_orders, get_positions, get_balances.
// Resting orders are SUBMITTED or PARTIALLY_FILLED; an array matches any of them.
const openOrders = await sdk.trading.getPaginatedOrders({ status: ["SUBMITTED", "PARTIALLY_FILLED"] });
const positions = await sdk.positions.listPositions();
const balances = await sdk.profile.getUserBalances();

// Seed your local view keyed by id from the paginated snapshot. The state
// plane (step 2) keeps this view current after the initial seed — there is no
// separate live-cache set to merge in. On a later reconnect you merge a fresh
// snapshot into this map on `(id, version)` rather than replacing it (step 4),
// so the stream never has to pause.
const orderState = new Map(openOrders.orders.map((o) => [o.id, o]));
```

## 2. Subscribe to the state plane

Construct a WebSocket client with `createMonacoWebSocket` and subscribe to the channels a market maker needs. Safe recovery needs the `onResync` and `onStatusChange` callbacks (see [step 4](#4-reconnect-and-resync)). The built-in `sdk.ws` takes them too, through the SDK's `ws` config (`new MonacoSDK({ ..., ws: { onStatusChange, onResync } })`); the example below constructs the client directly and passes the session from `login()` itself.

Pull the session credentials from the auth state returned by `login()`, and resolve the WebSocket URL from the network preset:

```typescript theme={null}
const session = {
  publicKey: authState.sessionPublicKey,
  privateKey: authState.sessionPrivateKey,
};

// When the socket dropped. Step 4's gap recovery pages the trade tape back
// only this far, so it has to be recorded where the drop is observed — by the
// time `onResync` fires, the reconnect has already happened. Record only the
// first status after "connected": an outage reports "reconnecting" once per
// retry, and a fresh session after session loss reports "connecting", so
// stamping every non-connected status would move the gap start forward.
let disconnectedAt = new Date();
let live = false;

const ws = createMonacoWebSocket(resolveWsUrl(NETWORK), {
  session,
  onStatusChange: (status) => {
    if (status === "connected") {
      live = true;
    } else if (live) {
      live = false;
      disconnectedAt = new Date();
    }
    console.log("ws status:", status);
  },
  onResync: async ({ code, slowClient }) => {
    await resync(code, slowClient, disconnectedAt); // defined in step 4
  },
});

await ws.connect();

// State plane: consume fills, cancels, and order-state changes here — do NOT
// poll get_orders to find out what filled.
//
// Apply events as they arrive, including across a reconnect: each one carries
// the `version` that step 4's snapshot merge compares against, so there is no
// window in which the stream has to be paused or buffered.
const unsubOrders = ws.orders(pair.id, mode, (event) => {
  // OrderPlaced / OrderMatched / OrderFilled / OrderPartiallyFilled /
  // OrderCancelled / OrderRejected / OrderExpired
  applyOrderEvent(event);
});

const unsubBook = ws.orderbook(pair.id, mode, 1, "BASE", (event) => {
  onBookMove(event.bestBid, event.bestAsk);
});

const unsubTrades = ws.trades(pair.id, (event) => {
  onTrade(event.data.price, event.data.quantity, event.data.makerSide);
});

const unsubBalances = ws.balances((event) => onBalance(event.data));
const unsubMovements = ws.movements((event) => onMovement(event.data));

// Live position state — size, mark price, unrealized PnL, liquidation price.
// Omit the pair to stream every pair, or pass pair.id to filter.
const unsubPositions = ws.positions((event) => onPosition(event.data));

// Liquidation alerts — your account crossed a maintenance threshold.
const unsubLiquidations = ws.liquidations((event) => onLiquidation(event.data));
```

Each subscription method returns an unsubscribe function. `ws.orders` and `ws.userOrders` (all pairs), `ws.balances`, `ws.movements`, `ws.positions`, and `ws.liquidations` require authentication; `ws.orderbook` and `ws.trades` are public. `ws.positions` and `ws.liquidations` take an optional trailing `tradingPairId` to filter to one pair. Full event shapes are documented in [SDK WebSockets](/sdk/typescript/websockets).

`magnitude` (price grouping) must be one of `0.0001, 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000`, and `quotationMode` is `"BASE"` or `"QUOTE"`.

## 3. Quote lifecycle

Send orders through the command plane; learn their outcome through the state plane.

```typescript theme={null}
// Place a resting quote — MCP: place_limit_order.
const bid = await sdk.trading.placeLimitOrder(pair.id, "BUY", "0.10", "60000", {
  tradingMode: mode,
  timeInForce: "GTC",
});

// Re-price an existing quote in place — MCP: replace_order.
await sdk.trading.replaceOrder(bid.orderId, { price: "60050" });

// Pull a quote — MCP: cancel_order.
await sdk.trading.cancelOrder(bid.orderId);
```

If you quote `postOnly: true`, the state plane confirms it: `data.postOnly` is `true` on that order's `OrderPlaced` acknowledgement and stays on **its maker fills** and on its `OrderPartiallyCancelled`, `OrderCancelled` and `OrderExpired`, so a matching event saves you a `get_order` round trip. The maker fill is the one that matters: a resting post-only quote only ever fills as a maker, so those are the frames a post-only quote actually produces. Still record the flag as order-level state when the acknowledgement arrives and carry it forward rather than re-deriving it per event — two frames legitimately omit it. The `OrderCancelled` that reports the **remainder of an IOC or market order** after a partial fill is a different payload that carries no `postOnly` at all (a post-only quote can never reach it, since post-only is refused on IOC/FOK and MARKET), and a server predating maker-fill support omits the key on maker fills. Against those, the acknowledgement's value stays the authority; everywhere else `data.postOnly ?? false` agrees with `get_order`.

A post-only quote that *would* have crossed is refused at admission, and that refusal reaches you as an **error on the command plane** — that is the authoritative signal. A missing acknowledgement is not itself evidence of rejection: silence proves nothing here for the same reason it proves nothing for `clientOrderId` below, so reconcile against the command result rather than inferring refusal from an absent frame.

Do **not** call `get_orders` on a timer to discover whether `bid` filled. The `ws.orders` handler from step 2 delivers `OrderFilled` / `OrderPartiallyFilled` / `OrderCancelled` as they happen, and `ws.balances` reports the resulting balance changes. `ws.movements` is not part of that loop: it carries deposits, withdrawals, funding settlements and the unlock a cancelled or expired order releases, never fills or fees — read a fill's ledger entries from movement history instead. Reprice off `ws.orderbook` and `ws.trades`, and re-evaluate exposure from the live order and balance streams — not from repeated snapshots.

For perps, position and risk-bucket changes follow from fills. Track them live off the `ws.positions` stream from step 2 — each `position_update` carries the current size, mark price, unrealized PnL, and liquidation price — and treat `ws.liquidations` alerts as a hard risk signal. Read current risk with a one-shot `sdk.positions.getPositionRisk(positionId)` (MCP `get_position_risk`) when you need a full risk breakdown, but drive quote adjustments off the live streams so your view never lags the exchange.

### Self-trade prevention

Your own taker flow can cross your own quotes — a hedge leg into your resting bid, or two of your margin sub-accounts quoting one book. The incoming order's self-trade-prevention mode decides what happens. Four modes exist (`CANCEL_MAKER`, `CANCEL_TAKER`, `CANCEL_BOTH`, `SKIP`), and the platform default is `CANCEL_MAKER`: **your resting quote is swept** — cancelled, not filled — and your incoming order keeps matching through the level. A maker that wants its quotes to survive its own flow opts the wallet into `SKIP` (trade around your own orders; they are never touched) or `CANCEL_TAKER` (give up the incoming remainder instead):

```typescript theme={null}
// Wallet-wide default — covers every application and margin sub-account on
// this wallet. No MCP tool for this; use the SDK or raw REST:
// GET/PUT /api/v1/accounts/self-trade-prevention.
await sdk.profile.setSelfTradePreventionDefault("SKIP");
```

A delegated-agent session cannot change the default (`403`) — set it from the wallet's own session. A per-order `selfTradePreventionMode` on any create, replace, or batch item overrides the wallet default for that order; a replacement is a new order, so restate the field to keep it.

STP cancellations reach the state plane as ordinary `OrderCancelled` events with `terminalReason: "SELF_TRADE_PREVENTION"` and `stpCounterpartyOrderId` naming the order on the other side. Handle them in the same terminal path as any other cancel; under `CANCEL_MAKER`, one means your own flow swept your quote — re-quote, and if it recurs, change the mode rather than fighting it. Two cases where your quote is **not** swept, both easy to misread from the taker's side. A `postOnly` order that would cross is refused at admission by an ownership-blind check that runs *before* self-trade prevention, so it sweeps nothing. And a zero-fill `MARKET` taker whose only in-band depth was your own quote is rejected for insufficient liquidity **before the sequence is allocated** — the cancellation was only simulated, so your quote is still resting and still exposed. In both cases a rejected taker is not evidence that your quote was pulled: reconcile against the `orders` channel rather than inferring it.

Mode-by-mode outcomes, FOK edges, and the liquidation carve-out: [Self-Trade Prevention](/trading-mechanics/self-trade-prevention).

## 4. Reconnect and resync

The WebSocket auto-reconnects with full-jitter exponential backoff (base 1s, capped at 30s) and unlimited retries by default. There is **no wire sequence number** on the streams, so after any reconnect you must assume you missed events and rebuild from a fresh snapshot.

`onResync` fires after every automatic reconnect, after the client sends authentication/subscription requests. It does not wait for acknowledgements or snapshots, and REST recovery remains application-owned. Refetch your snapshots and reconcile them against your local view.

### Reconcile on `(id, version)`

Every order event and every order REST row carries `version`: the sequencer step that wrote that state. It is the same counter on both, so a snapshot row and a live event are directly comparable, and you never have to pause the stream to rebuild.

Apply events as they arrive. When the snapshot lands, merge per order:

```typescript theme={null}
// snapshot.version >= local.version  →  take the snapshot
// otherwise                          →  keep local
```

<Warning>
  The rule is **`>=`, not `>`** — and that is not a rounding choice.

  Equal versions do **not** mean identical state on the stream. One sequencer step emits several events for one order: placing a marketable order emits `OrderPlaced` (status `SUBMITTED`) and then its fills, all under the same step. A batch place or replace goes further and shares **one** step across every item in the batch.

  A REST row, by contrast, is that step's **final** state. So when the versions tie, the snapshot is the more complete view and must win. Using `>` instead would let an `OrderPlaced` you happened to apply outrank the snapshot that says the same order already filled.
</Warning>

Nothing here needs buffering, so you keep quoting through a reconnect. That is the point: the snapshot merges into a live view instead of replacing it.

```typescript theme={null}
async function resync(code: number, slowClient: boolean, disconnectedAt: Date) {
  // slowClient === true means the server shed the connection with close code
  // 1013: its broadcast receiver missed events. This closes every subscription
  // on that socket, including positions and other absolute-state channels;
  // a dropped key or terminal update may never have a later replacement.
  console.warn(`resync after close ${code}${slowClient ? " (slow-client gap)" : ""}`);

  const [orders, positions, balances] = await Promise.all([
    sdk.trading.getPaginatedOrders({ status: ["SUBMITTED", "PARTIALLY_FILLED"] }),
    sdk.positions.listPositions(),
    sdk.profile.getUserBalances(),
  ]);

  // Merge, do not replace: events kept arriving while this was in flight, and
  // for those orders local state is the newer of the two.
  const snapshotIds = new Set<string>();
  for (const order of orders.orders) {
    snapshotIds.add(order.id);
    const local = orderState.get(order.id);
    if (takeSnapshot(order, local)) {
      orderState.set(order.id, order);
    }
  }

  // The resting snapshot cannot show what stopped resting during the gap —
  // see the next section for both halves of that. Both are required: the
  // snapshot frame and this merge do NOT close a reconnect gap on their own.
  const gapTrades = await recoverGapFills(disconnectedAt);
  // UserTrade carries tradeId, not orderId. Merge execution history by tradeId;
  // the missing-resting-order pass below repairs each terminal order itself.
  reconcileTrades(gapTrades);
  await recoverOrdersThatLeftTheBook(snapshotIds);

  reconcilePositions(positions);
  reconcileBalances(balances);
}

// Orders neither side can rank, collected for the fallback below.
const needsReplay = new Set<string>();

// Absent means unknown, not zero: treating it as zero would make a legacy row
// look older than everything. So rather than compare and guess, decide per
// side which one is rankable.
function takeSnapshot(snapshot: Order, local?: Order): boolean {
  // Nothing local, or local was seeded without a version (a pre-feature order,
  // or an event that carried none). A comparison against `undefined` can never
  // be true, so comparing here would freeze that order out of every future
  // resync — silently, and permanently. REST is the only rankable side: take it.
  if (!local || local.version === undefined) return true;
  // The snapshot has no version but local does: this one genuinely cannot be
  // ordered, and taking the older-looking side could revert newer stream
  // state. Hand it to the buffer-and-replay fallback instead of guessing.
  if (snapshot.version === undefined) {
    needsReplay.add(snapshot.id);
    return false;
  }
  return snapshot.version >= local.version;
}

// An order still eligible to rest on the book. Anything else has reached a
// terminal state and cannot come back, so absence from the snapshot says
// nothing about it.
function isResting(order: Order): boolean {
  return order.status === "SUBMITTED" || order.status === "PARTIALLY_FILLED";
}
```

### Catch what went terminal during the gap

An order that filled or cancelled **while the socket was down** is the dangerous case: its event was never delivered, and a snapshot filtered to resting statuses does not contain it either. Nothing corrects your local view, so you keep quoting against a position you no longer have — or keep believing in a quote that already filled.

Widening the order query does **not** reliably fix this. `getPaginatedOrders` has no time filter and is ordered by `createdAt` descending, so a quote you placed hours ago that filled ten seconds ago sorts by its *old* creation time — far down the pages, not near the top. Paginating until you find it means walking your whole order history on every reconnect.

Two bounded checks cover it instead:

**1. Fills — read the trade tape.** `sdk.profile.getUserTrades` is ordered by execution time, newest first, with cursor pagination. A fill from the gap is at the top of it whatever the order's age, so you page back only as far as the disconnect.

```typescript theme={null}
// Every fill you missed, bounded by the gap rather than by history depth.
async function recoverGapFills(disconnectedAt: Date) {
const missed = [];
let cursor: string | undefined;
outer: while (true) {
  const page = await sdk.profile.getUserTrades({ pageToken: cursor, pageSize: 100 });
  for (const trade of page.trades) {
    if (new Date(trade.executedAt) < disconnectedAt) break outer;
    missed.push(trade);
  }
  if (!page.nextPageToken) break;
  cursor = page.nextPageToken;
}
return missed;
}
```

User trade rows do not carry an order ID. Merge the recovered executions into your trade history by `tradeId`; the missing-resting-order pass below refreshes the authoritative order state, including orders that filled during the gap.

**2. Cancels and expiries — resolve snapshot absence.** A locally resting order missing from the persisted snapshot may have left the book, or persistence may lag a newer local event. Absence alone is not a tombstone. Retain the row and resolve it with a targeted `getOrder(id)`, applying the same version rule rather than deleting it or scanning history:

```typescript theme={null}
async function recoverOrdersThatLeftTheBook(snapshotIds: Set<string>) {
for (const [id, local] of orderState) {
  if (!isResting(local)) continue;
  if (snapshotIds.has(id)) continue;
  // Absence can mean termination or persistence lag. Compare against the
  // current local row AFTER awaiting: a newer event may arrive during the read.
  const order = await sdk.trading.getOrder(id);
  if (takeSnapshot(order, orderState.get(id))) {
    orderState.set(id, order);
  }
}
}
```

Both are bounded by how much actually happened, not by how long you have been trading. Reconcile positions and balances alongside them, and treat every resync as authoritative: re-derive your intended quotes from the reconciled state rather than assuming your pre-disconnect orders are still resting.

<Note>
  An order rejected *before* acceptance is never written to history, so it has no row to fetch and no trade to find. It also never rested, so it cannot be in local resting state — nothing to reconcile. A rejection *after* acceptance is persisted with status `REJECTED` and is covered by the absence check above.
</Note>

### When `version` is absent: buffer and replay

An order last written before `version` existed reports none, and `takeSnapshot` above deliberately refuses to guess for those — it collects them in `needsReplay` rather than ranking them. If you need to reconcile those orders — pre-feature rows, or a consumer running against an older deployment — fall back to ordering by arrival instead of by comparison:

1. **Start buffering** order events before the fetch begins (the handler is installed at subscribe time, so it is already live).
2. **Fetch** the snapshot.
3. **Replace** local state for those orders from the snapshot.
4. **Replay** the buffered events in arrival order.
5. **Resume** applying live events directly.

Order events carry absolute state — `filledQuantity` is cumulative — so replaying an event the snapshot already reflects rewrites the same value rather than double-counting it. Two costs come with it, and they are why this is the fallback rather than the default: local state is meaningless mid-replay (an event that predates the snapshot moves an order backwards until the rest of its buffer drains, so you must hold quoting until it finishes), and the buffer has to be switched on before the fetch starts or it misses precisely the window it exists to cover.

### Comparing two REST reads

The same counter answers a narrower question directly: whether a re-poll actually advanced, or which of `getOrder` and a list row is fresher. Highest wins, and here equal really does mean identical state — a REST row is only ever a step's final state, so two equal REST reads describe the same thing.

**Do not compare `updatedAt` for this.** Order detail (`getOrder`) and order lists (`getPaginatedOrders`) report `updatedAt` from two different clocks — the matching-engine event time and the database commit time — so the two endpoints disagree about the same order by a few hundred milliseconds, the gap widens under write lag rather than closing, and the commit clock can move without any state change. Timestamp precision also varies per call. `version` exists precisely because `updatedAt` cannot answer this question.

Two rules for `version` everywhere it appears:

* **Absent means unknown**, not zero: an order last written before the field existed reports no `version`. Fall back to a full field comparison for those rows.
* **Gaps are expected.** It is drawn from one global sequence shared by every order, so an order's successive versions are sparse (`918342` → `918511` is normal), consecutive values are never implied, and values from two different orders are not comparable to each other — a batch's orders all share one value. It is a comparator, not a counter.

## 5. Mutation safety

Single and batch order creation are replay-safe only when you retain each explicit `idempotencyKey` and reuse it with the same normalized payload within 24 hours of acceptance. Generate and persist keys before the first send; a new key is a new submission, and an expired key may place a new order. Cancellation converges safely. Replacement is not protected by an idempotency key, although a retry cannot place twice because it names an original order that the sequencer consumes only once.

For a keyed create or batch item, replay the exact payload with the same key within the window. Never blind-retry an unkeyed or expired create, or an ambiguous replace. First reconcile. Every surface below is **positive-only**: it can prove the intent landed, never that it did not. Ranked strongest first:

1. **`clientOrderId` on the order stream** — set it on every create and replace (`CancelOrderRequest` takes only `orderId`, so you cannot *set* a handle on a cancel — but the resulting `OrderCancelled` event still carries the order's stored `clientOrderId`, so cancellation is usable reconciliation evidence). It is echoed on every order WebSocket event, including on orders that never rested, which makes the stream the strongest surface you have. A matching event proves the order was accepted. **Silence proves nothing** and no condition you can observe changes that: the stream is not replayable and its fan-out is non-persistent, so the service can re-establish an ended upstream subscription without closing your socket. Two further limits: uniqueness is enforced only among your **resting** orders, so a `409 CLIENT_ORDER_ID_CONFLICT` on resubmission proves the first attempt landed *and is still resting* — its absence proves nothing; and because the value frees on terminal and may be reused, a lookup can match several historical orders.
2. **Exchange `orderId`** — for `replace_order` you already hold it, so `get_order` can confirm a *positive* outcome: `terminalReason: "REPLACED"` on the original means your replacement landed and you need its new id. A still-resting original is **inconclusive**, not proof the replace failed — order persistence is asynchronous, so that row can predate a replacement the engine has already applied. Note also that a replace retry cannot double your position: it names the original id, which the sequencer revalidates, so a second attempt is rejected rather than placed. The risk here is acting on stale state, not duplicate size.
3. **`strategyKey`**: not a reconciliation key. It is a routing label that groups risk buckets for delegated and margin orders, echoed on the create response and on TWAP orders and events; `get_order` rows and the `get_orders` list do not carry it, and the list endpoint does not filter by it. Match client-side by `clientOrderId` instead.
4. **Attributes + a narrow time window** — as a last resort, match by pair, side, price, and quantity among orders submitted in the last few seconds. This is the weakest key: identical resting quotes can collide, so treat a match as provisional.

Include already-filled orders in the check, and confirm against live `ws.orders` before deciding. For submissions without usable idempotency protection, every surface is positive-only, so "reconciliation proves the intent did not land" is a bar you frequently **cannot** clear — when you cannot, hold and escalate rather than resubmitting. A blind retry on an unkeyed or expired create can double your intended size and leave you over-exposed; the missed quote you get by holding is recoverable, the doubled position may not be.

<Warning>
  Do not reconcile a taking order by polling the order list alone. Order history is written asynchronously and served from a read replica, so an order that did execute can read as absent — most likely right after a timeout, since a timeout usually means the system is busy. An order rejected *before* acceptance is never written to history at all; one rejected *after* acceptance is persisted with status `REJECTED`. Use the order stream, which is pushed from the matching engine: `sdk.ws.userOrders(handler)` covers every pair, `sdk.ws.orders(pairId, mode, handler)` one market.
</Warning>

<Note>
  MCP and the TypeScript SDK generate create keys when omitted, but each new call generates a new key. Supply and retain an explicit key when recovery must cross calls or process restarts. If the original key is unavailable or expired, follow the same no-blind-retry contract on every surface.
</Note>

## 6. Emergency stop

Keep a kill switch that flattens everything in two commands: cancel all resting orders, then close all open positions.

These two commands are **not atomic**: a resting order can fill in the window between cancelling and closing, and a close can be partially filled. Treat the kill switch as a loop, not a one-shot — keep consuming `ws.orders` and `ws.movements`, re-check open orders and positions, and repeat until your flatness criteria hold.

```typescript theme={null}
// Cancel every resting order — MCP: batch_cancel_all_orders.
// Pass a tradingPairId to scope it to one market, or omit for all markets.
await sdk.trading.batchCancelAll();

// Close every open position — MCP: batch_close_all_positions.
await sdk.positions.batchCloseAllPositions();
```

Wire this to your risk limits (max drawdown, connectivity loss you cannot recover, or a manual halt). After an emergency stop, confirm the result from the state plane — the `ws.orders` cancellations and `ws.movements` / position updates — before deciding the maker is flat.

## Putting it together

The loop is: **bootstrap** a snapshot → **subscribe** to the state plane → **quote** through the command plane while consuming fills and book movement from the state plane → **resync** from a fresh snapshot on every reconnect → **emergency stop** when risk limits trip. Commands go out through MCP (or the SDK); truth comes back through the WebSocket.

When you are done, unsubscribe and disconnect:

```typescript theme={null}
unsubOrders();
unsubBook();
unsubTrades();
unsubBalances();
unsubMovements();
unsubPositions();
unsubLiquidations();
ws.disconnect();
await sdk.logout(); // revokes the session key
```

## Related

<CardGroup cols={2}>
  <Card title="MCP Server" icon="plug" href="/sdk/mcp-server">
    The command/snapshot plane: tools, capability profiles, and setup.
  </Card>

  <Card title="SDK WebSockets" icon="satellite-dish" href="/sdk/typescript/websockets">
    The state plane: every channel, event shape, and the resync contract.
  </Card>

  <Card title="Order Management" icon="list-checks" href="/developers/trade">
    Spot and perps order entry, batching, and conditional orders.
  </Card>

  <Card title="Positions" icon="scale" href="/developers/positions">
    Perps positions, margin accounts, and risk buckets.
  </Card>

  <Card title="PrivateLink" icon="network-wired" href="/developers/privatelink">
    Private gRPC trading and WebSocket feeds from your AWS VPC.
  </Card>

  <Card title="Rate Limits" icon="gauge-high" href="/developers/rate-limits">
    Order and movement budgets, budget headers, and how to back off.
  </Card>
</CardGroup>


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