> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.immix.xyz/changelog/trading/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.immix.xyz/_mcp/server. # Trading WebSocket changelog Consumer-facing history of the trading edge's WebSocket contract ([asyncapi.yaml](/api-reference/trading/overview) is the contract itself; this file says what changed, for the people who parse it). Every client-visible change has its section here, newest first, each stating what each side of the deploy window sees, headed by the `info.version` it lands with. **How to get this document.** It ships in every release's contract bundle — `immix-contracts-.tar.gz`, attached to the release with its `.sha256` — as `trading-ws/asyncapi.yaml` beside this file, with `manifest.json` naming the version and a digest per file. `info.version` and the sections below say what the contract is. ## 1.1.1 — every boolean constant states its type The contract's boolean constants now state their type: `success` on every acknowledgement and refusal, `isSnapshot` on every snapshot part, delta and `orderRejected`, and `reduceOnly` on `submitOrder`. 1.1.0 gave their values (`const: true`, `const: false`) without `type: boolean`, and a generator that reads an untyped constant as a string builds a client that expects a string where the frame carries a JSON boolean. Such a client parses no acknowledgement, refusal, snapshot or delta. 1.1.1 states `type: boolean` beside each value. Nothing else in the document changes. **Both sides of the deploy window.** The wire does not change: a member on 1.1.0 and a member on 1.1.1 send byte-identical frames, carrying exactly the values 1.1.0 documented. A client generated from 1.1.1 types these fields as booleans. A client generated from 1.1.0 keeps whatever its generator made of them; if it made strings, regenerate from 1.1.1. ## 1.1.0 — the door `submitOrder` and `cancelOrder` are **served**. 1.0.0 published them as prose and answered `UNKNOWN_OP`; this version publishes their messages and answers them for real. **Both sides of the deploy window.** A client generated against 1.0.0 keeps working unchanged: nothing it reads moved, and the six channels' row schemas are byte-identical. A client that starts sending `submitOrder` sees `UNKNOWN_OP` from a member still on 1.0.0 — which is the same answer 1.0.0 documented — and a real answer from one on 1.1.0. There is no frame a 1.0.0 client can receive from a 1.1.0 member that it could not receive before, except on the `order` channel: see the `msgType` note below. ### What is new * **`submitOrder`** → `submitOrderAck`. A success is an **offer, not an acceptance**: the command reached the stream, and nothing about admission is known yet. There is no `orderId` on the ack and there cannot be — the owner mints one when it admits the command. Correlate on the `clientOrderId` you chose. * **`cancelOrder`** → `cancelOrderAck`, echoing the `orderId` this edge resolved. `orderId` is **null** when you named a `clientOrderId` whose order has not reached this member yet; the owner resolves it from its own pending book. A null is not a failure. * **`orderRejected` on the `order` channel** — the owner's refusal, carrying `reason`, `message`, the `observedValue`/`limitValue` detail pair and the `unit` that pair is measured in. Read the pair's own nullness: whether a refusal states one is a property of the check that fired, not of the `reason`, and the same `reason` can arrive bare on one refusal and with two numbers on the next (a connection refused hard states nothing; one refused for exceeding its degraded grace states the duration and the grace, in `NS`). `unit` is null whenever the pair is null, and also for `INVALID_FIELD`, whose checks measure a price on one path and a quantity on another — take a null `unit` beside a stated pair as unlabelled, not as a default unit. ### Two things to change in your client 1. **Switch on `msgType` on the `order` channel.** It now carries two shapes: `orderUpdate` (the applied image, as before) and `orderRejected`. Both are `event: order` / `topic: order` because they are one subscription. A client that upserts every `order` frame into its blotter map without reading `msgType` will insert refusals as if they were orders. `msgType` was a required const in 1.0.0 for exactly this moment. 2. **Do not key a map on `orderRejected`.** It is never in a snapshot — `isSnapshot` is pinned `false` on that message — because a refusal is a fact no fold holds: nothing was minted, so there is no row to retain or restore. Refusals are live-only. A blotter rebuilt on `last: true` will not get them back, and that is the contract, not a gap. ### Money and ids on the way in * **`price` and `qty` are decimal strings, exact at the instrument's own scale.** A value that does not fit that scale exactly is **refused, never rounded**: submitting a quantity you did not write is worse than refusing the one you did. Trailing zeros are free — `"0.0350"` and `"0.035"` are the same number at scale 4. * **`cancelOrder.orderId` is a decimal string**, the same form this API renders it in. A JSON number is refused. An int64 does not survive a JSON number in a JavaScript client, which is why `orderRow.orderId` has always been `type: string`; accepting a number on the way in would have made the wire asymmetric in the one direction where the round-trip matters. * **Name the account by `account` or by `accountId`, and the instrument by `instrument` or by `instrumentId` — at least one of each pair, and both stated must agree.** Sending both is fine, and is what an interface holding the id it resolved and the name a trader typed will do; only a disagreement refuses (`MALFORMED`). The HTTP lane states the identical rule for the identical body, for both pairs. **Prefer the ids where you hold them.** A symbol is reference data's to change and an id is not, so a rename between your read and your submit turns a symbol into `UNKNOWN_INSTRUMENT` while the id still resolves. * **Sizing and precision come from reference data, and that API is not published yet.** `price` is exact at the instrument's `priceScale` and `qty` at its `qtyScale`, and the owner further refuses `TICK_SIZE_VIOLATION`, `QTY_STEP_VIOLATION`, `QTY_BOUND_BREACH` and `MIN_NOTIONAL_BREACH` against its tick, step, bounds and minimum notional. Those live on the refdata instrument row — and **a scale is not a tick**: a venue's tick can be coarser than the scale, so scales alone will not tell you whether a price is placeable. A reference-data API serving that row is planned and not yet available; until it ships you cannot pre-validate sizing, and a miss costs a round trip whose refusal names the observed value against the limit it breached. The contract will name that endpoint once it exists. * **The schema now states the rules the door enforces**, so a generated validator agrees with the server: `price` and `qty` carry the decimal-string pattern, `cancelOrder.orderId` and `instrumentId` carry theirs, `price` is required for `LIMIT` and refused on `MARKET` as a `oneOf`, and `reduceOnly` is `const: false` because v1 refuses it outright. Nothing the server accepted before is refused now — these describe behaviour 1.1.0 already had. * **Name the order by `orderId` or by `clientOrderId`, exactly one.** This one really is either/or: neither is refused because nothing is addressed, and both is refused too, because unlike the account pair there is no lookup that could tell you they agree. ### Your idempotency key, and how to recover an outcome you never saw * **`clientOrderId` deduplicates against `(organization, clientOrderId)` for the platform's whole retention window** — not merely while the order is live. Re-sending a submit under a key you already used **restates the existing order's outcome and never places a second order**. * **`CLIENT_ORDER_ID_CONFLICT` does not mean "you reused your own key."** It means another user of your organization holds that key. (1.1.0's first cut said "already in use by a live order" — that text was wrong on both counts and is corrected.) * **So the recovery is: re-send the identical submit under the same `clientOrderId`.** If the socket drops after an ack and the order is absent from the `order` image — which reads the same for "refused" and "not folded here yet" — re-sending is safe and is the procedure. Minting a new key instead is what risks two orders. * **A `refusal` answer to a write guarantees nothing reached the stream**, so re-sending after one is always safe. * **Correlate on `command`, not on `clientOrderId` alone.** A refused *cancel* arrives as `orderRejected` carrying the order's `clientOrderId` — the same key a refused *submit* carries — so matching on the key alone shows a cancel's refusal as though the order was never placed. A cancel can also fail a second way: the venue rejects it, which arrives as an `orderUpdate` with `transition: CANCEL_REJECTED`, not as an `orderRejected`. ### What a subscription delivers, and what it is needed for * **`order` carries the ORGANIZATION's orders** — every principal's, and the HTTP lane's as well as this one. Filter to what you sent before you show a refusal to a trader. * **Subscribe to `order` before you write.** A session that writes without holding it is acked and then never told what happened; the ack says the command reached the stream and nothing more. * **A write's ack precedes its outcome**, so you never see an outcome for a command you have not seen acknowledged. ### The refusal codes the door needs `INVALID_FIELD`, `INVALID_PRICE`, `INVALID_QTY`, `INVALID_CLIENT_ORDER_ID`, `UNKNOWN_INSTRUMENT`, `UNKNOWN_ACCOUNT`, `UNKNOWN_ORDER`, `NOT_SERVING`, `OWNER_ABSENT` and `BACKPRESSURE` join `refusalCode` in this version, beside the ops that write. They are the only additions to that enum, and a client generated against 1.0.0 meets them the first time it sends a write — which is why the enum has always said to tolerate a value it does not know and to read `retryable` instead. One of the ten is **reserved**: `OWNER_ABSENT` is published but not emitted by 1.1.0. When the orders owner is not established, a write refuses `NOT_SERVING` in this version. Handle `OWNER_ABSENT` as you would any other retryable refusal so that nothing has to change when a later version starts sending it, but do not expect to observe it yet. A `NOT_SERVING` or `BACKPRESSURE` refusal is `retryable: true` and is a statement about **this member**, never about your capabilities: retry, or reconnect to another member. ## 1.0.0 — the read edge The first version. One WebSocket session on which an organization's principals **watch** their trading state: authenticate, subscribe to any of six channels, receive a complete image and then every change to it. **This version is read-only, and says so.** There is no write op in the document and none is served: a frame naming one is answered `UNKNOWN_OP`, like any other op this build does not have. An earlier draft of this document claimed `submitOrder` and `cancelOrder` were "documented here but not yet served" while carrying neither — a sentence a client could act on and nothing could deliver. Order entry lands in `1.1.0`, with its envelopes published before it is served. ### What this version states that a client must read The behaviour below was always the behaviour; none of it changed. It was in the platform's plans rather than in the contract, which meant a consumer had to derive it — and a derived store model is the class of bug this document exists to prevent. * **Per channel: what the image holds, and how a row leaves it.** Each of the six messages now states its snapshot scope, its retention, and the signal that a row is gone. The answers differ and the differences matter: `order` serves every live order whatever its age plus terminal orders inside a served window (24 h by default); `execution` has no window and follows its orders; `accountBalance`, `account` and `credential` **never remove a row** — an emptied pool is reported as zero, and a retired account or a revoked credential is served with its `status`. Where a row *can* leave (`order`, `execution`, by the platform's own retention), **the release rides no wire**: there is no tombstone, and a resync is how a client learns. * **Ordering.** A `subscribe` ack precedes the first part of every topic it named; a topic's parts are contiguous, with no delta of that topic between them; every delta that follows carries a `seq` at or past the part's. Topics named in one `subscribe` are imaged in the order named. Across topics nothing is guaranteed and nothing needs to be. * **The inbound budget and the heartbeat.** 50 frames per second refuses, 500 closes with `4004`, both over a tumbling one-second window per session; a refused frame is applied to nothing and answers on its own op. The heartbeat is every 5 seconds by default, and `streamAgeMs` is how long ago this member applied a platform fact — pick a dead-socket timeout from the interval, never from that number. * **`additionalProperties: false` is about the producer, not you.** It is how this gateway's tests refuse to serve an undocumented field. **Ignore properties you do not know**: fields are added in minors and a client that refuses one will break on the first. ### `orderRow` gains `message` — the platform's words for its `reason` `reason` is a stable enum name, and for the reasons the **platform** decides — `OWNER_TIMEOUT`, `INSTRUCTION_FRESHNESS_EXPIRED`, `RECONCILE_PROOF` — no venue ever said anything, so `venueText` was null and a client had no sentence to show a human. The only route to one was a code-to-text table of its own: the hand-held table v2.0.0 removed, arriving back through the side door. **`message` is the platform's voice; `venueText` is the venue's.** They are not alternatives, and a client shows both: | the row says | `message` | `venueText` | | ----------------------------- | ------------------------------------------------------ | ----------------------- | | `reason: OWNER_TIMEOUT` | "the platform gave up waiting for the venue to answer" | `null` — no venue spoke | | `reason: VENUE_REJECTED` | "the venue rejected this order" | "insufficient margin" | | no reason (the ordinary path) | `null` | `null` | **Render `message`, then `venueText` after it when present.** A rule that rendered one *instead of* the other would hide the informative half exactly where it mattered most — on `VENUE_REJECTED`, where the venue's own words are the whole point. Nothing on the platform's wire moved: `message` is derived at this edge from the `reason` the row already carries, from a table that is exhaustive by construction — a reason appended to the platform's schema fails our build until someone writes what it says. It is **display only**: branch on `reason`, never on this string, which may be reworded in any release. ### Three vocabularies are published instead of promised * **`exchange`** is one `$ref` shared by `accountRow`, `credentialRow` and `venueCapabilityRow`, so the whole-string joins an order form makes are sound by construction. It is deliberately **not a closed enum** — the platform lists a venue by carrying a reference-data row, not by cutting a release, so a closed enum would make every new listing a breaking change. Today's members are published as examples. **The market-type split is part of the name**: `binance_spot` and `binance_usd-m` are two venues, not one with an attribute. * **`capabilities`** on the auth ack is the HTTP lane's seventeen scope strings, published as an enum and held equal to the platform's own by a lockstep test. Append-only: treat an unknown scope as a grant you cannot use, never as an error. * **`instrumentId`** is one `$ref` across `orderRow` and `executionRow`, and the description answers the cutover question outright: it is the platform's own reference-data key, an int64 as a decimal string, and it does **not** equal an immix-api v2 `Instrument.id`. Join through `instrument`, the platform symbol, once. ### A snapshot part and a delta are two named shapes Each of the six topic messages is a `oneOf` of **`SnapshotPart`** and **`Delta`**, discriminated by a `const` on `isSnapshot`. The part requires `snapshotId`, `part` and `last`; the delta does not carry them at all — not as optional fields, not at all. A generated client gets **two types to switch on** instead of one with three optionals to null-check. Named variants rather than a JSON-Schema conditional deliberately: `if`/`then` is stripped before generation by the toolchain the consumer generates with, so a conditional would reach their types as exactly the loose optionals it was meant to remove. `submitOrder.params` is built the same way, for the same reason. No frame changed by this. ### What a client does 1. `op: auth` with a bearer token. The answer carries the **grant, not the grantee**: `capabilities` and `expiresAtNs`, with no identifier in it. Read your own identity from the HTTP lane's `GET /me`. 2. `op: subscribe` with topic names from the closed set. The ack arrives first, then a complete image as numbered snapshot parts. 3. **Upsert by key into a map that `part: 1` cleared.** Snapshot parts and deltas are the same shape and are applied the same way; the image is complete at `last: true`. A repeat `subscribe` for a held topic re-pushes the image — that is the resync, and there is no separate op for it. ### The v2 → v3 map For `immix-control-centre`, which vendors the v2 spec at 5.0.0. The things it isolates in one place (channel names, topic shape, store keys) change freely; the things it depends on structurally (the `{op, reqId, params}` envelope, the auth flow, string numerics, account and instrument display names on rows) are kept; the things it works around (snapshot chunking, payload sniffing, flat frames, uncorrelated errors) are fixed so the workaround is deleted. | v2 | v3 | What it costs the client | | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{op, params:{token}, reqId}` auth | unchanged; the answer adds `session{capabilities, expiresAtNs}` | none — identity comes from `GET /me`, never from the ack | | topics as `{channel, account}` objects or dotted strings | bare channel-name strings from a closed set; no account or book | `buildApiTopic` returns a string; the per-account subscription managers collapse to one subscribe per channel | | op-less `errorEvent` (+ `429`/`503` on the op) | every failure is an op-response with `code` + `retryable`; `op: "error"` only for a frame whose op could not be read | the `errorEvent` bridge is deleted; add `retryable`-driven backoff, which is the 429 handling that was missing | | integer `code` (open) + `message` | string `RefusalCode` (closed, tolerated-unknown) + `message` + `retryable` | key on `BACKPRESSURE` rather than `503` | | `order`: `orderUpdate` / `orderCancelReject` / `orderAmendReject`, `msgType` sniffed | `order`: `orderUpdate` / `orderRejected`, `msgType` **required on every message** | delete the payload heuristics. v2's single `orderCancelReject` splits in two, because v2 conflated two events: the **platform refusing the cancel command** (unknown or already-terminal order) arrives as `orderRejected` `command: CANCEL`, correlated by `orderId` or by the `clientOrderId` the cancel named; the **venue refusing a cancel already instructed** arrives as an `orderUpdate` with `cancelRequested: false` and `transition: CANCEL_REJECTED`, folded into the row it describes, so applying a status stays a plain upsert | | `fills` | `execution`, rows keyed `executionId` | rename the channel; row key `fillId` → `executionId`; the fill-echo fields on the order row are gone — join by `orderId` | | `externalBalance` (`locked`) | `accountBalance` (`held`) | rename; column `locked` → `held` | | `accountStatus` (accounts + connection status in one) | `account` + `credential`, two channels | the directory reads `account`, health reads `credential`, joined by `credentialId` | | `venueCapability` nested per class | `venueCapability` flat rows, `timeInForce` as the accepted set | none — the order form can finally read it | | `gatewayTimeNs`, `keyId` | `seq`, `seqTs` | none (declared, never read) | | snapshot: one frame, silently split past a cap | `isSnapshot` + `part` + `last` | delete the two defect docblocks; accumulate until `last` | | `PENDING_NEW` / `NEW` / `PENDING_CANCELED` / … | the platform's status lattice **plus** `live`, `terminal`, `cancelRequested` | `TERMINAL_STATUSES` becomes `terminal`; the `PENDING_*` docblocks go | | `rejectReason` (100+ values) + `rejectSource` + `rejectMessage` on the order row | admission refusals are `orderRejected`; venue refusals are `orderUpdate{reason, venueCode, venueText}` | read `message`/`venueText`; no client-side reject registry | | `unsolicited: true` orders | `execution` with `orderId: null` | the "unknown order" rendering goes; a discovered-execution row instead | | `internalBalance`, `accountRisk`, `bookRisk`, `externalPosition` | reserved or absent | no v3 target in this version — see *Reserved names* below | | `executionOrder`, `strategy`, `bookName`, TWAP/PEG/ICE params | absent | no v3 target until the algo platform; the flat-frame router branch is deleted | ### Money is served at the scale each value states Every money value is a decimal string rendered from the scale **the value itself carries**, not from reference data. Parse it as a decimal and compare numerically. **Do not read meaning into the digit count.** The same amount recorded at different precisions serves different strings — `"2.5"` and `"2.500000"` are the same number — because each states the precision its own fact carried. The string depends on the value alone, so it does not change as reference data catches up, and the HTTP lane serves the identical string for the same value. ### Reserved names `position`, `ledgerBalance`, `transfer` and `reconciliation` are **real names whose domains have not landed**. Subscribing to one answers `TOPIC_NOT_ACTIVE`, which is deliberately different from `UNKNOWN_TOPIC`: the first means wait for a release, the second means fix your spelling. They are activated additively, with a minor version bump and a section here. ### Known gap in this version `execution.feeUsd` is always `null`. The fee's USD conversion needs signed wide-integer rendering that is not in place yet; `null` is inside the field's contract — it never means the un-converted amount — so a client that treats null as "no value" is already correct and will keep working when the number arrives. `notionalUsd` and `fxSource` are populated. ### Session lifetime A session is closed when its credential lapses, when the register withdraws the principal's membership, or when it floods the inbound budget. A `sessionClosing` frame naming the reason precedes the close, and `sessionExpiring` warns one lead time before a lapse so a client can re-authenticate over the open socket and keep its subscriptions. **The reason arrives twice, on purpose.** The `sessionClosing` frame carries it with the `retryable` flag; the WebSocket close frame that follows carries the same number as its close code (RFC 6455's private-use range — 4001 `SESSION_EXPIRED`, 4002 `AUTH_FAILED`, 4003 `PRINCIPAL_REVOKED`, 4004 `RATE_LIMITED`). Read whichever suits your client. A browser reads `event.code` off the close event whether or not it processed the last message, so a client that only ever sees the close still knows to mint a fresh credential rather than retry the same one — which is the difference between recovering and reconnect-looping. The identity is **immutable for the life of the socket**: re-authenticating with a credential for a different principal is refused, and the session keeps the one it was opened with.