Trading WebSocket changelog
Consumer-facing history of the trading edge’s WebSocket contract
(asyncapi.yaml 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-<tag>.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 noorderIdon the ack and there cannot be — the owner mints one when it admits the command. Correlate on theclientOrderIdyou chose.cancelOrder→cancelOrderAck, echoing theorderIdthis edge resolved.orderIdis null when you named aclientOrderIdwhose order has not reached this member yet; the owner resolves it from its own pending book. A null is not a failure.orderRejectedon theorderchannel — the owner’s refusal, carryingreason,message, theobservedValue/limitValuedetail pair and theunitthat 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 thereason, and the samereasoncan 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, inNS).unitis null whenever the pair is null, and also forINVALID_FIELD, whose checks measure a price on one path and a quantity on another — take a nullunitbeside a stated pair as unlabelled, not as a default unit.
Two things to change in your client
- Switch on
msgTypeon theorderchannel. It now carries two shapes:orderUpdate(the applied image, as before) andorderRejected. Both areevent: order/topic: orderbecause they are one subscription. A client that upserts everyorderframe into its blotter map without readingmsgTypewill insert refusals as if they were orders.msgTypewas a required const in 1.0.0 for exactly this moment. - Do not key a map on
orderRejected. It is never in a snapshot —isSnapshotis pinnedfalseon 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 onlast: truewill not get them back, and that is the contract, not a gap.
Money and ids on the way in
-
priceandqtyare 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.orderIdis 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 whyorderRow.orderIdhas always beentype: 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
accountor byaccountId, and the instrument byinstrumentor byinstrumentId— 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_INSTRUMENTwhile the id still resolves. -
Sizing and precision come from reference data, and that API is not published yet.
priceis exact at the instrument’spriceScaleandqtyat itsqtyScale, and the owner further refusesTICK_SIZE_VIOLATION,QTY_STEP_VIOLATION,QTY_BOUND_BREACHandMIN_NOTIONAL_BREACHagainst 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:
priceandqtycarry the decimal-string pattern,cancelOrder.orderIdandinstrumentIdcarry theirs,priceis required forLIMITand refused onMARKETas aoneOf, andreduceOnlyisconst: falsebecause v1 refuses it outright. Nothing the server accepted before is refused now — these describe behaviour 1.1.0 already had. -
Name the order by
orderIdor byclientOrderId, 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
clientOrderIddeduplicates 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_CONFLICTdoes 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 theorderimage — 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
refusalanswer to a write guarantees nothing reached the stream, so re-sending after one is always safe. - Correlate on
command, not onclientOrderIdalone. A refused cancel arrives asorderRejectedcarrying the order’sclientOrderId— 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 anorderUpdatewithtransition: CANCEL_REJECTED, not as anorderRejected.
What a subscription delivers, and what it is needed for
ordercarries 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
orderbefore 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:
orderserves every live order whatever its age plus terminal orders inside a served window (24 h by default);executionhas no window and follows its orders;accountBalance,accountandcredentialnever remove a row — an emptied pool is reported as zero, and a retired account or a revoked credential is served with itsstatus. 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
subscribeack 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 aseqat or past the part’s. Topics named in onesubscribeare 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, andstreamAgeMsis how long ago this member applied a platform fact — pick a dead-socket timeout from the interval, never from that number. additionalProperties: falseis 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:
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
exchangeis one$refshared byaccountRow,credentialRowandvenueCapabilityRow, 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_spotandbinance_usd-mare two venues, not one with an attribute.capabilitieson 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.instrumentIdis one$refacrossorderRowandexecutionRow, 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 v2Instrument.id. Join throughinstrument, the platform symbol, once.
A snapshot part and a delta are two named shapes
Each of the six topic messages is a oneOf of <channel>SnapshotPart and
<channel>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
op: authwith a bearer token. The answer carries the grant, not the grantee:capabilitiesandexpiresAtNs, with no identifier in it. Read your own identity from the HTTP lane’sGET /me.op: subscribewith topic names from the closed set. The ack arrives first, then a complete image as numbered snapshot parts.- Upsert by key into a map that
part: 1cleared. Snapshot parts and deltas are the same shape and are applied the same way; the image is complete atlast: 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.
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.