Perpetuals Kronos X v2.0 “Pacifica” Member API

WebSocket API

Two sockets carry the whole venue: /ws/public for market data and /ws/private for account data, order entry and synchronous reads. The private socket is a complete trading transport on its own, and pipelined order entry over it is the single largest throughput lever available to a DMA integration. This page is the wire contract: envelopes, operations, channels, delivery guarantees and the mandatory recovery procedure.

Sockets

EnvironmentWebSocket base
Sandboxwss://sandbox.mtf.perpetuals.net
Productionwss://production.mtf.perpetuals.com
One entry host per environment (revision 3.83). The sandbox is sandbox.mtf.perpetuals.net, three nodes behind that one entry host; it replaces the single-node sandbox.mtf.perpetuals.com from the date venue operations announce. The entry host sends new connections to the node that accepts writes, the leader; never pin a node. A leader change therefore happens on the sandbox as it can on production (see Leader change below).
WS/ws/public

Market data and public reads. No authentication, no member content. Use it when a process only needs prices: a market-data recorder, a pricing service, a risk view fed from public prints. A public socket never sees an order, an execution or a position.

WS/ws/private

A strict superset. After auth it carries your order lifecycle, executions, positions, wallet, margin and risk events, accepts order entry, and answers every live-state read that REST answers. Use it for anything that trades. Running market data on a second, separate public socket is the recommended topology: a burst of book updates then cannot compete with your fills for the same connection budget.

TLS only, text frames, JSON. Both paths exist on both hosts.

Scope. Credentials carry a scope. A READ_ONLY (drop copy) key may connect, authenticate, subscribe to every private channel and run every read, but every write is refused with code 1005: order.place, order.cancel, order.cancelAll, order.amend and marketSegments.set. The check is re-read from the credential registry on every request, so a scope change takes effect on a live session.

Message envelopes

Every client message is one JSON object with an op, your own correlation id, and an args array:

{ "op": "subscribe", "reqId": "s1", "args": ["orderbook.25.BTC-USD-PERP", "trades.BTC-USD-PERP"] }

Every request receives exactly one reply, echoing op and reqId:

{ "op": "subscribe", "reqId": "s1", "success": true, "code": 0, "msg": "ok" }

Read replies carry the engine sequence number the answer was taken at, exactly like the REST envelope, plus a server timestamp:

{ "op": "get.positions", "reqId": "q7", "success": true, "code": 0, "msg": "ok",
  "data": [ { "symbol": "BTC-USD-PERP", "netQty": "12", "avgEntry": "64980.25" } ],
  "asOfSeq": 904118, "ts": 1785312041205 }

Streamed frames carry a channel. Private channels also carry asOfSeq and eventIndex:

{ "channel": "order", "type": "OrderAccepted",
  "data": { "orderId": 887711, "clOrdId": "a3f9-0001", "symbol": "BTC-USD-PERP", "side": "BUY",
            "price": "64000", "qty": "5", "remainingQty": "5", "status": "NEW",
            "clientUid": "CLIENT-A", "acceptedTs": 1785312041188 },
  "asOfSeq": 904118, "eventIndex": 0 }

Wire rules

The pong is not an ack. {"op":"ping"} is answered with {"op":"pong","reqId":"...","ts":...}. There is no success member. A client that waits for success === true on its keepalive will conclude the socket is dead and reconnect in a loop.

Authentication

Since Member API revision 3.85 the login signs a server challenge, and it is mandatory. The FIRST frame the venue sends on every /ws/private connection, before anything else, is its challenge:

{ "channel": "challenge", "type": "challenge", "nonce": "5f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a", "ts": 1700000000000 }

nonce is 32 lowercase hex characters (128 random bits) drawn for this connection; ts is the venue clock. Read it, keep the nonce, then send the auth op with the nonce as the fourth arg:

{ "op": "auth", "reqId": "1", "args": ["<apiKey>", 1700000060000, "<signature>", "<nonce>"] }

where

signature = hex_lowercase( HMAC_SHA256( secret, "GET/ws/private" + expiresMs + nonce ) )

expiresMs is Unix milliseconds a few seconds in the future, sent as a JSON number or a numeric string and concatenated as its plain decimal form; nonce is concatenated exactly as the challenge carried it, with no separator. expiresMs must lie between now and now plus 60 seconds by the venue clock, the same freshness bound as the REST recv-window cap: a value already in the past is rejected as before, and a value further than 60 seconds in the future is refused with code 1001 and the message "auth failed: expiresMs too far in the future". Sign a short-lived value at connect time rather than a far-future constant. Hex case does not matter: your signature is lower-cased before the constant-time comparison.

The signature is bound to the connection: a frame verifies on the connection whose challenge it signs and on no other, so an auth frame captured on one connection cannot open another (before revision 3.85 the recipe signed "GET/ws/private" + expiresMs alone, and such a frame authenticated a second connection with full trading authority). The nonce lives as long as its connection: a second auth on it (another key after a rotation) signs the same nonce; after a reconnect, sign the new connection's challenge and never reuse a nonce. /ws/public sends no challenge.

Worked example, reproduce this before you go near the sandbox:

InputValue
secretmy-api-secret
expiresMs1700000060000
nonce5f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a
signed stringGET/ws/private17000000600005f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a
signatured9e79457cc986b400928993ae3e89cd4e3a0f5fdeeb035d4a3768430fcbf0980

A failure is {"success":false,"code":1001}. The socket stays open, so you may retry with a corrected clock or signature. The msg names the cause: auth failed: sign the nonce of this connection's challenge frame and send it as the fourth arg (Member API section 10.1) for the pre-3.85 frame of three args, auth failed: the nonce is not this connection's challenge for a frame from another connection, auth failed: expiresMs too far in the future, and auth failed otherwise. Never log the auth frame.

Change your login (revision 3.85). After the connect, read frames until the one whose channel is challenge and keep its nonce; sign "GET/ws/private" + expiresMs + nonce; send the nonce as the fourth element of args; on every reconnect do the same with the new connection's nonce. The three-arg login is refused.

Before auth

On /ws/private before auth succeeds you may send ping, auth, any public get.* read, and subscribe to venueEvents. That session behaves exactly like a public one until auth lands, and the member-scoped venue event types begin flowing afterwards. Every other subscription and every private read or write is refused with 1001.

Revocation is immediate. Every private request re-validates the session's API key against the registry. If the key is revoked, the request is refused with 1001 and the session drops back to unauthenticated without the socket being closed. Any active key of your member authenticates (multi-key: up to 5 per scope, and revoking one key never touches the others), so re-auth with another active credential and resubscribe.

Operations

Control and trading ops

OpargsReplyNotes
pingnone{"op":"pong","reqId","ts"}Rides the reply queue, so it is never dropped under stream pressure.
auth[apiKey, expiresMs, signature, nonce]ackPrivate socket only; signs the connection's challenge nonce (revision 3.85). Failure 1001.
subscribearray of channel namesackBook channels answer with a snapshot; venueEvents answers with a SNAPSHOT frame.
unsubscribearray of channel namesackUnknown names are ignored.
resyncarray of book channel namesack + snapshot per channelRe-seeds orderbook.* and matchableBook.*.
order.place[ <same body as POST /v1/orders> ]ack with the full order stateStrict fields, unknown member gives 2017. Since Member API 3.72 (in force from the date venue operations announce) a clOrdId, a clientUid or a Trader ID of the reg block that begins or ends with whitespace acks success: false with 2013 and a msg naming the rule, as on REST; orders.batch answers it per entry.
orders.batch[ <same body as POST /v1/orders/batch> ]ack with data.results, one entry per order in array order1 to 50 complete, independent orders, each with its own clOrdId and clientUid; non-atomic; entry count outside 1 to 50 acks 2002.
orders.batchAmend[ {"amends": [ ... ]} ], the body of POST /v1/orders/batch/amendack with data.results in array order and asOfSeqRevision 3.74. 1 to 50 amendments {orderId, price?, qty?}, each processed exactly as order.amend; non-atomic; one order-budget unit per entry after the engine-busy check. See the section below.
orders.batchCancel[ {"cancels": [ ... ]} ], the body of POST /v1/orders/batch/cancelack with data.results in array order and asOfSeqRevision 3.74. 1 to 50 cancellations {orderId} or {clOrdId}, looked up first and each processed exactly as order.cancel; free; one cancellation batch of yours at a time per venue node.
order.cancel[{"orderId": 887711}]ack, no data2014 when the order is unknown or not yours. The cancelled order arrives on the order channel.
order.cancelAll[{"symbol":"BTC-USD-PERP"}]ack with {"canceled": n}symbol and side optional; omit both ([{}]) to cancel every open order across all instruments, untriggered conditionals included; side, BUY or SELL in any case, cancels that side only, exactly as DELETE /v1/orders?side= (since Member API revision 3.84). Since Member API revision 3.84 nothing else is accepted and a refusal cancels nothing: a member the op does not define is 2017 (names are case-sensitive; a batch body sent here by mistake is one), a side other than BUY or SELL 5003 as on REST, a null or non-string value, a member given twice, args given twice or args that are not exactly one object 2013. Before it every other member, side included, and every args object after the first were ignored and a null symbol read as absent, each cancelling EVERY open order the rest matched.
order.amend[{"orderId":887711,"price":"65100","qty":"5"}]ack, no dataMisaligned price or qty gives 2002. The amended order arrives on the order channel.
marketSegments.set[{"restricted":true,"allow":["MM01"],"version":7}]ack with the new versionConfiguration write. Refused for a READ_ONLY key with 1005; version conflict gives 4001. Since revision 3.63 an optional symbol in the same object writes your per-market layer on that market (the ack carries symbol).
session.cancelOnDisconnect[{"enabled":true,"graceMs":5000}]ack, msg reports armed graceMs=5000 or disarmedPrivate socket, after auth, trading-capable credential only (1005 on READ_ONLY). See the section below.
The single amend and cancel acks carry no order (revision 3.83 states it; unchanged since before revision 3.24). A successful order.amend or order.cancel acks {"op","reqId","success":true,"code":0,"msg":"ok"} with no data; only the order.place ack and the batch acks carry orders. Read the state the venue applied from the order channel (OrderAmended, OrderCanceled: price, qty, remainingQty, status, clOrdId, clientUid; guaranteed and recoverable by cursor), from get.order, from the REST PUT and DELETE answers, or from the row of a one-entry orders.batchAmend or orders.batchCancel (adopting the batch ops needs re-certification, Conformance Test Plan X-07 to X-11). The venue never applies part of an amendment: qty is the new total, fills included, and the engine applies the price and quantity you sent or refuses the whole amendment; only remainingQty can differ from what you computed. A success ack means the amendment was taken, not that the order still stands: a reprice that crosses re-matches the order.
subscribe is not atomic. The list is applied left to right and the op stops at the first unknown channel, acking success:false with code 2013. Channels before the failure are already subscribed. Either subscribe one channel per request, or treat a failed multi-channel subscribe as "unknown state" and unsubscribe everything before retrying.
Book depth tokens are a closed set, and sessions have a channel ceiling. The {depth} token in orderbook.{depth}.{symbol} and matchableBook.{depth}.{symbol} must be one of 1, 5, 25, 100, on subscribe and on resync alike; any other token is refused with 2013 ("unknown channel ..."), never clamped or served at a default depth. resync applies the same admission as subscribe: only book channels with a published depth token and a known symbol are served, and matchableBook needs a private authenticated socket; anything else is refused with 2013 rather than silently acked. One session may hold at most 64 channels: a subscribe past the cap is refused with 2013 and the message "subscription limit reached (64 channels per session)", while re-subscribing a channel the session already holds still succeeds at the cap. The depth PARAMETER on get.orderbook and get.matchableBook keeps its nearest-bucket behavior.

