- 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.
The two planes
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 (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.
- For the MCP server, run authenticated mode with
MONACO_CAPABILITY=market-makerso order placement, replacement, and cancellation tools are registered. See MCP Server. - Start on
staging(testnet). Fund a fresh wallet through the faucet. - Resolve the trading pair ID and market constraints (tick size, order-size limits) up front — see Markets and 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 onlyretryAfteron a429). Quote with batch create and replace, cancel with batch cancel, and back off forretryAfteron a429. Reads have a separate per-account budget with no headers, another reason to track state over the WebSocket. See Rate Limits.
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.2. Subscribe to the state plane
Construct a WebSocket client withcreateMonacoWebSocket and subscribe to the channels a market maker needs. Construct it directly rather than using the built-in sdk.ws: the SDK’s default client does not forward the onResync and onStatusChange callbacks you need for safe recovery (see step 4).
Pull the session credentials from the auth state returned by login(), and resolve the WebSocket URL from the network preset:
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.
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.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 subaccounts 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):
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.
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:
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.
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:
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.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:
- Start buffering order events before the fetch begins (the handler is installed at subscribe time, so it is already live).
- Fetch the snapshot.
- Replace local state for those orders from the snapshot.
- Replay the buffered events in arrival order.
- Resume applying live events directly.
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 ofgetOrder 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→918511is 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 explicitidempotencyKey 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:
clientOrderIdon the order stream — set it on every create and replace (CancelOrderRequesttakes onlyorderId, so you cannot set a handle on a cancel — but the resultingOrderCancelledevent still carries the order’s storedclientOrderId, 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 a409 CLIENT_ORDER_ID_CONFLICTon 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.- Exchange
orderId— forreplace_orderyou already hold it, soget_ordercan 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. strategyKey— also echoed onget_ordersrows at the wire layer, so you can match client-side by it (the order-list endpoint does not server-filter bystrategyKey). Note the ergonomic TypeScript SDKOrdertype does not surfacestrategyKey, so read it from the raw wire row rather thanorder.strategyKey.- 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.
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.
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.
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 consumingws.orders and ws.movements, re-check open orders and positions, and repeat until your flatness criteria hold.
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:Related
MCP Server
The command/snapshot plane: tools, capability profiles, and setup.
SDK WebSockets
The state plane: every channel, event shape, and the resync contract.
Order Management
Spot and perps order entry, batching, and conditional orders.
Positions
Perps positions, margin accounts, and risk buckets.
Rate Limits
Order and movement budgets, budget headers, and how to back off.

