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
| Environment | WebSocket base |
|---|---|
| Sandbox | wss://sandbox.mtf.perpetuals.net |
| Production | wss://production.mtf.perpetuals.com |
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).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.
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.
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
- Every price, quantity, rate and money value is an exact decimal string. Parse to a decimal type, never to a float.
asOfSeqis the engine's total order across the whole venue. It is comparable with the RESTasOfSeq, with the recovery cursor and across channels.eventIndexorders the deliveries produced by one engine command: it is the 0-based position of an event among the events of that ONE command and restarts at 0 for everyasOfSeq, not a running counter, so within oneasOfSeqthe values you see increase but are not contiguous. In a cursorseqis a signed 64-bit integer (19 digits, 0 to 9223372036854775807) andeventIndexa signed 32-bit integer (10 digits, 0 to 2147483647); ten digits is the padded width, not the range (revision 3.83 states the ranges).- On an
orderframeacceptedTsis the sequencing time of the event the frame reports (the acceptance, the amendment, the cancel, the trigger, the completing fill, the expiry or the rejection), not the time the order was first accepted; the open-order reads carry the placement time instead (the REST page, Order state). - Within a single channel, frames arrive in ascending
(asOfSeq, eventIndex). Across channels there is no ordering guarantee, and acks are not ordered against channel frames. Correlate byreqId,orderIdandtradeId. (asOfSeq, eventIndex)is not a unique frame identity. A member that is both maker and taker on one trade receives twoexecutionframes on the same cursor. See the dedupe keys below.- Null members are omitted everywhere except the
venueEventschannel andgapframes, which write explicit nulls. An ack with no payload simply has nodatamember. - Ignore unknown fields, unknown channels and unknown
typevalues. The venue adds them without a breaking-change notice. - Nothing is emitted before it is durable. Acks and events are released only after the journal write covering them has committed, so an
OrderAcceptedyou have seen has survived the commit.
{"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:
| Input | Value |
|---|---|
| secret | my-api-secret |
| expiresMs | 1700000060000 |
| nonce | 5f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a |
| signed string | GET/ws/private17000000600005f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a |
| signature | d9e79457cc986b400928993ae3e89cd4e3a0f5fdeeb035d4a3768430fcbf0980 |
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.
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.
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
| Op | args | Reply | Notes |
|---|---|---|---|
ping | none | {"op":"pong","reqId","ts"} | Rides the reply queue, so it is never dropped under stream pressure. |
auth | [apiKey, expiresMs, signature, nonce] | ack | Private socket only; signs the connection's challenge nonce (revision 3.85). Failure 1001. |
subscribe | array of channel names | ack | Book channels answer with a snapshot; venueEvents answers with a SNAPSHOT frame. |
unsubscribe | array of channel names | ack | Unknown names are ignored. |
resync | array of book channel names | ack + snapshot per channel | Re-seeds orderbook.* and matchableBook.*. |
order.place | [ <same body as POST /v1/orders> ] | ack with the full order state | Strict 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 order | 1 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/amend | ack with data.results in array order and asOfSeq | Revision 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/cancel | ack with data.results in array order and asOfSeq | Revision 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 data | 2014 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 data | Misaligned 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 version | Configuration 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 disarmed | Private socket, after auth, trading-capable credential only (1005 on READ_ONLY). See the section below. |
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.{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.
- What firing does. When an armed session's socket closes, for any reason, the venue waits the grace window and then, if the member has NO live authenticated TRADING-CAPABLE private WebSocket session left, submits ONE member-scope cancel-all: every open order of the member, on every instrument, untriggered conditionals included, regardless of the transport that placed them. The cancels report
cancelReasonUSER_REQUESTED, because the armed protection is your own standing instruction. By its fields such a cancel cannot be told apart from a member-wide cancel-all of your own (order.cancelAllorDELETE /v1/orderswithout a symbol, from any session of yours) or from the member portal's kill switch, which reportUSER_REQUESTEDtoo (ADMIN_CANCELis reserved for cancels by venue operations); what tells the protection apart is that it fires only after an armed session closed and the grace window passed, that it is ONE command (all itsOrderCanceledframes share oneasOfSeq), that it covers all your open orders on every market whichever transport placed them, and that it answers no request of yours (revision 3.83 states it; the value staysUSER_REQUESTEDby design). The venue re-checks liveness immediately before submitting, so a reconnect at the last instant still stops the cancel. - Read-only sessions count for neither side. A session on a READ_ONLY key cannot arm the protection (
1005), does not satisfy the liveness probe, and itsauthdoes not disarm a pending timer: a credential that cannot manage orders can neither trigger cancels nor silently veto them. The same rule applies on FIX to drop-copy sessions. - A trading reconnect disarms it, per transport. A WebSocket timer is cleared only by a WebSocket
authwith a trading-capable credential, a FIX timer only by a FIX trading Logon. One timer is pending per member per transport; a second armed session dropping restarts the window on a BEST EFFORT basis (the restart can race a timer already at expiry; the final liveness re-check applies either way). - Arming survives a key rotation. The protection is keyed to your MEMBER identity captured at arm time, so rotating the session's API key mid-session does not void an acknowledged arming.
- A venue restart voids arming. Arming is in-memory by design: a node restart or failover voids every pending timer and every armed session uniformly, nothing fires during or after the restart, and every reconnect starts unarmed. Re-arm on every connect. A leader change voids it the same way, and a
session.cancelOnDisconnectacked5030is not armed. - Orders only, never positions. The venue does not close, reduce or hedge any position on a disconnect, under this feature or any other. On FIX the same protection is armed at Logon via venue tags
9004/9005; see the FIX page.
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.
| Op | REST twin | Params in args[0] | Auth |
|---|---|---|---|
get.time | GET /v1/time | none | no |
get.status | GET /v1/status | none | no |
get.instruments | GET /v1/instruments | none | no |
get.orderbook | GET /v1/orderbook | symbol, depth | no |
get.trades | GET /v1/trades | symbol, limit | no |
get.ticker | GET /v1/ticker | symbol | no |
get.markPrice | GET /v1/mark-price | symbol | no |
get.indexPrice | GET /v1/index-price | symbol | no |
get.funding | GET /v1/funding/current | symbol | no |
get.insuranceFund | GET /v1/insurance-fund | none | no |
get.account | GET /v1/account | none | yes |
get.positions | GET /v1/positions | symbol? | yes |
get.clientPositions | GET /v1/positions/end-clients | symbol?, clientUid? | yes |
get.openOrders | GET /v1/orders/open | symbol? | yes |
get.order | GET /v1/orders/{id} | orderId | yes |
get.executions | GET /v1/executions | symbol?, limit | yes |
get.marginPreview | POST /v1/margin/preview | orders: [ ... ] | yes |
get.marketSegments | GET /v1/market-segments | symbol? (revision 3.63: the per-market layer) | yes |
get.matchableMembers | GET /v1/market-segments/matchable-members | symbol? (revision 3.63: on that market) | yes |
get.matchableBook | GET /v1/orderbook/matchable | symbol, depth | yes |
get.adlIndicator | GET /v1/adl/indicator | symbol? | yes |
get.unwinds | GET /v1/unwinds | none | yes |
get.streamCursor | GET /v1/stream/cursor | none | yes |
{ "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.
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.
| Ops | Bucket | Refusal |
|---|---|---|
get.*, marketSegments.set | read, 6000/min | 1003 |
order.place, orders.batch and orders.batchAmend (one unit per entry, charged up front, all or nothing), order.amend | order 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.batchCancel | neither | counted toward the order-to-trade regime but never blocked, so you can always withdraw liquidity |
ping, auth, subscribe, unsubscribe, resync | neither | free |
Order entry does not consume a read token. An unauthenticated socket is bucketed by connection id rather than by key.
Ack codes
| Code | Meaning |
|---|---|
1001 | auth failed, auth required, or the credential is no longer valid |
1003 | rate limit |
1004 | order-to-trade ratio regime blocking new orders and amendments |
1005 | READ_ONLY credential attempted a write |
2002 | a malformed batch body or an entry count outside 1 to 50 (orders.batch, orders.batchAmend, orders.batchCancel) |
2013 | unknown 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 |
2014 | cancel or amend target not found |
2017 | unknown JSON member on order entry |
2020 | clOrdId in the reserved KX- namespace |
2021 | reg.traderId missing or not a registered Authorised User |
5000 | internal 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 |
5001 | engine 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) |
5030 | not 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) |
5004 | event store not configured on this deployment (get.streamCursor) |
5007 | history 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
| Channel | Delivery | Content |
|---|---|---|
orderbook.{depth}.{symbol} | BEST_EFFORT | L2 snapshot on subscribe, then deltas with a per-channel seq and a CRC32C checksum. Depths 1, 5, 25, 100. |
trades.{symbol} | BEST_EFFORT | Public prints: price, qty, taker side, flags of NORMAL, LIQUIDATION, ADL, RFQ, BARRIER_CLOSE or REVERSAL. |
ticker.{symbol} | BEST_EFFORT | Best bid and ask, last, mark, index, funding, next funding time, open interest, 24h volume. |
markIndex.{symbol} | BEST_EFFORT | Mark and index on every price tick. |
funding.{symbol} | BEST_EFFORT | type: "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.venue | BEST_EFFORT | Legacy 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). |
venueEvents | GUARANTEED | Typed 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
| Channel | Delivery | Content |
|---|---|---|
order | GUARANTEED | Lifecycle: 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. |
execution | GUARANTEED | Your 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. |
wallet | GUARANTEED | Ledger movements: fees, funding, settlements, transfers, each with entryId and the resulting balance, carried twice as balanceAfter and the equal-valued legacy alias balance. |
eligibility | GUARANTEED | Market Segments version changes; since revision 3.63 the frame carries symbol when a per-market layer changed. |
unwind | GUARANTEED | Liquidation episode lifecycle by episodeId. |
rfq | GUARANTEED | RFQ negotiation on RFQ-mode markets: created, quoted, myQuote, quoteCanceled, accepted, canceled, expired. See the dedicated section below. |
barriers | GUARANTEED | Barrier (knock-out) contract lifecycle: opened, knockOut, takeProfit, expiry, earlyClose, liquidation, finalSettlement. See the dedicated section below. |
swaps | GUARANTEED | Yield 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. |
adl | mixed | type: "execution" is GUARANTEED; type: "indicator" is BEST_EFFORT. |
margin | mixed | type: "marginCall" is GUARANTEED; type: "update" is BEST_EFFORT. |
position | BEST_EFFORT | Member-level net position, average entry, realized PnL. |
clientPosition | BEST_EFFORT | Per-clientUid sub-positions. High volume for a large broker, subscribe deliberately. |
matchableBook.{depth}.{symbol} | BEST_EFFORT | Your matchable view of the book, rows [price, matchableQty, ownQty]. |
venueEvents | GUARANTEED | The 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 }
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.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.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.
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.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.
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.
| Type | Scope | Meaning |
|---|---|---|
SNAPSHOT | both | Sent once on subscribe: venue state, per-instrument state, and your member risk state. |
STOP_TRADING | market-wide | Transition into any non-TRADING state, with a typed reason. |
RESUME_TRADING | market-wide | Transition into TRADING. |
LIQUIDATION | private | One frame per venue-initiated liquidation execution against your account. |
ADL | private | Auto-deleveraging affecting you, as DELEVERAGED or DEFAULTER. |
MARGIN_CALL | private | Every change of your riskStatus. |
LIQUIDATION_COMPLETE | private | Terminal message of an unwind episode, including episodes that produced no execution. |
MEMBER_PARAMS | private | A 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_WARNING | market-wide, advisory | The mark price tick age crossed half the market's staleness threshold. The market is still fully tradable. |
STALE_MARK_RECOVERED | market-wide, advisory | The mark price feed is fresh again; the staleness episode is over. |
MARGIN_WATCH | private, advisory | Your margin utilisation crossed your watch level (ABOVE) or fell back below it minus 5 points (CLEARED); informational, not a margin call (revision 3.70). |
| Enumeration | Values |
|---|---|
tradingState | TRADING, HALTED, REDUCE_ONLY, POST_ONLY, DELISTED |
reason on STOP_TRADING | CIRCUIT_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_TRADING | CIRCUIT_BREAKER_EXPIRY, MARK_PRICE_RECOVERED, ADMINISTRATIVE |
state on MARGIN_CALL | ACTIVE, CLEARED. CLEARED exactly when the new riskStatus is NORMAL; every other riskStatus (MARGIN_CALL, LIQUIDATION, DEFAULTED) is ACTIVE. |
scope | INSTRUMENT, VENUE |
role on ADL | DELEVERAGED, DEFAULTER |
trigger on LIQUIDATION_COMPLETE | MARGIN_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:
- It writes explicit nulls. Every other channel omits null members. Here
"clOrdId": nullmeans "venue initiated, there is no member-authored reference", which an absent key could not express. Resolve a venue-initiated close to the right end-client position withpositionRef, which isclientUid + "|" + symbol. eventIdis"{asOfSeq}-{eventIndex}"and is your idempotency key for every type exceptSNAPSHOT, whose id is"snapshot-{asOfSeq}"and is not unique. Apply everySNAPSHOTyou receive on the connection that received it; never dedupe snapshots by id.- The
SNAPSHOTis built atomically against engine state: no frame with a higherasOfSeqcan precede it on that connection. A frame at or below it may follow, so apply the snapshot first and then apply later frames idempotently. - A
subscribetovenueEventsbuilds that snapshot on the engine thread and fails transiently under engine load rather than closing your session. Treat a failedvenueEventssubscribe as retryable. - The spellings here deliberately differ from the legacy
status.*channel (TRADINGagainstACTIVE,REDUCE_ONLYagainstREDUCEONLY). Do not feed one surface's values into a switch written for the other.GET /v1/statuscarries both vocabularies side by side: its per-instrumentstatusmember is the legacy spelling and itstradingStatemember is thevenueEventsspelling, so read the one your switch was written for. venueEventsis not ordered against other channels. ALIQUIDATIONframe and its matchingexecutionframe may arrive in either order; correlate byasOfSeq,tradeIdandorderId.
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"}}.
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.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.
| Type | Sent to | Meaning |
|---|---|---|
created | requester (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. |
quoted | requester | A maker submitted or replaced a quote: price and validity only. |
myQuote | quoting maker | Confirmation of your own quote, carrying your clOrdId. |
quoteCanceled | quoting maker | Your quote was withdrawn: your own cancel, or a venue withdrawal when your member enters liquidation. |
accepted | both parties | The requester accepted a quote and the trade executed. |
canceled | requester | The RFQ closed without a trade: your own cancel, or a venue cancel when your member enters liquidation. |
expired | requester | The quoting window lapsed without an accept. |
| Type | data |
|---|---|
created | rfqId, 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) |
quoted | rfqId, quoteId, price, expiryTs (end of the quote's validity, ms), closeContractId (close requests only) |
myQuote | rfqId, quoteId, symbol, price, expiryTs, closeContractId (close requests only), clOrdId |
quoteCanceled | rfqId, quoteId, closeContractId (close requests only) |
accepted | rfqId, 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, expired | rfqId, 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:
GET /v1/rfq/mineis the authoritative quote list, not your frame history.quotedframes are anonymous, so a replacement is indistinguishable from a new quote: a maker may hold one live quote per RFQ and a re-quote replaces it, arriving as a freshquoteIdwith no frame retiring the old one, and a quote whose validity lapses dies silently with no frame at all. Accepting a replaced or lapsedquoteIdis refused with2025. Treat the frames as a wake-up signal; accept from the snapshot.quoteCanceledis addressed to the maker only. The requester learns a quote is gone fromGET /v1/rfq/mine, never from a frame.- A refused accept produces no frame. The rejection, for example
3001when you fail the margin check or3007when the maker no longer passes it, reaches you only as the reply to the accept call, and the RFQ stays open. - On
PERPandSPOTmarkets the accepted trade also arrives on yourexecutionchannel and on the publictrades.{symbol}feed, both flaggedRFQ, and is margined, prefunded and netted exactly like a book fill. It does not update the last-trade print, does not trigger conditional orders and does not feed the circuit breaker. Dedupe againstexecutionbytradeId+roleas usual. - On
BARRIERmarkets the accept opens a contract instead of a position: anopenedframe follows on thebarrierschannel from the same commit, at the sameasOfSeqwith a latereventIndex. Channels are not ordered against each other, so correlate byasOfSeq, or by thecontractIdin the accept reply, not by arrival order. - A CLOSE REQUEST (revision 3.44,
closeContractIdset) is the holder asking the writer to close an open contract: only the writer sees and may quote it, and its accept CLOSES the contract at the quoted price. Theacceptedframe reaches both parties as usual, the closing trade arrives onexecutionandtrades.{symbol}flaggedBARRIER_CLOSE, and anearlyCloseframe follows on thebarrierschannel from the same commit. If the contract terminates any other way while its close request is open, the venue cancels the request in the same command and the requester receives acanceledframe that still names the contract incloseContractId. Everyrfqframe of a close request carriescloseContractId,quoteCanceled,canceledandexpiredincluded.
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.
| Type | Meaning |
|---|---|
opened | An accepted barrier RFQ opened a contract. The requester of the RFQ is always the HOLDER; the quoting maker is the WRITER. |
knockOut | The mark price touched the barrier. The contract terminates at the barrier price: settlePrice equals barrier even if the mark gapped through it. |
takeProfit | The 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. |
expiry | The 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. |
earlyClose | The 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. |
liquidation | A 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. |
finalSettlement | Operator close-out of a delisted barrier market at a determined price. |
| Field | Meaning |
|---|---|
contractId | Contract identity, stable across the opened frame, the terminal frame and GET /v1/barriers. |
symbol | The 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. |
role | HOLDER or WRITER. |
direction | LONG 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. |
qty | Contract quantity. |
entry | The entry price the writer quoted. opened frames only. |
settlePrice | Terminal 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. |
barrier | The knock-out level. |
takeProfit | The optional take-profit level (revision 3.43). Omitted on a knock-out-only contract; present on every frame of a contract that has one. |
strictTakeProfit | STRICT 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. |
premium | The 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). |
premiumBps | The 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, clientTag | Writer 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, clientClass | Writer 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. |
terminationReason | Terminal 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). |
pnl | Realized 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). |
expiryTs | Contract expiry, ms. Omitted on an open-ended contract. |
ts | Event 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:
- Terminal types are mutually exclusive and exactly one closes each contract, so
(contractId, type)is a natural idempotency key alongside the cursor. - The cash effect of a close arrives as a guaranteed
walletentry of kindBARRIERSETTLE, zero-sum between holder and writer and fee-free: knock-out, take-profit, expiry, liquidation and final-settlement closes carry no fee, and the ordinary maker/taker fees were charged once, at entry, on the entry notional. The one exception is the early close (revision 3.44): it is a member-negotiated execution, so itsBARRIER_CLOSEtrade pays maker/taker fees on the closing notional exactly like the entry (holder taker, writer maker), booked as ordinaryFEEentries beside theBARRIERSETTLEpair. There is no funding on barrier markets. - The holder's margin is its premium at entry (revision 3.45:
premiumBpsof the entry-to-barrier distance times quantity, 90 percent after the sandbox's 10 percent knock-out rebate, 50 percent on a strict take-profit contract), IM equal to MM, tracking the remaining mark-to-barrier distance less the rebate thereafter, and the premium is also the maximum possible loss: termination at the barrier price is a guaranteed stop with no gap risk, the writer receives exactly the premium there, and the contract can never liquidate its holder. A strict contract (strictTakeProfittrue) pays only on a take-profit touch before its expiry and forfeits the whole premium at expiry otherwise. The writer is margined like a perp on the market's risk tiers at current notional, bounded by the remaining mark-to-take-profit distance when the contract carries a take profit (revision 3.43), so a writer can be pulled into liquidation, which tears its contracts up at the mark clamped to the contract's terms. - Equity marks to market continuously between frames: a contract's unrealized PnL moves your
marginchannel figures andGET /v1/accountwithout anything appearing on 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:
| Type | Meaning |
|---|---|
opened | A take created the contract. amount is the principal, negative for the taker (it left its balance) and positive for the writer. |
matured | The maturity sweep paid principal plus the frozen reward in full; amount = the due, paid = the cumulative amount paid, outstanding 0, status MATURED. Terminal. |
default | The 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. |
cured | A later retry paid the last of the claim; outstanding 0, status CURED. Terminal. |
finalSettlement | The 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 }
- Every frame carries
contractId,symbol,asset,role,qty,notional,rateBps,tenorDays,reward,maturityTs,status,rfqId,tradeId, yourclientUid,tsand the role-signedamount(what your role paid by this event is negative, what it received positive); settlement frames addpaid,outstandingandreason(the same word as the resultingstatus). Every money figure is in the market's asset, never in the settlement currency. - The cash legs arrive as guaranteed
walletentries withccy= the asset:SWAPPRINCIPALat the take andSWAPSETTLEat every settlement, both with the contract id asrefId; the twoFEElegs of the take carry the take'stradeIdasrefId, like every other execution fee. - Unlike
rfqandbarriers,swapsIS cursor-backfillable: its frames are stored as event rows and come back throughGET /v1/stream/eventsafter a gap.GET /v1/swapsis the state read.
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:
- Resubscribe, and buffer incoming
rfqandbarriersframes. - Re-read
GET /v1/rfq/mine,GET /v1/rfq/open,GET /v1/rfq/quotes/mineandGET /v1/barriers. These reads are REST only: they have noget.*twins on the socket. - 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.
| Class | Members | Behaviour under pressure |
|---|---|---|
| GUARANTEED | order, execution, wallet, eligibility, unwind, adl type execution, margin type marginCall, venueEvents, rfq, barriers, and every reply: op acks, get.* replies and pongs | Never silently dropped. If the queue overflows you receive a terminal gap frame and the socket is closed with 4008. |
| BEST_EFFORT | orderbook.*, matchableBook.*, trades.*, ticker.*, markIndex.*, funding.*, status.*, position, clientPosition, margin type update, adl type indicator | Oldest 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 }
| Field | Meaning |
|---|---|
fromSeq, fromEventIndex | The 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. |
channels | Affected guaranteed families, sorted. May over-report, never under-reports. |
dropped | A lower bound on the frames you did not get. Advisory, for your operations dashboard. |
backfill | A 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. |
unrecoverable | Affected 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. |
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
| Code | Reason | What it means and what to do |
|---|---|---|
1000 | Normal | Orderly close. Reconnect normally and follow the recovery procedure from your resume point: nothing tells you what was in flight. |
1001 | venue restart | Normally 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. |
1001 | leader change, reconnect | Clustered 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. |
4008 | SLOW_CONSUMER | Preceded 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. |
4009 | IDLE_TIMEOUT | No 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
- Send something at least every 30 seconds. The venue closes a socket with 4009 after 60 seconds with no inbound frame, swept every 5 seconds. Any op refreshes the timer, not only
ping, but a dedicated 30 secondpingis the simple correct answer. - The venue sends
{"channel":"heartbeat","type":"heartbeat","ts":...}after 15 seconds of outbound silence. It is not data: do not feed it to your channel dispatcher, and do not use its absence as a liveness signal, since a busy socket never emits one. - Protocol-level ping and pong frames are not a substitute. Liveness is measured on application frames.
- Every socket write has a 5 second deadline. A peer whose TCP receive window stays full for 5 seconds trips the session to 4008 exactly as a queue overflow does. Read the socket on a dedicated loop and hand frames to a queue; if your parser, your database write or your strategy runs on the read loop you will eventually earn a 4008.
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.
- 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. - Reconnect,
auth,subscribe. Subscribe to your channels before you do anything else so live frames start arriving. - Buffer live frames. Do not process them. Everything that arrives from now until step 7 goes into an in-memory buffer, untouched.
- Take your resume point. Per backfill filter, the ordinal maximum of the
nextCursorof 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 fromget.streamCursoron the socket, orGET /v1/stream/cursor. - Page the backfill.
GET /v1/stream/events?fromSeq=C&channels=...&limit=500, then repeat withafterCursor=nextCursorwhilehasMoreis true. Apply rows idempotently. Stop whenhasMoreis false andstore.statusisCOMPLETE, or whennextCursorpasses the cursor of your first buffered live frame; a page withhasMorefalse orcount0 ends the loop, nevernextCursorequal to theafterCursoryou sent. Since revision 3.73 a last page answers a position at the store watermark (eventIndex2147483647 andleg9, a bound, not a delivery), valid only with the samechannelsandsymbol, 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. - 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. - 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.
- Re-snapshot state-class channels. Book, positions, client positions, account and the best-effort
marginupdates are not backfillable and must be re-read, not replayed. Guaranteedmarginframes oftype: "marginCall"do have sequence-ordered history and come back through the backfill in step 5. The guaranteedrfqandbarrierschannels are state-only too: they never appear in a backfill, so re-readGET /v1/rfq/mine,GET /v1/rfq/open,GET /v1/rfq/quotes/mineandGET /v1/barriers, and discard bufferedrfqandbarriersframes at or below the snapshots'asOfSeq.
| Channel | Dedupe key, in addition to the cursor |
|---|---|
execution | tradeId + role |
order | orderId + frame type |
wallet | entryId |
eligibility | version, symbol (revision 3.63, per-market layer only) |
adl | tradeId |
venueEvents | eventId alone, except SNAPSHOT |
| Best-effort family | Re-read |
|---|---|
orderbook, matchableBook | {"op":"resync","args":["orderbook.25.BTC-USD-PERP"]} |
position | get.positions |
clientPosition | get.clientPositions |
margin | get.account |
adl indicator | get.adlIndicator |
ticker, markIndex, trades, status | get.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.
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 trip | Serialized ceiling | Pipelined |
|---|---|---|
| 80 ms | about 12 orders per second | bounded by your 40000/min order bucket and, on production, the 1,000/s STANDARD ceiling, not by latency |
| 20 ms | about 50 orders per second | same |
| 2 ms | about 500 orders per second | same |
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();