Cancel on disconnect (session.cancelOnDisconnect)

An opt-in protection: arm it on a private session and, if that session drops and you do not come back within a grace window, the venue cancels your open orders for you. graceMs is clamped to 0 to 30000; absent means 0 (cancel immediately on disconnect). Armed PER SESSION, in memory only: a reconnect starts unarmed and must re-arm.

Synchronous reads

Every live-state REST read is also answerable on the socket. The data payload is byte-identical to the REST data payload because both are built by the same builder, and the reply carries asOfSeq so a snapshot can be ordered against the live stream. You never need a second transport for a point-in-time question.

OpREST twinParams in args[0]Auth
get.timeGET /v1/timenoneno
get.statusGET /v1/statusnoneno
get.instrumentsGET /v1/instrumentsnoneno
get.orderbookGET /v1/orderbooksymbol, depthno
get.tradesGET /v1/tradessymbol, limitno
get.tickerGET /v1/tickersymbolno
get.markPriceGET /v1/mark-pricesymbolno
get.indexPriceGET /v1/index-pricesymbolno
get.fundingGET /v1/funding/currentsymbolno
get.insuranceFundGET /v1/insurance-fundnoneno
get.accountGET /v1/accountnoneyes
get.positionsGET /v1/positionssymbol?yes
get.clientPositionsGET /v1/positions/end-clientssymbol?, clientUid?yes
get.openOrdersGET /v1/orders/opensymbol?yes
get.orderGET /v1/orders/{id}orderIdyes
get.executionsGET /v1/executionssymbol?, limityes
get.marginPreviewPOST /v1/margin/previeworders: [ ... ]yes
get.marketSegmentsGET /v1/market-segmentssymbol? (revision 3.63: the per-market layer)yes
get.matchableMembersGET /v1/market-segments/matchable-memberssymbol? (revision 3.63: on that market)yes
get.matchableBookGET /v1/orderbook/matchablesymbol, depthyes
get.adlIndicatorGET /v1/adl/indicatorsymbol?yes
get.unwindsGET /v1/unwindsnoneyes
get.streamCursorGET /v1/stream/cursornoneyes
{ "op": "get.clientPositions", "reqId": "q2", "args": [ { "symbol": "BTC-USD-PERP", "clientUid": "CLIENT-A" } ] }

Not mirrored over WebSocket, by design: the range-scoped history routes, the backfill read GET /v1/stream/events, and the RFQ and barrier snapshot reads (GET /v1/rfq/open, GET /v1/rfq/mine, GET /v1/rfq/quotes/mine, GET /v1/barriers). Recovery after a disconnect is therefore a REST call; keep an HTTP client alongside your socket. The cheap cursor read is available on both surfaces.

Book seeding. get.orderbook is a one-shot read and carries no per-channel seq, exactly like its REST twin. Seed an incrementally maintained book from the subscribe or resync snapshot, which does carry seq. Seeding from get.orderbook leaves you unable to validate the first delta's prevSeq.

Read dispatch

get.* ops run on a worker so that a slow snapshot never delays a pipelined cancel queued behind it. Everything that mutates state, namely auth, subscribe, unsubscribe, resync, marketSegments.set, the four order.* ops and the three orders.* batch ops, is processed inline in arrival order, each through its durable ack before the next frame is read. The consequence you must design for: a get.* reply can overtake an order op you sent earlier. Never sequence a write behind a read on the same socket and assume the venue saw them in that order. Writes are ordered against writes; reads are not ordered against anything.

Rate limits

Two independent full-capacity token buckets per MEMBER, shared with REST and across every API key the member holds, so neither opening a socket nor issuing another key changes your quota.

OpsBucketRefusal
get.*, marketSegments.setread, 6000/min1003
order.place, orders.batch and orders.batchAmend (one unit per entry, charged up front, all or nothing), order.amendorder budget, 40000/min, and the per-second ceiling of your class where published (STANDARD 1,000 orders/s on production, 0 on the sandbox)1003, and 1004 when the RTS 9 order-to-trade regime is blocking (per entry on orders.batchAmend, the other entries running)
order.cancel, order.cancelAll, orders.batchCancelneithercounted toward the order-to-trade regime but never blocked, so you can always withdraw liquidity
ping, auth, subscribe, unsubscribe, resyncneitherfree

Order entry does not consume a read token. An unauthenticated socket is bucketed by connection id rather than by key.

Ack codes

CodeMeaning
1001auth failed, auth required, or the credential is no longer valid
1003rate limit
1004order-to-trade ratio regime blocking new orders and amendments
1005READ_ONLY credential attempted a write
2002a malformed batch body or an entry count outside 1 to 50 (orders.batch, orders.batchAmend, orders.batchCancel)
2013unknown op, unknown channel, or a malformed frame; also a batch op the venue has withdrawn (revision 3.74), which acks exactly as an unknown op
2014cancel or amend target not found
2017unknown JSON member on order entry
2020clOrdId in the reserved KX- namespace
2021reg.traderId missing or not a registered Authorised User
5000internal error; on a clustered deployment also a write op whose commit the venue did not confirm within its 10 second window, with msg internal error, the answer REST gives the same case (since revision 3.83; before, the ack read 2013 "malformed request"): its outcome is unknown and it may still execute, so read the order back on the leader by clOrdId or send it again with the SAME clOrdId
5001engine busy, retry with backoff; on orders.batchAmend and orders.batchCancel also the orders could not be looked up, or eight of your cancellation batches already wait on the node (nothing was submitted). On a clustered deployment a SINGLE write acked 5001 while its node lost the leadership may already be in the venue's log and may still execute on the new leader: read it back by clOrdId before resending (revision 3.83 states it)
5030not the leader (orders.batchAmend and orders.batchCancel on a clustered deployment's follower node, revision 3.74, and every other write op there, order.place, order.cancel, order.cancelAll, order.amend, orders.batch, marketSegments.set and session.cancelOnDisconnect, since revision 3.76, answered first, ahead of authentication and validation): msg NOT_LEADER, data.leaderId and data.leader, answered before anything is charged or counted. data.leader is an https base URL, on every venue deployment the environment's published entry host; while no leader is known data carries leaderId 0 and no leader. The node does not close the socket: close it yourself, reconnect to your configured entry host and send the op again (see Leader change below)
5004event store not configured on this deployment (get.streamCursor)
5007history store busy (get.streamCursor, revision 3.73): the venue's history store did not answer in time, is held after a slow read, or is catching up; sleep data.retryAfterMs and send the op again. A 1003 on get.streamCursor carries data.retryAfterMs too (the event stream group's reads in flight, member reference section 4)

The full registry, including the order rejection codes returned in an order.place ack, is on reference.html.

Public channels

ChannelDeliveryContent
orderbook.{depth}.{symbol}BEST_EFFORTL2 snapshot on subscribe, then deltas with a per-channel seq and a CRC32C checksum. Depths 1, 5, 25, 100.
trades.{symbol}BEST_EFFORTPublic prints: price, qty, taker side, flags of NORMAL, LIQUIDATION, ADL, RFQ, BARRIER_CLOSE or REVERSAL.
ticker.{symbol}BEST_EFFORTBest bid and ask, last, mark, index, funding, next funding time, open interest, 24h volume.
markIndex.{symbol}BEST_EFFORTMark and index on every price tick.
funding.{symbol}BEST_EFFORTtype: "rate" updates and type: "settlement" results; on daily-settled (LOCAL_DAILY) markets a type: "settlementDetail" frame follows each settlement with settlementValue, indexAtSnapshot, snapshotsUsed, snapshotTs and settleTs. On those markets the rate frame's predicted fields are omitted before the day's window opens. Since revision 3.70 a LOCAL_DAILY day that closed WITHOUT a settlement arrives as type: "skipped" (skipReason, settleTs, snapshotTs, settleCcy, ts): best effort, no funding exchanged; re-read the durable record with GET /v1/funding/history?includeSkips=true (the REST page, Funding).
status.{symbol}, status.venueBEST_EFFORTLegacy trading-state feed with the legacy spellings (ACTIVE, REDUCEONLY). Prefer venueEvents. status.{symbol} also repeats the market's unchanged status whenever the venue edits the market's configuration, so treat a frame as a prompt to re-read get.status or get.instruments, never as the source of a value (revision 3.83 states it).
venueEventsGUARANTEEDTyped venue events. On a public socket: SNAPSHOT, STOP_TRADING, RESUME_TRADING, plus the advisory types STALE_MARK_WARNING and STALE_MARK_RECOVERED.
{ "channel": "trades.BTC-USD-PERP", "type": "trade",
  "data": { "tradeId": 88214, "price": "64990.5", "qty": "3", "side": "SELL",
            "flags": "NORMAL", "ts": 1785312041188 } }
{ "channel": "ticker.BTC-USD-PERP",
  "data": { "symbol": "BTC-USD-PERP", "lastPrice": "64990.5", "bestBid": "64990", "bestAsk": "64991",
            "markPrice": "64992.13", "indexPrice": "64988.40", "fundingRate": "0.000012500",
            "predictedFundingRate": "0.000011200",
            "fundingRatePct": "0.00125", "predictedFundingRatePct": "0.00112",
            "fundingRateAnnualizedPct": "1.36875", "nextFundingTs": 1785312600000,
            "openInterest": "18422", "volume24h": "391055" } }

fundingRate is a dimensionless fraction of position notional per interval, never a currency amount; fundingRatePct is the same number scaled by 100 and fundingRateAnnualizedPct scales it to a 365 day year without compounding. The funding.{symbol} channel carries the same pair on both its rate and settlement frames. What funding actually COST is the payment, in the instrument's settlement currency: read accFunding on the position surfaces or the wallet channel's funding entries.

Private channels

ChannelDeliveryContent
orderGUARANTEEDLifecycle: OrderAccepted, OrderTriggered, OrderAmended, OrderFilled, OrderCanceled, OrderExpired, OrderRejected. OrderFilled reports the fill that completes the order; a partial fill is reported on execution only, and every frame before the terminal one reads status NEW (PENDING_TRIGGER before a conditional triggers), an OrderAmended of a part-filled order included: the filled quantity is qty minus remainingQty. A venue reduce-only trim of a resting or an untriggered conditional reduce-only order arrives unsolicited as OrderAmended. acceptedTs is the time of the event the frame reports.
executionGUARANTEEDYour fills with role, fee, flags, clOrdId, refSource, clientUid and, since revision 3.70 on PERP and BARRIER fills once the venue LEI is configured, the venue-leg uti (the REST page, RFQ trading); your own side only.
walletGUARANTEEDLedger movements: fees, funding, settlements, transfers, each with entryId and the resulting balance, carried twice as balanceAfter and the equal-valued legacy alias balance.
eligibilityGUARANTEEDMarket Segments version changes; since revision 3.63 the frame carries symbol when a per-market layer changed.
unwindGUARANTEEDLiquidation episode lifecycle by episodeId.
rfqGUARANTEEDRFQ negotiation on RFQ-mode markets: created, quoted, myQuote, quoteCanceled, accepted, canceled, expired. See the dedicated section below.
barriersGUARANTEEDBarrier (knock-out) contract lifecycle: opened, knockOut, takeProfit, expiry, earlyClose, liquidation, finalSettlement. See the dedicated section below.
swapsGUARANTEEDYield contract lifecycle on SWAP markets (revision 3.51): opened, matured, default, cured, finalSettlement, delivered to both parties with role WRITER | TAKER and a role-signed amount. Event-class and cursor-backfillable like wallet. See the section below.
adlmixedtype: "execution" is GUARANTEED; type: "indicator" is BEST_EFFORT.
marginmixedtype: "marginCall" is GUARANTEED; type: "update" is BEST_EFFORT.
positionBEST_EFFORTMember-level net position, average entry, realized PnL.
clientPositionBEST_EFFORTPer-clientUid sub-positions. High volume for a large broker, subscribe deliberately.
matchableBook.{depth}.{symbol}BEST_EFFORTYour matchable view of the book, rows [price, matchableQty, ownQty].
venueEventsGUARANTEEDThe public and advisory types plus your own LIQUIDATION, ADL, MARGIN_CALL, LIQUIDATION_COMPLETE and, since revision 3.61, MEMBER_PARAMS (a change of your own parameters; the names only, re-read GET /v1/account/parameters) and, since revision 3.70, the private advisory MARGIN_WATCH (your margin utilisation crossing or clearing the venue's early-warning level).
{ "channel": "execution",
  "data": { "tradeId": 88214, "orderId": 887711, "symbol": "BTC-USD-PERP", "side": "BUY",
            "price": "64990.5", "qty": "3", "fee": "97.48575", "role": "TAKER", "flags": "NORMAL",
            "clOrdId": "a3f9-0001", "refSource": "MEMBER", "clientUid": "CLIENT-A",
            "ts": 1785312041188 },
  "asOfSeq": 904119, "eventIndex": 1 }
{ "channel": "wallet",
  "data": { "kind": "FUNDINGPAY", "ccy": "USDC", "amount": "-142.883", "balance": "2841556.117",
            "balanceAfter": "2841556.117", "entryId": 660412, "ts": 1785312600000 },
  "asOfSeq": 904702, "eventIndex": 0 }
{ "channel": "margin", "type": "marginCall",
  "data": { "equity": "181450.22", "im": "204000", "mm": "102000", "available": "0", "ccy": "USDC" },
  "asOfSeq": 904480, "eventIndex": 6 }
Cross-surface field names. Wallet balance is the legacy spelling of the REST ledger's balanceAfter; frames carry both, always equal. The margin channel's lowercase mm is the same quantity GET /v1/account reports as uppercase MM. Do not reconcile the two surfaces as if they were different numbers.
Order-channel payloads are lifecycle events, not full order records. The data object is rendered from the engine event. It carries type and tif with the documented enum spellings, and its status reports PENDING_TRIGGER while a conditional order is waiting to trigger, matching what a live read returns. But it is not the complete order: reduceOnly arrives as false and trigger is absent regardless of how the order was submitted. Drive your state machine from the frame-level type and keep flags and trigger terms in your own order record, keyed by orderId and clOrdId.
Unsolicited flow. Cancels and executions arrive for orders you never sent, because the venue closes positions on liquidation and auto-deleveraging. Those legs carry refSource: "VENUE" and a venue reference of KX-LQ-{orderId} or KX-ADL-{tradeId}. Key on refSource, never on the shape of clOrdId: the KX- namespace is reserved and your own submissions using it are rejected with 2020, but historical rows may carry an empty reference instead.

Order book maintenance

Subscribing returns a snapshot immediately:

{ "channel": "orderbook.25.BTC-USD-PERP", "type": "snapshot", "seq": 12, "asOfSeq": 904118,
  "bids": [["64999.5","12"],["64999","3"]], "asks": [["65000","7"],["65000.5","20"]], "checksum": 2016409727 }

Then deltas. A quantity of "0" deletes the level:

{ "channel": "orderbook.25.BTC-USD-PERP", "type": "delta", "prevSeq": 12, "seq": 13, "asOfSeq": 904144,
  "changes": { "bids": [["64999.5","9"]], "asks": [] }, "checksum": 1333591281 }

If prevSeq does not equal the seq you last applied, you missed a frame: resync rather than continuing.

{ "op": "resync", "reqId": "r9", "args": ["orderbook.25.BTC-USD-PERP"] }

Checksum

CRC32C (Castagnoli, reflected polynomial 0x82F63B78, initial value 0xFFFFFFFF, final XOR 0xFFFFFFFF) over the UTF-8 bytes of a canonical string built from the top 25 levels of each side:

bidPx1:bidQty1|bidPx2:bidQty2|...#askPx1:askQty1|askPx2:askQty2|...

Bids best first, then a single #, then asks best first. Prices and quantities are the feed strings verbatim, with no reformatting, no trailing-zero normalisation and no rounding. The result is an unsigned 32-bit integer. Verify on every update. On mismatch, resync and discard the book you had: a book that diverged silently will quote through the spread.

A snapshot checksum covers min(depth, 25); a delta checksum always covers the top 25. A SNAPSHOT checksum covers the levels the snapshot carried, capped at the top 25 per side (so min(depth, 25)): at depth 1, 5 or 25 it is over exactly your rows, and at depth 100 it is over the top 25 of the 100 rows returned, so verify a snapshot over the rows you hold (at most 25), never over all 100. A DELTA checksum is always over the top 25 of the full book, whatever depth you subscribed, so a depth 1 or 5 subscription cannot recompute it and should verify snapshots only. At depth 25 or 100 your window always holds the top 25 levels, so verification is continuous over deltas too. Subscribe at depth 25 or 100 if you verify checksums continuously, which you should.
Deltas are not depth filtered, and deletes carry window repair rows. changes carries every price level the engine touched for that instrument, including levels outside your subscribed depth window, and when levels are deleted it ALSO carries the levels sliding into the standard window boundaries (depth 1, 5, 25, 100) from below, so a depth-limited window is repaired in the same frame instead of going short until natural refill. Implement the book as a map keyed by price string and apply every row you are given; keeping out-of-window rows is recommended (a deeper local book needs fewer repairs), and a client that trims to its subscribed depth after each frame is also correct because the repair rows restore the boundary levels it dropped.

Per-channel seq is a counter per instrument held by the gateway process and shared by all its subscribers. It is meaningful only relative to your own snapshot, is not comparable across gateways, and restarts from zero when a gateway restarts, which is why the snapshot carries the value to anchor against.

matchableBook follows the same snapshot-then-delta shape with three-element rows, but its deltas carry no checksum. Recover it with resync after any conflation gap rather than trying to verify it.

The same book over FIX. A FIX session can carry an equivalent streaming book and a public trade tape (MarketDataIncrementalRefresh 35=X, see the FIX page), so a FIX integration does not need this socket purely for prices. The trade-off is deliberate: FIX gives you New, Change and Delete levels keyed by price and a print-by-print tape carrying the venue trade id, but no per-level checksum, no mark, index or funding, and no typed venue events, and loss is detected by MsgSeqNum(34) rather than per-channel seq. Use this socket when you want continuous checksum verification and the wider feed.

venueEvents

One channel, typed frames, on both sockets. Public sessions receive the market-wide types; an authenticated private session receives those plus its own risk events, scoped to that member only. Subscribing on /ws/private alone gives you everything on one subscription. A client subscribed on both sockets receives the market-wide types twice with the same eventId and drops duplicates by identity.

TypeScopeMeaning
SNAPSHOTbothSent once on subscribe: venue state, per-instrument state, and your member risk state.
STOP_TRADINGmarket-wideTransition into any non-TRADING state, with a typed reason.
RESUME_TRADINGmarket-wideTransition into TRADING.
LIQUIDATIONprivateOne frame per venue-initiated liquidation execution against your account.
ADLprivateAuto-deleveraging affecting you, as DELEVERAGED or DEFAULTER.
MARGIN_CALLprivateEvery change of your riskStatus.
LIQUIDATION_COMPLETEprivateTerminal message of an unwind episode, including episodes that produced no execution.
MEMBER_PARAMSprivateA change of your own parameters (revision 3.61): data carries scope (ACCOUNT or MARKET), symbol (null at account scope), origin (OPERATOR for a value venue operations set, MEMBER for one you set yourself on PUT /v1/account/parameters, revision 3.64), set and cleared, the parameter names written; the effective values are on GET /v1/account/parameters.
STALE_MARK_WARNINGmarket-wide, advisoryThe mark price tick age crossed half the market's staleness threshold. The market is still fully tradable.
STALE_MARK_RECOVEREDmarket-wide, advisoryThe mark price feed is fresh again; the staleness episode is over.
MARGIN_WATCHprivate, advisoryYour margin utilisation crossed your watch level (ABOVE) or fell back below it minus 5 points (CLEARED); informational, not a margin call (revision 3.70).
EnumerationValues
tradingStateTRADING, HALTED, REDUCE_ONLY, POST_ONLY, DELISTED
reason on STOP_TRADINGCIRCUIT_BREAKER (automatic volatility halt; triggerPrice, referencePrice and expectedResumeTs are non-null only here), STALE_MARK_PRICE (price feed exceeded the staleness timeout, reduce-only until it recovers), DELISTING (permanent withdrawal of that listing, cancelsResting is true; after a re-listing the same symbol can return as a new market, and this frame is the only venueEvents frame a re-listing produces: a tickSize or qtyStep change is never made in place, see the REST page, Instruments), ADMINISTRATIVE (operator action or a venue-wide halt)
reason on RESUME_TRADINGCIRCUIT_BREAKER_EXPIRY, MARK_PRICE_RECOVERED, ADMINISTRATIVE
state on MARGIN_CALLACTIVE, CLEARED. CLEARED exactly when the new riskStatus is NORMAL; every other riskStatus (MARGIN_CALL, LIQUIDATION, DEFAULTED) is ACTIVE.
scopeINSTRUMENT, VENUE
role on ADLDELEVERAGED, DEFAULTER
trigger on LIQUIDATION_COMPLETEMARGIN_BREACH, ADMIN_FORCED
{ "channel": "venueEvents", "type": "STOP_TRADING", "eventId": "904118-2", "asOfSeq": 904118,
  "ts": 1785000123456,
  "data": { "scope": "INSTRUMENT", "symbols": ["BTC-USD-PERP"], "tradingState": "HALTED",
            "reason": "CIRCUIT_BREAKER", "triggerPrice": "71850.5", "referencePrice": "68000",
            "expectedResumeTs": 1785000243456, "cancelsResting": false } }
{ "channel": "venueEvents", "type": "LIQUIDATION", "eventId": "904512-9", "asOfSeq": 904512,
  "ts": 1785000301222,
  "data": { "episodeId": 41, "symbol": "BTC-USD-PERP", "side": "SELL", "qty": "3", "price": "67660",
            "tradeId": 88214, "orderId": 775401, "clientUid": "7F3A9C21D0B4",
            "positionRef": "7F3A9C21D0B4|BTC-USD-PERP", "clOrdId": null, "fee": "101.49" } }

Rules specific to this channel:

The mark-price staleness ladder

Every market with a staleness policy has a per-market staleness threshold, and the venue walks a three-step ladder against it. At HALF the threshold it broadcasts the advisory STALE_MARK_WARNING with data members symbol, tickAgeSec (seconds since the last mark price tick) and reduceOnlyInSec (seconds left before reduce-only; 0 when already past the threshold); the market is still fully tradable. At the full threshold the market goes reduce-only through an ordinary STOP_TRADING with tradingState: "REDUCE_ONLY" and reason: "STALE_MARK_PRICE". Recovery is automatic: fresh ticks bring a RESUME_TRADING with reason: "MARK_PRICE_RECOVERED" if the market flipped, then the advisory STALE_MARK_RECOVERED (symbol, tickAgeSec) once the age is back under half the threshold. When the feed recovers before the flip, STALE_MARK_RECOVERED alone closes the episode.

{ "channel": "venueEvents", "type": "STALE_MARK_WARNING", "eventId": "advisory-905220-1",
  "asOfSeq": 905220, "ts": 1785000501000,
  "data": { "symbol": "BTC-USD-PERP", "tickAgeSec": 151, "reduceOnlyInSec": 149 } }

The advisory types are best-effort and informational. Their eventId namespace is advisory-{asOfSeq}-{n}, unique and collision-free with the other namespaces. They never appear in the SNAPSHOT and are never backfilled or replayed; a venue restart mid-episode BEFORE the market flipped may repeat a warning once, so treat a repeat as a reminder about the same episode. A restart or failover landing AFTER the flip emits NO late warning for that episode: a market the venue already holds in reduce-only never warns again, so the warning-precedes-flip ordering holds across restarts too; a subscriber joining mid-episode learns the reduce-only state from the SNAPSHOT, and the episode still ends with STALE_MARK_RECOVERED. One warning is emitted per staleness episode, always before any STOP_TRADING with STALE_MARK_PRICE for that market, and markets without a staleness policy never warn. Never drive your trading-state machine off the advisories: state transitions arrive only as STOP_TRADING, RESUME_TRADING and SNAPSHOT.

On the private socket the advisory MARGIN_WATCH (revision 3.70) is the venue's early warning that your margin utilisation, position initial margin plus order margin over equity in the settlement currency, is at or above your watch level (marginWatchPct on GET /v1/account/parameters). data members: state (ABOVE or CLEARED), ccy (the settlement currency, the same string as the margin channel and MARGIN_CALL), utilisationPct, thresholdPct and reArmPct (decimal strings with at most two decimals); ts is the venue's evaluation time. One ABOVE per crossing and one CLEARED once utilisation falls below the level minus 5 points; the frame follows every transition, is not subject to the one-hour notice cooldown of the operator's own mail, arrives at most once per member per 15 s evaluation pass, is never journaled, never in the SNAPSHOT, GET /v1/stream/events or any backfill, and a venue restart or failover may repeat ABOVE once for an account still above. Informational only: it is not a margin call and not a demand for collateral under the Rules, nothing about your account changes, and MARGIN_CALL, LIQUIDATION and the margin channel remain the only risk-state signals. Example: {"channel":"venueEvents","type":"MARGIN_WATCH","eventId":"advisory-905301-2","asOfSeq":905301,"ts":1785000512000,"data":{"state":"ABOVE","ccy":"USD","utilisationPct":"81.4","thresholdPct":"80","reArmPct":"75"}}.

Note for brokers and market makers. Treat STALE_MARK_WARNING as the signal to widen quotes or pause quoting on that market: the mark price is no longer moving, and reduceOnlyInSec tells you how long remains before opening orders start being refused on it. STALE_MARK_RECOVERED is the all-clear. Staleness also pauses automatic liquidation DETECTION for any member with exposure on the stale market: a margin breach re-detects on the first sweep after recovery, so expect liquidation activity to resume together with the mark, not during the outage.
Sandbox timing. The stale-mark timeout is a deliberate parity difference: 60 seconds per market on the sandbox against 300 seconds in production, so the whole ladder (warning at half the threshold, reduce-only at the threshold, recovery) can be exercised in minutes during integration testing. The mechanics are identical in both environments.

RFQ and barriers

A market whose mode is RFQ on GET /v1/instruments has no order book: it trades by request for quote, negotiated over REST (POST /v1/rfq to open a request, POST /v1/rfq/{id}/quotes to answer it, POST /v1/rfq/{id}/accept to trade; full parameter detail on rest.html). order.place against such a market is refused with code 2022. Two private channels push the lifecycle to the parties involved: rfq carries the negotiation and barriers carries the knock-out contracts that accepted RFQs open on barrier markets. Subscribe to both by bare name on an authenticated /ws/private session. Neither sends a snapshot on subscribe: seed your state from the REST reads named below and apply frames on top. RFQ is a REST and WebSocket surface only; it does not exist on FIX.

The rfq channel

Frames are scoped to your own involvement, and no frame ever names a counterparty: the requester never learns who quoted, and makers never learn who is asking. The open board is deliberately not pushed either. Discover RFQs you may quote by polling GET /v1/rfq/open, which is filtered per caller by Market Segments eligibility; nothing on this channel announces another member's new RFQ, with one deliberate exception (revision 3.44): a barrier CLOSE REQUEST has exactly one eligible quoter, the contract's writer, so its created frame is also delivered to that writer, with role WRITER, closeContractId and no requester reference (since revision 3.56 the writer's copy also carries capacity and clientTag, writer transparency, and since revision 3.57 levelOrigin / clientClass; the requester's copies never do). Since revision 3.57 the quoted frame to the requester and the maker's myQuote confirmation carry the quote's own premiumBps when the writer set one (writer risk model; omitted when the market share applies). Every frame of a close request carries closeContractId; the member is omitted on ordinary requests.

TypeSent toMeaning
createdrequester (and, for a close request, the contract's writer)Your POST /v1/rfq opened a quoting window. On a close request the requester's copy says role REQUESTER and the writer's copy role WRITER; ordinary requests carry no role.
quotedrequesterA maker submitted or replaced a quote: price and validity only.
myQuotequoting makerConfirmation of your own quote, carrying your clOrdId.
quoteCanceledquoting makerYour quote was withdrawn: your own cancel, or a venue withdrawal when your member enters liquidation.
acceptedboth partiesThe requester accepted a quote and the trade executed.
canceledrequesterThe RFQ closed without a trade: your own cancel, or a venue cancel when your member enters liquidation.
expiredrequesterThe quoting window lapsed without an accept.
Typedata
createdrfqId, symbol, side, qty, expiryTs (end of the quoting window, ms), barrier (barrier markets only), takeProfit (when the request has one, revision 3.44), strictTakeProfit, premiumBps and premium (barrier markets only, revision 3.45: the strict flag, the premium share the request would open at, and the holder's maximum loss valued indicatively at the current mark, or the closed contract's frozen premium on a close request), closeContractId and role (close requests only), clOrdId (the requester's copy only)
quotedrfqId, quoteId, price, expiryTs (end of the quote's validity, ms), closeContractId (close requests only)
myQuoterfqId, quoteId, symbol, price, expiryTs, closeContractId (close requests only), clOrdId
quoteCanceledrfqId, quoteId, closeContractId (close requests only)
acceptedrfqId, quoteId, tradeId, symbol, role (REQUESTER or MAKER), side (the requester's side), price, qty, closeContractId (close requests only: the contract that closed at price), ts
canceled, expiredrfqId, symbol, side, qty, closeContractId and role REQUESTER (close requests only), clOrdId; expiryTs rides these frames too but is 0, so read the window end from the created frame
{ "channel": "rfq", "type": "quoted",
  "data": { "rfqId": 3102, "quoteId": 9017, "price": "68010.5", "expiryTs": 1785312071188 },
  "asOfSeq": 904210, "eventIndex": 0 }
{ "channel": "rfq", "type": "accepted",
  "data": { "rfqId": 3102, "quoteId": 9017, "tradeId": 88301, "symbol": "BTC-USD-KO",
            "role": "REQUESTER", "side": "BUY", "price": "68010.5", "qty": "10",
            "ts": 1785312059421 },
  "asOfSeq": 904215, "eventIndex": 3 }

Rules specific to this channel:

The barriers channel

One frame when a knock-out contract opens, one when it closes. Both parties receive every frame; role tells you which side you are, and the counterparty is never identified. Barrier contracts are bilateral: they never appear in /v1/positions, on the position channel or in open interest. The live view is GET /v1/barriers, one row per contract with role, direction, entry, barrier, the optional take profit, the market's own mark, distance to knock-out, unrealized PnL and margin; GET /v1/barriers?status=CLOSED lists your recently settled contracts with their terminationReason.

TypeMeaning
openedAn accepted barrier RFQ opened a contract. The requester of the RFQ is always the HOLDER; the quoting maker is the WRITER.
knockOutThe mark price touched the barrier. The contract terminates at the barrier price: settlePrice equals barrier even if the mark gapped through it.
takeProfitThe mark price reached the contract's optional take-profit level (revision 3.43). The contract terminates at the take-profit price: settlePrice equals takeProfit even if the mark gapped through it, fee-free like a knock-out.
expiryThe contract settled at the mark on the first price tick at or past its expiryTs. Only contracts opened with an expiry produce this; without one the contract is open-ended.
earlyCloseThe holder's close request was accepted (revision 3.44): the contract terminated at the writer's quoted price, settlePrice equals that quote, and unlike every other close this one printed a trade (BARRIER_CLOSE) that paid maker/taker fees like the entry. terminationReason is EARLY_CLOSE.
liquidationA party to the contract entered liquidation and the venue closed the contract at the mark price. The frame does not say which party: if it was you, your own unwind and venueEvents frames say so.
finalSettlementOperator close-out of a delisted barrier market at a determined price.
FieldMeaning
contractIdContract identity, stable across the opened frame, the terminal frame and GET /v1/barriers.
symbolThe barrier market's own symbol (BTC-USD-KO on the sandbox). It has its own mark price feed: subscribe markIndex.{symbol} to watch the distance to the barrier.
roleHOLDER or WRITER.
directionLONG or SHORT, always the holder's direction. A BUY RFQ holds long with the barrier below the entry; SELL holds short with the barrier above.
qtyContract quantity.
entryThe entry price the writer quoted. opened frames only.
settlePriceTerminal frames only: the barrier level on knockOut, the take-profit level on takeProfit, the writer's accepted quote on earlyClose, the terms-clamped mark on expiry and liquidation, the operator's price on finalSettlement.
barrierThe knock-out level.
takeProfitThe optional take-profit level (revision 3.43). Omitted on a knock-out-only contract; present on every frame of a contract that has one.
strictTakeProfitSTRICT take-profit contract (revision 3.45): pays only if the take profit is touched before expiryTs; its expiry frame settles at minus the premium whatever the mark. On every frame.
premiumThe holder's PREMIUM (revision 3.45), its maximum loss: |entry - barrier| x qty x contract value x premiumBps / 10000, frozen at the open. On every frame (a close frame omits it only when the venue no longer remembers the entry of a contract closed long ago).
premiumBpsThe frozen premium share in basis points of the barrier distance (revision 3.45): 10000 = the full distance, 10000 - knockOutRebateBps on a normal contract, strictTakeProfitPremiumBps on a strict one. On every frame.
capacity, clientTagWriter transparency (revision 3.57), on frames delivered to you as the WRITER only: the capacity of the HOLDER side (OWN_ACCOUNT | FOR_CLIENT) and, on FOR_CLIENT contracts, your per-viewer 16-hex-char pseudonym of the holder's end client (stable toward you, unlinkable across members). Omitted on the holder's own frames.
levelOrigin, clientClassWriter risk model (revision 3.57), on frames delivered to you as the WRITER only: the contract's frozen level origin (CLIENT | DERIVED) and, on FOR_CLIENT contracts, the holder's end-client MiFID II class (RETAIL | PROFESSIONAL | ELIGIBLE). Omitted on the holder's own frames; never read by any pricing or risk rule.
terminationReasonTerminal frames only: KNOCK_OUT | TAKE_PROFIT | EXPIRY | TEAR_UP | FINAL_SETTLEMENT | EARLY_CLOSE, the same vocabulary as the closed rows of GET /v1/barriers?status=CLOSED (liquidation frames carry TEAR_UP, earlyClose frames EARLY_CLOSE).
pnlRealized PnL of the close, signed for YOUR role: the holder's result on the holder's frame, its exact negation on the writer's. Terminal frames only. Minus the premium on knockOut and on the expiry of a strict contract; the full take-profit distance on takeProfit; otherwise the price PnL floored at minus the premium and capped at the potential profit (revision 3.45).
expiryTsContract expiry, ms. Omitted on an open-ended contract.
tsEvent time, ms.
{ "channel": "barriers", "type": "opened",
  "data": { "contractId": 71, "symbol": "BTC-USD-KO", "role": "HOLDER", "direction": "LONG",
            "qty": "10", "entry": "68010.5", "barrier": "65000", "takeProfit": "72000",
            "strictTakeProfit": false, "premium": "27094.5", "premiumBps": 9000,
            "expiryTs": 1785916800000, "ts": 1785312059421 },
  "asOfSeq": 904215, "eventIndex": 4 }
{ "channel": "barriers", "type": "knockOut",
  "data": { "contractId": 71, "symbol": "BTC-USD-KO", "role": "HOLDER", "direction": "LONG",
            "qty": "10", "settlePrice": "65000", "barrier": "65000", "takeProfit": "72000",
            "strictTakeProfit": false, "premium": "27094.5", "premiumBps": 9000,
            "terminationReason": "KNOCK_OUT", "pnl": "-27094.5",
            "expiryTs": 1785916800000, "ts": 1785384012045 },
  "asOfSeq": 911302, "eventIndex": 1 }
{ "channel": "barriers", "type": "takeProfit",
  "data": { "contractId": 72, "symbol": "BTC-USD-KO", "role": "WRITER", "direction": "LONG",
            "qty": "10", "settlePrice": "72000", "barrier": "65000", "takeProfit": "72000",
            "strictTakeProfit": true, "premium": "15052.5", "premiumBps": 5000,
            "terminationReason": "TAKE_PROFIT", "pnl": "-39895",
            "ts": 1785390000000 },
  "asOfSeq": 911890, "eventIndex": 2 }
{ "channel": "barriers", "type": "earlyClose",
  "data": { "contractId": 73, "symbol": "BTC-USD-KO", "role": "HOLDER", "direction": "LONG",
            "qty": "10", "settlePrice": "69500", "barrier": "65000", "takeProfit": "72000",
            "strictTakeProfit": false, "premium": "27094.5", "premiumBps": 9000,
            "terminationReason": "EARLY_CLOSE", "pnl": "14895",
            "ts": 1785391200000 },
  "asOfSeq": 912040, "eventIndex": 5 }

Rules specific to this channel:

The swaps channel (yield contracts, revision 3.51)

On a SWAP market ("kind": "SWAP", always RFQ) the rfq channel carries the WRITER's standing OFFER instead of a request: created, canceled and expired reach the writer with rateBps, tenorDays, minQty and remainingQty; a take arrives as accepted to BOTH parties with role WRITER | TAKER, the taken qty, the offer's remainingQty after it, rateBps, tenorDays, contractId and tradeId; swap offers are never quoted, so quoted, myQuote and quoteCanceled never appear on them. The contract itself lives on the private swaps channel, delivered to both parties, the counterparty never identified:

TypeMeaning
openedA take created the contract. amount is the principal, negative for the taker (it left its balance) and positive for the writer.
maturedThe maturity sweep paid principal plus the frozen reward in full; amount = the due, paid = the cumulative amount paid, outstanding 0, status MATURED. Terminal.
defaultThe writer could pay only part of the due (or of a claim on a later retry): amount = what moved this time, paid cumulative, outstanding the claim still owed, status DEFAULTED. The writer's offers are withdrawn and it is blocked from new ones (3008) until the claim is paid.
curedA later retry paid the last of the claim; outstanding 0, status CURED. Terminal.
finalSettlementThe venue removed the market from trading and paid principal plus the reward accrued pro rata by elapsed whole days in full; status FINAL_SETTLEMENT. Terminal (a short writer produces default instead).
{ "channel": "swaps", "type": "opened",
  "data": { "contractId": 17, "symbol": "BTC-STAKE-SWAP", "asset": "BTC", "role": "TAKER",
            "qty": "0.25", "notional": "0.25", "rateBps": 450, "tenorDays": 30, "reward": "0.000924",
            "amount": "-0.25", "maturityTs": 1789603200000, "status": "OPEN",
            "rfqId": 5102, "tradeId": 88240, "clientUid": "CLIENT-A", "ts": 1787011200000 },
  "asOfSeq": 905110, "eventIndex": 5 }

Delivery and recovery: state-only

Both channels are GUARANTEED: nothing on them is ever silently dropped, and a queue overflow delivers a terminal gap frame naming them before close 4008. Neither is cursor-backfillable: they have no sequence-ordered history, so they never appear in a gap frame's backfill query and are listed in its unrecoverable member instead, alongside venueEvents. After any gap or reconnect, recover by re-reading state, not by replay:

  1. Resubscribe, and buffer incoming rfq and barriers frames.
  2. Re-read GET /v1/rfq/mine, GET /v1/rfq/open, GET /v1/rfq/quotes/mine and GET /v1/barriers. These reads are REST only: they have no get.* twins on the socket.
  3. Drop buffered frames at or below the snapshots' asOfSeq, then resume live processing.

An RFQ or contract that lived and died entirely inside the gap never reappears on these channels, but it is never lost from your books: the trade comes back through the execution backfill and the settlement cash through the wallet backfill, both of which are cursor-backfillable.

Delivery guarantees, gaps and close codes

This is the section that separates a correct client from one that silently loses fills. Every frame belongs to exactly one of two delivery classes, and the two behave completely differently when you cannot keep up.

ClassMembersBehaviour under pressure
GUARANTEEDorder, execution, wallet, eligibility, unwind, adl type execution, margin type marginCall, venueEvents, rfq, barriers, and every reply: op acks, get.* replies and pongsNever silently dropped. If the queue overflows you receive a terminal gap frame and the socket is closed with 4008.
BEST_EFFORTorderbook.*, matchableBook.*, trades.*, ticker.*, markIndex.*, funding.*, status.*, position, clientPosition, margin type update, adl type indicatorOldest frames are dropped. You receive a non-terminal gap frame and the socket stays open.

Any channel added in future is BEST_EFFORT unless documented otherwise. Two classifications are deliberate and worth understanding: funding settlements are best-effort because the cash effect arrives as a guaranteed wallet entry and the rate is reproducible from GET /v1/funding/history; position and clientPosition are best-effort because each message supersedes the last, so the correct recovery is a snapshot read rather than a replay.

Gap frames

Nothing is ever dropped silently. Both kinds of loss are announced on the gap channel, and the two require opposite responses.

Non-terminal, conflation. Best-effort market data was dropped and the socket continues:

{ "channel": "gap", "type": "gap", "reason": "CONFLATED", "delivery": "BEST_EFFORT",
  "terminal": false, "channels": ["orderbook","ticker","trades"],
  "fromSeq": 903990, "toSeq": 904087, "dropped": 412,
  "backfill": null, "unrecoverable": null, "ts": 1785312041205 }

Recovery is a state re-read of the named families, never a cursor. fromSeq and toSeq are advisory and are both null when a drop raced the drain and the bounds were not observable. Do not tear down the connection.

Terminal, slow consumer. A guaranteed queue or the reply queue overflowed because you stopped reading. This frame is sent immediately before close 4008:

{ "channel": "gap", "type": "gap", "reason": "SLOW_CONSUMER", "delivery": "GUARANTEED",
  "terminal": true, "channels": ["execution","margin","order","wallet"],
  "fromSeq": 904118, "fromEventIndex": 2, "inclusive": true, "toSeq": null, "dropped": 1287,
  "backfill": { "path": "/v1/stream/events",
                "query": "fromSeq=904118&channels=execution,margin,order,wallet&limit=500" },
  "unrecoverable": null, "ts": 1785312044518 }
FieldMeaning
fromSeq, fromEventIndexThe cursor of the last guaranteed frame actually written to your socket, inclusive. It is the send watermark, not the frame the full queue refused, because the queue still held accepted-but-unsent frames with lower sequence numbers. Resuming past them would be silent loss.
channelsAffected guaranteed families, sorted. May over-report, never under-reports.
droppedA lower bound on the frames you did not get. Advisory, for your operations dashboard.
backfillA ready-made first backfill request naming only families with sequence-ordered history: order, execution, wallet, margin, adl, unwind, eligibility. null on a public or unauthenticated socket.
unrecoverableAffected guaranteed families with no sequence-ordered history: venueEvents, rfq and barriers. Recover venueEvents by resubscribing and applying the fresh SNAPSHOT, plus GET /v1/status; recover rfq and barriers by re-reading GET /v1/rfq/mine, GET /v1/rfq/open, GET /v1/rfq/quotes/mine and GET /v1/barriers.
Do not confuse the two. Treating CONFLATED as fatal makes you reconnect on ordinary market-data pressure. Treating SLOW_CONSUMER as recoverable without a backfill loses fills permanently, because the socket is about to close and those frames will not be re-sent on it.

Close codes

CodeReasonWhat it means and what to do
1000NormalOrderly close. Reconnect normally and follow the recovery procedure from your resume point: nothing tells you what was in flight.
1001venue restartNormally the venue is stopping for a rollout or a restart. Preceded by one advisory control frame, {"channel":"advisory","type":"advisory","advisory":"restart","msg":"The venue is restarting; reconnect and resubscribe.","ts":...}, on every socket whatever it is subscribed to; guaranteed frames already released to your socket are written before the advisory. The venue also sends the same advisory and close, while it keeps running, to every session that may have missed a frame when a venue-side fault lost frames of one command or batch (the command itself executed); the venue keeps running, so new upgrades are not refused; the recovery procedure below delivers every missed frame of the guaranteed families by the cursor backfill, and you re-read state for the best-effort families and for rfq, barriers and venueEvents; no other close code or reason marks this case. Reconnect with backoff, auth, resubscribe (nothing is preserved across a restart: not the auth, not the subscriptions, not cancel-on-disconnect arming), then run the recovery procedure below. While the venue is stopping a new upgrade answers HTTP 503 with Retry-After; keep backing off until it stops.
1001leader change, reconnectClustered deployments only (revision 3.76; among them the clustered sandbox of revision 3.83, from the date venue operations announce): the node you were connected to no longer leads, and the venue stays up. Preceded by the advisory {"channel":"advisory","type":"advisory","advisory":"leaderChange","msg":"The venue's leader node changed; reconnect and resubscribe.","ts":...}. Do exactly what you do after a restart: reconnect (the entry address leads to the new leader), auth, resubscribe, re-arm cancel-on-disconnect (a leader change voids it), recover from your cursor. New upgrades are not refused.
4008SLOW_CONSUMERPreceded by the terminal gap frame. Back off at least 1000 ms, reconnect, resubscribe, then backfill from the gap cursor. Then fix your consumer: you earned this.
4009IDLE_TIMEOUTNo inbound frame for 60 seconds. Nothing was dropped and no gap frame is sent. Reconnect and add a keepalive.

You may also see an abnormal closure (1006) roughly two seconds after 4008 or 1001: the venue sends the gap or advisory frame and the close frame, waits a bounded grace period (about two seconds) for the close handshake, then aborts the connection so its own session does not leak against a wedged peer. Read and process the gap or advisory frame as soon as it arrives rather than after your close handler runs.

Restarts and reconnection

A rollout no longer drops your private-channel subscriptions by killing the TCP connection. When the venue stops it closes every WebSocket deliberately: the advisory frame, then close 1001 with reason string venue restart, inside a bounded window of about two seconds, and it refuses new upgrades with HTTP 503 meanwhile. Subscriptions are not preserved: reconnect with backoff, auth, resubscribe every channel, re-arm session.cancelOnDisconnect if you use it, and recover exactly as after any other disconnect (below): resume the guaranteed families from your last cursor over GET /v1/stream/events, re-read state for the best-effort families and for rfq, barriers and venueEvents. Drop copy and other private channels behave the same as order-entry sessions. The same advisory and close also reach every session that may have missed a frame when a venue-side fault lost frames of one command or batch while the venue keeps running (the command itself executed); new upgrades are not refused then; recover the same way: the cursor backfill delivers every missed frame of the guaranteed families, and you re-read state for the best-effort families and for rfq, barriers and venueEvents. A client that only handles the close code behaves correctly; treat the advisory channel like any unknown channel if you do not want it. FIX sessions receive Logout(35=5) with Text(58)="venue restart" before the TCP close.

Leader change (clustered deployments, revision 3.76). On a clustered deployment one node, the leader, accepts writes, and the venue's entry address always leads to it. When leadership moves while the former leader stays up (a planned hand-over during a rollout, or a node cut off from the others), that node closes every WebSocket the way a restart does, with the advisory leaderChange and close 1001 reason string leader change, reconnect; FIX sessions get Logout(35=5) with Text(58)="leader change, reconnect". Reconnect and recover as after a restart. A write op that reaches a node which is not the leader acks 5030 NOT_LEADER with data.leaderId and data.leader before anything is charged or counted; before revision 3.76 the single ops answered 5001 there. A single-node deployment never sends either; the clustered sandbox of revision 3.83 (from the date venue operations announce) sends both at a leader change, including the hand-over venue operations stage at an agreed time for Conformance Test Plan case I-05. Since revision 3.77 a FIX session gets the leader-change Logout from the node itself as well: an order, cancel, replace or market data request that reaches a node which is not the leader is answered with the Logout and the close (reconcile by ClOrdID(11) on the leader before resubmitting), and that close arms no cancel-on-disconnect.

The leader hint (revision 3.83 states it; nothing changed). leader is the member-facing base URL venue operations configure for the node that leads: an https base URL, never a WebSocket URL, never derived from a node's own host name, and on every venue deployment the environment's published entry host, which sends new connections only to the leader. Accept it only when it equals the entry host you configured, and then open a NEW connection there (wss://<host>/ws/private). If it ever names another host, send no credentials there: reconnect to your configured entry host and tell venue operations. While no leader is known the ack carries leaderId 0 and no leader: back off briefly and retry your entry host.

A node that is not the leader keeps your socket open. It serves auth, reads and streams on a WebSocket opened on it and acks every write op 5030, but does not close the socket: at the first 5030 close it yourself, reconnect to the entry host, auth, resubscribe, re-arm session.cancelOnDisconnect and recover from your cursor. Only a node that led closes its own sockets when it loses the role (the advisory and the 1001 close above). A reconnect just after a leader change can still land on the former leader and read 5030 again: retry after a short delay.

A 5030 is a redirect, never a rejection. It is answered before the command reaches the venue's sequence, and a node that is not the leader never forwards a write, so an op acked 5030 cannot reach the book later. Send it again on the leader with the SAME clOrdId, and never count it as a reject or let it stop your trading. A cancel, cancel-all, amendment or cancellation batch acked 5030 did not take effect: send it again at once. A leader change itself pulls no resting order: re-quote or cancel what you no longer want once you are on the leader. None of these other answers proves that a write was not executed: 5001 on a SINGLE write that was already in the former leader's log (it may still execute on the new leader at its original terms), 5000 on a write whose commit the venue did not confirm within 10 seconds (since revision 3.83; it acked 2013 before), a batch row 5000, and no ack at all for an op you sent after the 1001 close. Read such an order back on the leader by clOrdId before you decide, or send it again with the SAME clOrdId; never under a new reference.

Heartbeats and keepalive

Queue sizes, for capacity planning. Per session the venue holds 4096 guaranteed frames, 2000 best-effort frames and 512 replies. The writer serves them in strict priority: replies first, then guaranteed events, then the pending conflation gap marker, then market data. A saturated guaranteed queue therefore starves market data before it trips, which is the correct behaviour under pressure.

Reconnect and recovery

After any disconnect, whatever the cause, run this procedure. Steps 3 and 5 are the ones integrations skip, and skipping them produces duplicated or out-of-order state that surfaces days later as a position break.

  1. Back off. Exponential from 1000 ms. Never tighter than 1000 ms after a 4008; after a 1001 venue restart, keep backing off while the upgrade answers 503.
  2. Reconnect, auth, subscribe. Subscribe to your channels before you do anything else so live frames start arriving.
  3. Buffer live frames. Do not process them. Everything that arrives from now until step 7 goes into an in-memory buffer, untouched.
  4. Take your resume point. Per backfill filter, the ordinal maximum of the nextCursor of your last page under that filter and the cursor of the last event you processed (a live frame's cursor can be ahead of the store): the cursors you persisted are authoritative for what you have processed. On a first start with nothing persisted, take it from get.streamCursor on the socket, or GET /v1/stream/cursor.
  5. Page the backfill. GET /v1/stream/events?fromSeq=C&channels=...&limit=500, then repeat with afterCursor=nextCursor while hasMore is true. Apply rows idempotently. Stop when hasMore is false and store.status is COMPLETE, or when nextCursor passes the cursor of your first buffered live frame; a page with hasMore false or count 0 ends the loop, never nextCursor equal to the afterCursor you sent. Since revision 3.73 a last page answers a position at the store watermark (eventIndex 2147483647 and leg 9, a bound, not a delivery), valid only with the same channels and symbol, on an environment from the date venue operations announce for it there (before that date a last page answers the cursor of its last event, else the cursor you sent): persist it per filter.
  6. Deduplicate. The backfill cursor is inclusive at sequence granularity, so the first rows are often ones you already applied. Compare by (seq, eventIndex, leg), equivalently the 30-character fixed-width cursor, plus the natural key below.
  7. Drain the buffer, discarding everything at or below your cursor, but only for the families that backfill read (a filtered cursor passes every seq up to the watermark), then resume live processing.
  8. Re-snapshot state-class channels. Book, positions, client positions, account and the best-effort margin updates are not backfillable and must be re-read, not replayed. Guaranteed margin frames of type: "marginCall" do have sequence-ordered history and come back through the backfill in step 5. The guaranteed rfq and barriers channels are state-only too: they never appear in a backfill, so re-read GET /v1/rfq/mine, GET /v1/rfq/open, GET /v1/rfq/quotes/mine and GET /v1/barriers, and discard buffered rfq and barriers frames at or below the snapshots' asOfSeq.
ChannelDedupe key, in addition to the cursor
executiontradeId + role
orderorderId + frame type
walletentryId
eligibilityversion, symbol (revision 3.63, per-market layer only)
adltradeId
venueEventseventId alone, except SNAPSHOT
Best-effort familyRe-read
orderbook, matchableBook{"op":"resync","args":["orderbook.25.BTC-USD-PERP"]}
positionget.positions
clientPositionget.clientPositions
marginget.account
adl indicatorget.adlIndicator
ticker, markIndex, trades, statusget.ticker, get.markPrice, get.indexPrice, get.trades, get.status

store.status is a three-value enum: COMPLETE means the store is level with the engine; CATCHING_UP means poll again from nextCursor; REPAIRING means the store has a recorded hole, so reads stall below it rather than skipping it. On REPAIRING, keep polling from nextCursor (it stops below the hole, never past it) and raise an operational alert. The block describes the store of the node that answered; REPAIRING starts at every leader change of a clustered venue, so expect the first backfill after a 1001 leader change, reconnect to read it. No timeout ends it and the venue sets no maximum duration; lagSeq then counts from the start of the hole to the engine head, and until it clears you recover the state you need from the state reads and keep consuming live frames (revision 3.83 states it). Full parameter and envelope detail is on rest.html.

Persist, per backfill filter, the nextCursor of your last page and your high-water event cursor. On process start, backfill from the ordinal maximum of the two before you trust your own position and order state.

Order entry over WebSocket

order.place takes the same body as POST /v1/orders, with the same validation, the same strict-field rule and the same clOrdId idempotency. The ack carries the same order-state payload as the REST response, including any executions that happened synchronously, and is sent only after the journal write is durable. The same fills also arrive on the execution channel, so dedupe by tradeId + role.

{ "op": "order.place", "reqId": "o-1041",
  "args": [ { "symbol": "BTC-USD-PERP", "side": "BUY", "type": "LIMIT", "tif": "POST_ONLY",
              "price": "64000", "qty": "5", "clOrdId": "a3f9-1041", "clientUid": "CLIENT-A" } ] }

A re-sent clOrdId with the byte-identical payload acks with success: true and data.duplicate: true, pointing at the original order. Reusing the reference with a DIFFERENT payload acks success: false with code 2009, the registry wording in msg and data.clOrdId echoing the reference. Either response reaches you only as the ack: neither is written to the event stream, so do not wait for them to appear in a backfill. A rejected order.place ack always carries the registry wording for its code in msg. The ack is the verdict on the request: an engine rejection acks success: false with the reject code and the order-state payload (status REJECTED, rejectCode) in data, where REST reports the same outcome inside an HTTP 200 with code: 0 and FIX in an ExecutionReport; the difference is deliberate, each transport uses its own place for a verdict (REST reference, order entry).

Batch order entry

orders.batch takes the same body as POST /v1/orders/batch: 1 to 50 complete, independent place-order entries, each with its own clOrdId and its own clientUid (one message, N client-tagged orders, the mass-close use case). The batch is non-atomic: each entry validates and executes exactly like a single order.place (the order-size and position caps, 2013 and 2011, apply per entry exactly as to singles), submitted to the engine in array order, and the ack's data.results returns per-entry outcomes in array order with the same shapes as REST: a success entry is the single-order ack payload shape (engine rejects ride rejectCode), a gateway failure entry is {"code", "msg", "clOrdId"}. The batch is charged one order-budget unit per entry, up front, all or nothing (a denied batch acks 1003 with data.retryAfterMs and consumes nothing), and every entry counts toward the RTS 9 regime. The ack rides the guaranteed reply queue like every other order ack.

{ "op": "orders.batch", "reqId": "b-2001",
  "args": [ { "orders": [
    { "symbol": "BTC-USD-PERP", "side": "SELL", "type": "LIMIT", "tif": "GTC",
      "price": "65000", "qty": "0.2", "reduceOnly": true,
      "clOrdId": "close-cl-a-1", "clientUid": "CLIENT-A" },
    { "symbol": "BTC-USD-PERP", "side": "SELL", "type": "LIMIT", "tif": "GTC",
      "price": "65000", "qty": "0.5", "reduceOnly": true,
      "clOrdId": "close-cl-b-1", "clientUid": "CLIENT-B" },
    { "symbol": "BTC-USD-PERP", "side": "SELL", "type": "LIMIT", "tif": "GTC",
      "price": "65000", "qty": "0.1", "reduceOnly": true,
      "clOrdId": "close-cl-c-1", "clientUid": "CLIENT-C" } ] } ] }
reduceOnly is member-scoped, so this example works only because the MEMBER is net long. The reduce-only budget is judged against your member NET position and never against the end client on the entry: the check does not read client sub-positions at all. These three entries therefore share one member-level budget of 0.8 rather than holding one each, and where your clients hold OFFSETTING positions the shape fails outright, a member book that nets to zero refusing every entry with 2008 REDUCE_ONLY_VIOLATION even though each client genuinely holds what it is closing. To close an end client, send PLAIN entries with no reduceOnly, each sized against that client's own position from GET /v1/positions/end-clients; keep reduceOnly for shrinking your own house exposure. Full rules on the Trading rules page.

Since revision 3.74 an entry whose outcome the venue could not confirm answers a 5000 row, "outcome unknown", and its neighbours keep their own rows, where such a fault acked the whole op 2013: reconcile that entry by its clOrdId and resend it only with the SAME clOrdId. After an entry the venue's intake refused as it was submitted, every later entry answers a 5001 row "not submitted"; on a clustered deployment the replicated intake can instead refuse an entry after the later ones were submitted, and those may take effect, so read every row.

Batch amendment and cancellation

orders.batchAmend and orders.batchCancel (revision 3.74) take the bodies of POST /v1/orders/batch/amend and POST /v1/orders/batch/cancel in args[0]: 1 to 50 amendments {orderId, price?, qty?}, each processed by the engine exactly as order.amend, or 1 to 50 cancellations {orderId} or {clOrdId}, each looked up first (an entry naming no open order of yours is a 2014 row and nothing is submitted for it) and processed exactly as order.cancel. The rules are the REST routes' (REST reference, batch amendment and batch cancellation): non-atomic in array order, a refused entry never stopping a later one, case-sensitive entry names, an order named twice answering 2013 on the later entry, the amendment batch charged one order-budget unit per entry after the engine-busy check with a 1004 row per entry on a blocked market, the cancellation batch free with one batch of yours at a time on a venue node.

{ "op": "orders.batchCancel", "reqId": "c-3001",
  "args": [ { "cancels": [ { "orderId": 887734 }, { "clOrdId": "q-ask-3" } ] } ] }

The ack carries the rows and asOfSeq at its top level, as the read acks do: {"op", "reqId", "success": true, "code": 0, "msg": "ok", "data": {"results": [...]}, "asOfSeq", "ts"}. Keep asOfSeq with each row's orderId: it is the evidence the Conformance Test Plan asks for. A row carrying code is a failure row; any other row is the order, and an amendment row can read REJECTED (2005, a post-only order repriced to cross) or CANCELED (STP_CANCEL): that level is gone. An amendment row is the order as of asOfSeq; when the venue could not read your orders back after the batch it is the order's last state change among the batch's own entries and asOfSeq the lowest seqNo at which a still-working row was taken, or the seqNo before it when the command that made that row's change also traded the order (the row is then the order before those fills), so every row is the order as of asOfSeq or later and the order and execution channels' events after it bring it up to date. After an entry the venue's intake refused as it was submitted, every later entry answers 5001 "not submitted"; on a clustered deployment the replicated intake can instead refuse an entry after the later ones were submitted, and those may take effect: read every row. An envelope refusal comes only before anything is submitted: 1001, 1005 after the live re-read of your credential, 2002, 1003 with data.retryAfterMs and 2034 on the amendment op, 5001, and 5030 with the leader on a follower node. orders.batch keeps its ack without asOfSeq. Neither op is offered over FIX. Adopting either op adds a surface outside your certified scope: re-certify with the Conformance Test Plan's rows X-07 to X-11 before production use.

One message at a time per socket. A session reads its next frame only after the op before it was acked durably, so a batch of 50 holds the socket for all of its commands. Keep a SECOND private socket for urgent cancels. The single order.amend and order.cancel acks carry no rejectCode: an amendment the re-match rejected (a post-only order repriced to cross, 2005) acks accepted while the order channel carries the rejection.

Trader ID

order.place carries the Trader ID of the Authorised User entering the order as reg.traderId, inside the same optional reg object POST /v1/orders takes. The venue issues the value when it registers the user, in the form TR-{memberId}-{8 hex}. Enforcement is staged per member: while you have no registered Authorised Users nothing is gated and an existing integration keeps working untouched. From the moment your first Authorised User is registered, every order must carry a registered, active reg.traderId, or the ack is success:false with code 2021, refused before the engine is reached. All three transports build the order through the same builder, so REST, WebSocket and FIX gate identically; on FIX the value rides venue tag 9012.

{ "op": "order.place", "reqId": "o-1042",
  "args": [ { "symbol": "BTC-USD-PERP", "side": "BUY", "type": "LIMIT", "price": "64000", "qty": "5",
              "clOrdId": "a3f9-1042", "clientUid": "CLIENT-A",
              "reg": { "traderId": "TR-12-9F3A21BC" } } ] }

order.amend takes no reg object: its args are orderId, price and qty. The Trader ID recorded when the order was placed stays on the order across an amendment, so 2021 never appears on an amend ack.

Pipelining

Pipelining is supported and recommended. You may keep many order ops in flight without waiting for each ack. The venue processes order ops in arrival order on the connection's read loop, so causal ordering per client is preserved: a place followed by a cancel is never reordered. Only the round-trip stall disappears.

The arithmetic is what matters. A client that serializes request and response is limited to one message per round trip:

Round tripSerialized ceilingPipelined
80 msabout 12 orders per secondbounded by your 40000/min order bucket and, on production, the 1,000/s STANDARD ceiling, not by latency
20 msabout 50 orders per secondsame
2 msabout 500 orders per secondsame

A serialized REST client at 80 ms round trip cannot exceed roughly 12 orders per second no matter how fast the venue is, and a market maker requoting a dozen instruments will blame venue latency for a limit that is entirely in its own request loop. Pipelined WebSocket removes that ceiling; the remaining constraint is the 40000 order messages per minute bucket, which is about 667 per second sustained with full-bucket burst capacity above it.

When you do exhaust the bucket, the refusal ack carries data.retryAfterMs, the exact refill wait, typically a millisecond or two. Sleep that and resend. Do not treat a 1003 as a signal to pause for a window: the bucket refills continuously, and a client that stalls for a minute and then bursts will read its own sawtooth as a venue problem.

What pipelining does not change: acks may arrive interleaved with channel frames and are not ordered against them, so correlate strictly by reqId; and get.* reads run off the read loop, so they are not ordered against your order ops at all. Keep reads on a separate connection if you also pipeline orders heavily.

Worked reconnect example

A complete Node client: keepalive, close-code handling, buffer, backfill, dedupe, drain. It assumes a signed REST helper as described on rest.html.

import crypto from "node:crypto";
import WebSocket from "ws";

const HOST = "sandbox.mtf.perpetuals.net";
const KEY = process.env.KX_API_KEY, SECRET = process.env.KX_API_SECRET;
const CHANNELS = ["order", "execution", "wallet", "margin", "venueEvents"];
// The families the REST backfill reads. Since revision 3.73 the nextCursor of a last page is a position
// at the store watermark, valid only for this filter: the cursor, and drain()'s discard by it, cover
// these families alone. venueEvents has no sequence history (never a backfill family): its buffered
// frames, the subscribe-time SNAPSHOT first, are always applied, deduped by eventId, and never move the
// cursor.
const BACKFILL = ["order", "execution", "wallet", "margin", "adl", "unwind", "eligibility"];
const BACKFILLED = new Set(BACKFILL);

let cursor = loadPersistedCursor();   // 30-char string, or null on a cold start
const applied = new Set();            // recent dedupe keys, bounded in production
let backoff = 1000;

// Revision 3.85: the login signs the connection's challenge nonce.
function wsSignature(expiresMs, nonce) {
  return crypto.createHmac("sha256", SECRET).update("GET/ws/private" + expiresMs + nonce).digest("hex");
}

// (asOfSeq, eventIndex, leg) as a fixed-width comparable string. leg 1 = taker leg.
function cursorOf(f) {
  const leg = f.channel === "execution" && f.data.role === "TAKER" ? 1 : 0;
  return String(f.asOfSeq).padStart(19, "0") + String(f.eventIndex ?? 0).padStart(10, "0") + leg;
}

function dedupeKey(f) {
  switch (f.channel) {
    case "execution":   return `x:${f.data.tradeId}:${f.data.role}`;
    case "order":       return `o:${f.data.orderId}:${f.type}`;
    case "wallet":      return `w:${f.data.entryId}`;
    case "eligibility": return `e:${f.data.version}`;
    case "adl":         return `a:${f.data.tradeId}`;
    case "venueEvents": return f.type === "SNAPSHOT" ? null : `v:${f.eventId}`;
    default:            return null;   // best-effort state channels are not deduped
  }
}

function connect() {
  const ws = new WebSocket(`wss://${HOST}/ws/private`);
  const buffer = [];
  let live = false, gapCursor = null, keepalive = null;

  ws.on("open", () => {
    // Nothing to send yet: the venue's first frame is this connection's login challenge.
    keepalive = setInterval(() => ws.send(JSON.stringify({ op: "ping", reqId: "ka" })), 30_000);
  });

  ws.on("message", (raw) => {
    const f = JSON.parse(raw);

    // 0. The login challenge (revision 3.85): sign THIS connection's nonce, send it as args[3].
    if (f.channel === "challenge") {
      const expiresMs = Date.now() + 10_000;
      ws.send(JSON.stringify({ op: "auth", reqId: "auth", args: [KEY, expiresMs, wsSignature(expiresMs, f.nonce), f.nonce] }));
      return;
    }

    // 1. Replies. Note the pong has no `success` member.
    if (f.op === "pong") return;
    if (f.op === "auth") {
      if (!f.success) { console.error("auth failed", f.code); ws.close(); return; }
      ws.send(JSON.stringify({ op: "subscribe", reqId: "sub", args: CHANNELS }));
      return;
    }
    if (f.op === "subscribe") {
      if (!f.success) { console.error("subscribe failed", f.code, f.msg); ws.close(); return; }
      recover(ws, buffer).then(() => { live = true; drain(buffer); });
      return;
    }
    if (f.op) return;   // any other ack: route to your pending-request map by reqId

    // 2. Control frames.
    if (f.channel === "heartbeat") return;
    if (f.channel === "gap") {
      if (f.terminal) {
        gapCursor = String(f.fromSeq).padStart(19, "0") + String(f.fromEventIndex).padStart(10, "0") + "0";
        if (gapCursor < cursor) gapCursor = cursor;       // never resume ahead of what we processed
        console.warn("terminal gap, expect 4008", f.channels, f.unrecoverable);
      } else {
        console.warn("conflated", f.channels, f.dropped);
        resnapshotBestEffort(ws, f.channels);              // state re-read, never a cursor
      }
      return;
    }

    // 3. Channel frames: buffer until the backfill has completed.
    if (!live) { buffer.push(f); return; }
    apply(f);
  });

  ws.on("close", (code) => {
    clearInterval(keepalive);
    if (gapCursor) cursor = gapCursor;                     // resume point from the terminal gap
    const wait = code === 4008 ? Math.max(1000, backoff) : backoff;
    backoff = Math.min(backoff * 2, 30_000);
    console.warn("closed", code, "reconnecting in", wait, "ms");
    setTimeout(connect, wait);
  });

  ws.on("error", (e) => console.error("socket error", e.message));
}

// Steps 4 to 6: resume point, then page the REST backfill until it reaches the buffer.
async function recover(ws, buffer) {
  if (!cursor) {
    const head = await restGet("/v1/stream/cursor");        // signed REST, see rest.html
    cursor = head.data.headCursor ?? "0".repeat(30);
  }
  for (;;) {
    const page = await restGet(`/v1/stream/events?afterCursor=${cursor}&channels=${BACKFILL.join(",")}&limit=500&waitMs=500`);
    for (const row of page.data.events) apply(row, row.cursor);
    cursor = page.data.nextCursor;
    const first = buffer.find((f) => BACKFILLED.has(f.channel));   // the first buffered frame the backfill covers
    const firstLive = first ? cursorOf(first) : null;
    if (firstLive && cursor >= firstLive) break;            // ordinal compare: fixed width, no parsing
    if (!page.data.hasMore && page.data.store.status === "COMPLETE") break;
    if (page.data.store.status === "REPAIRING") alertOps("stream store repairing");
  }
  await resnapshotStateClass(ws);                           // book, positions, account
  backoff = 1000;
}

function drain(buffer) {
  while (buffer.length) {
    const f = buffer.shift();
    // Discard by the backfill cursor ONLY the families the backfill read (step 7): a venueEvents frame,
    // the SNAPSHOT first, is applied whatever its asOfSeq, and dedupeKey() keeps a repeat out.
    if (BACKFILLED.has(f.channel) && cursorOf(f) <= cursor) continue;   // already covered by the backfill
    apply(f);
  }
}

function apply(f, rowCursor) {
  const key = dedupeKey(f);
  if (key !== null) {
    if (applied.has(key)) return;
    applied.add(key);
  }
  // Only a backfilled family moves the resume point: a venueEvents frame's asOfSeq is no position
  // in the backfill's filter.
  if (rowCursor || BACKFILLED.has(f.channel)) cursor = rowCursor ?? cursorOf(f);
  handle(f);                                                // your order state machine
}

connect();
Prove it before you go live. Stall your reader deliberately until you earn a 4008, and assert that you parse the terminal gap, back off at least a second, backfill from its cursor, and end with an event log that is both gap-free and duplicate-free by cursor order. Then idle a socket without pinging and assert you handle 4009. Both failures are cheap in the sandbox and expensive in production.