REST API changelog
Every client-visible change to the REST API, newest first, by area: each area lists its changes under the version of its part of the API that they shipped in.
Organisation, risk and trading · Data
Organisation, risk and trading
Consumer-facing history of the trading gateway’s HTTP contract
(openapi.json is the
contract the gateway serves at /openapi.json; 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.
From 0.1.0 the document carries its own version — info.version, semver, bumped by hand
with the section that describes the change; what bumps which part is the
API guide’s Versioning section. Every section from
there on is headed by the version it lands and its date (## <version> — <YYYY-MM-DD> — …),
and the contract test holds info.version equal to the newest heading here, so neither moves
alone. The wire schemas the message shapes derive from are named beside it
(info.x-immix-register-schema, info.x-immix-orders-schema, info.x-immix-custody-schema,
info.x-immix-algos-schema). The sections below 0.1.0 are headed by the register schema’s
version (114.v6, …) — which info.version carried until then — or by a word for what
changed where the schema did not move, and are dated the same way.
0.14.0 — 2026-10-05 — WebSocket tickets
One operation under a new WebSocket tickets tag: POST /v1/ws-tickets exchanges
your bearer token for a short-lived, single-use ticket that opens the trading WebSocket
from a browser, which cannot send a bearer token on the WebSocket handshake. Nothing existing
moves. The API guide’s WebSocket tickets
section has the whole flow.
- The request is
{"stream":"trading"}—streamis an enum with one member today (WsTicketRequest); anything else is 400MALFORMED_REQUEST. - The response is
{"ticket", "expiresAtNs", "stream"}(WsTicket), servedCache-Control: no-store.ticketis opaque and URL-safe: put it in the WebSocket URL as?ticket=unchanged.expiresAtNs(an epoch-nanosecond string) is when it stops opening sessions: 10 seconds after it is created, or your token’s expiry if that comes first. - Refusals. The route is admitted like every other (401, 403,
NOT_PRIMED); a token with no expiry cannot create a ticket (401UNAUTHENTICATED); at most 60 tickets a minute per user (429RATE_LIMITEDwithRetry-After; a request refused for its body or its token costs none of it); and a member that holds no signing key answers 503TICKETS_UNAVAILABLE, a newError.code— a retry there changes nothing, and itsretryableisfalse. - No
Idempotency-Key, unlike every other write: a replayed answer would hand a retry the same single-use ticket, so every call mints a new one. - From a browser on another origin the call needs CORS, which the gateway does not serve yet: call it from the gateway’s own origin or through your backend until it does.
- The first
/v1route. Every other route keeps its path.
During the deploy window: a member below 0.14.0 does not know the route and answers 404;
one on 0.14.0 mints, or answers 503 TICKETS_UNAVAILABLE where no signing key is configured.
A ticket opens a session once the trading WebSocket authenticates on the handshake — its own
changelog’s 1.4.0 says so; a client that is not a browser needs no ticket, and sends its
bearer token on that handshake.
0.13.0 — 2026-10-05 — Every collection is a page
Every collection now answers a page, and can be walked a page at a time:
- Three parameters on every collection.
limitis the page size, 1 to 100; a larger value is clamped.cursoris thenextCursororprevCursorof a page you were served, sent with the same filters.include=totaladds the exact number of rows matching the filters. Withoutlimitthe whole collection is served as one page, as before. - Four members beside
globalSequenceanditems:nextCursor,prevCursor,totalandasOfSeqTsNs. Each is on every page,nullwhere it does not apply. - A second position token.
asOfSeqTsNs, and anX-Immix-As-Of-Seq-Tsheader on every read served from the platform’s state — the collections, single rows and their 404s,GET /meandGET /operations/{key}— are the platform’s timestamp of the changeX-Immix-Global-Sequencenames. A response holds every change at or before the position it states and may already hold later ones. Before a member has applied its first change the header is absent andasOfSeqTsNsisnull. A write’s answer carries the position of the fact that answered it and no timestamp, as before; the one write response that gains the header is the 404 for a path entity the member does not hold, which is a read’s miss. - One order per collection, stated on its operation.
orders,executionsand an algo instance’s orders are newest first, by descending id. This moves three orders:ordersandexecutionswere unordered by contract and ascending in practice, and an instance’s orders were published ascending. The register’s collections (orgs,users,credentials,accounts,account-addresses,portfoliosand the three policies) were unordered and now ascend by id. Balances, algo instances, an instance’s documents and the algo catalogue keep the order they had. - A query parameter a read does not take is refused 400
MALFORMED_REQUEST. It was ignored. A misspelt filter no longer serves the unfiltered collection, and a single row takesviewalone: a client that re-sends its list’s filters on a row read, such asGET /users/{id}?status=DISCOVERED, is refused.GET /meandGET /operations/{key}take no parameter. A query string that does not decode is refused the same way. INVALID_CURSOR(400) joinsError.code: a cursor that does not decode, or that came from another query — another collection, filter, view or organisation. Drop it and fetch the first page.
Pages are read from live state, so merge them by the row’s key and never append them, and take a finished walk as complete as of its first page’s position. The guide’s Reads section has the walk.
In the document, each collection’s 200 is the new named schema PageInfo together
with the collection’s own items: a regenerated client gains one type and four optional,
nullable members on every list response. Every collection gains the parameters Limit,
Cursor and Include, and every read response the header AsOfSeqTs. The four members
become required in a later version, once no member below this one is left.
Across the deploy window, a 0.13.0 member serves every collection in its stated order with
all six members, honours the three parameters and refuses a parameter it does not take, on a
collection and on a single row alike. A 0.12.0 member ignores limit, cursor and
include, ignores any other parameter it does not know, and answers the whole collection in its own order with globalSequence and items
alone and no X-Immix-As-Of-Seq-Ts. A walk can cross the two versions: its first page
from a 0.13.0 member, then the cursor request answered by a 0.12.0 member with the whole
collection and no nextCursor. A reader that merges pages by key and takes a page without a
nextCursor as the end then holds no row twice. Merging does not drop a row that left the
collection after its page was read: as on any walk, take the result as complete as of the
first page’s position (the guide’s Reads). A reader
that needs strict pages sends limit only once the environment runs 0.13.0 throughout.
0.12.0 — 2026-10-05 — Every refusal names its request and says whether to retry
Every refusal now says three more things, in the same envelope:
error.requestIdis the request’s id. Every response carries the same value in anX-Request-Idheader, a success included, so quote it when you ask about a request. Send your ownX-Request-Id— 8 to 128 of[A-Za-z0-9._-]— and it is the id used; anything else is replaced with a fresh one.error.retryablesays whether sending the identical request again, itsIdempotency-Keyincluded, afterRetry-Afteror a short pause, can succeed with nothing else changed. It istruefor a 429 and a 503 and forINTERRUPTED, andfalseotherwise, with two kinds of exception. Four 503s arefalsebecause they state how the service is deployed:BALANCES_UNAVAILABLEandALGOS_UNAVAILABLEalways,INTAKE_UNAVAILABLEon a member that stores no credentials, andNOT_SERVINGon a read replica. And a refusal a record owner judged is alwaysfalse: it is the recorded answer to itsIdempotency-Key, and that key sent again replays it. So the orders owner’s 429RATE_CEILINGisfalse: send the order again under a fresh key onceRetry-Afterhas passed. The guide’s Retrying section lists every code that istrue.error.messageis now on every refusal. Where neither the gateway nor the platform has something specific to add, it is a standing sentence for the code. It is still display text: never parse it.
A request no route answers is refused in the envelope too. An unknown path answers
404 ROUTE_NOT_FOUND, before authentication. The code is its own, so NOT_FOUND keeps
one meaning, no such row in your organisation, and a misspelt path, or a member older than
the operation you call, never reads as a missing row. A path asked with a method it does not
take answers 405 METHOD_NOT_ALLOWED with an Allow header; it answered 404 before. A
request whose Content-Length declares a body past 1 MB answers 413
PAYLOAD_TOO_LARGE, one whose Content-Type names a charset the gateway cannot read
answers 400 MALFORMED_REQUEST, and an unexpected fault answers 500 INTERNAL_ERROR. Each
of these left with the web server’s own body until now. Error.code grows by
ROUTE_NOT_FOUND and METHOD_NOT_ALLOWED. Two answers still carry no envelope: a request
the web server turns
away before the gateway reads it, such as a request line it cannot parse, and a failure too
deep for the gateway to answer.
In the document, the envelope’s inner object is now the named schema ErrorBody, and
Error.error references it: a regenerated client gains one type name where it had an
anonymous one, and no field moves. message, requestId and retryable are published
optional in this version, because a member below it does not send them all; they become
required in a later version, once no such member is left. X-Request-Id is declared on
every operation, as an optional request header and as a header of every response: a
regenerated client gains one optional parameter on each operation.
Across the deploy window, a 0.12.0 member sends all four members of error on every
refusal and X-Request-Id on every response. A 0.11.x member sends code, sometimes
message, and neither requestId nor retryable; it sends no X-Request-Id and ignores
yours, answers a wrong method 404, and answers an unknown path or an oversized body with a
body that is not the envelope. Where retryable is missing, decide by the status and the
code, as before: treat a record owner’s refusal as final for its key, and bound any retry of
INTAKE_UNAVAILABLE and NOT_SERVING, whose deployment case that member cannot tell you
apart from an outage.
0.11.4 — 2026-10-04 — A write a stalled member could not look up answers 503, never a 202 that leads nowhere
A member that cannot look a request’s key up within the write deadline now answers 503
NOT_SERVING: nothing was stored or sent, so retry. That covers POST /credentials and
POST /credentials/{id}/rotate, and POST /orders and the algos document writes when a name
the body states resolves to nothing here. Until now it answered 202 with a Location for an
operation the request never took, which polling could answer 404 UNKNOWN_OPERATION. The
Unavailable response’s description names the case.
Across the deploy window: a gateway below 0.11.4 answered such a request 202 with a
Location; from 0.11.4 it answers 503 NOT_SERVING. On either side, retry such a request
under the same key — below 0.11.4 once its Location answers 404, from 0.11.4 at once.
0.11.3 — 2026-10-04 — A retried credential key stores nothing the member already answered
POST /credentials and POST /credentials/{id}/rotate now ask the member’s journal before
they store any key material. A retry of a key the member answering it already holds stores
nothing and answers as any retry does: the first attempt’s answer, replayed with
Idempotent-Replay: true and its position; the attempt still being carried, attached to; or
409 IDEMPOTENCY_KEY_REUSED for a key used on another record or resource. The intake’s
store-time refusals — 409 INTAKE_KEY_REUSED, 429 RATE_LIMITED, and 503
INTAKE_UNAVAILABLE when the store refuses the write — meet only a key the member holds no
answer for.
The guide’s Writes section has the rule.
Across the deploy window: a gateway below 0.11.3 answered most such retries the same
way, with two exceptions. Near the end of the time a member keeps a key’s answer, it stored
the legs again before replaying, and could answer INTAKE_KEY_REUSED, RATE_LIMITED or
INTAKE_UNAVAILABLE where the replay belonged. And a retry sent while the first attempt was
still being carried, if the platform then lost that attempt in transit, could be accepted
while the key material it named was disabled in the store — a credential no connector can
use. From 0.11.3 the late retry replays its key’s answer, and the retry sent mid-flight
answers the lost attempt’s 503 — the next retry then stores the legs and sends them. On
either side, retry under the same key with the same legs; new legs are a new key.
0.11.2 — 2026-10-02 — A retried order key replays, whatever its names resolve to now
On POST /orders, the gateway’s own 422 for a name — INSTRUMENT_NOT_LIVE for a symbol the
member holds no instrument under, ACCOUNT_NOT_ACTIVE for a display name no live account of
your organisation holds — now stands only for a key the member answering it holds no answer
for. A retry of a key it already holds answers as any retry does, whatever the name resolves
to now: the order the first attempt placed, or the platform’s refusal of it, replayed with
Idempotent-Replay: true and its position; a submit still being carried, attached; a key used
for another operation, 409 IDEMPOTENCY_KEY_REUSED. A key the member holds nothing for meets
the 422 as before — the same message, nothing sent, no position header, and the key free to
retry. The 422 now comes from the member’s journal, so a member that cannot carry writes
answers 503 NOT_SERVING or OWNER_ABSENT, or 429 INBOX_FULL, ahead of it, as it does any
write. The guide’s Orders section has the rule.
Across the deploy window: a gateway below 0.11.2 answered such a retry with the 422 —
after the account was renamed or deactivated, or once the member stopped holding the symbol —
while the order its first attempt placed stood, and its message said nothing was sent: a
client that minted a fresh key on it placed a second order. From 0.11.2 the retry replays
the first attempt’s answer. On either side, after the gateway’s 422, fix the name and retry
under the same key: it is the order’s clientOrderId, so a retry under it can never place a
second order, and a fresh key can.
0.11.1 — 2026-10-02 — Name an algo’s account and instrument
A config document — the document of POST /algos/instances and of
PUT /algos/instances/{id}/config — may now name an account or an instrument wherever the
algo’s params schema types a reference, instead of stating its id: an account by its display
name in your organisation ("account": "okx-main"), an instrument by its platform symbol
("instrument": "OKX@BTC/USDT") — the names POST /orders already accepts. A JSON number is
still an id, and a JSON string is a name: "700" names an account called 700.
The gateway resolves each name when it receives the request and sends the document with the
name replaced by the id it names, keeping the name beside it as a comment, so a document you
read back (GET /algos/instances/{id}/configs/{seq}, or the config write’s 200) shows
"account": 118310760 /* okx-main */. Everything else in the document — your comments, the
layout, every other value — is kept exactly as you wrote it. A name is resolved once: a later
rename never moves a running instance, and submitting the document again resolves it again.
When the comments would take the document past its 6,144-byte bound — or for a name a comment
cannot carry — the ids are written without them.
- A name nothing here holds is 422
REFERENCE_UNKNOWN_ACCOUNTorREFERENCE_UNKNOWN_INSTRUMENT— the codes the platform gives an id it does not know — answered by the gateway with nothing sent: no position header, nothing journaled, and the key retries fresh. The message names the value’s path and the name. - The 6,144-byte bound holds for the document as written and as sent: one that fits as
written but not once its names are replaced by their ids is 413
DOCUMENT_TOO_LARGE. - A retried key the member answering it already holds replays its answer, as before, whatever its names resolve to now: a refusal answers only a key’s first attempt.
- A version that member has not caught up with yet — one loaded a moment ago — sends the
document as written, and the platform refuses its names 422
FIELD_TYPE_MISMATCH— a fact, so the key is spent: onceGET /algos/versionslists the version, retry under a new key.
Across the deploy window: a gateway below 0.11.1 sends a named document as written, and
the platform refuses it 422 FIELD_TYPE_MISMATCH at the reference’s path — a fact, so the key
is spent. From 0.11.1 the name is resolved. A document that states ids crosses unchanged on
both sides.
0.11.0 — 2026-10-01 — The algos surface
Twelve operations under a new Algos tag serve your organisation’s algo instances —
strategies the platform runs for you, each created from a loaded algo version with a
config document — and the reads around them. Nothing existing moves.
The reads. GET /algos/instances lists every instance your organisation ever created,
DELETED ones included, in id order; GET /algos/instances/{id} serves one, with its
attribution (who created it, who or what last changed it — lastUpdatedByUserId is null
when the engine or the platform’s own rule moved it, and lastCause says which) and
ETag: "<configSeq>", the sequence number of its latest document.
GET /algos/instances/{id}/configs and …/configs/{seq} serve the document history: each
document verbatim (the JSONC text you sent), its outcome (STAGED, APPLIED, REFUSED),
outcomeAtNs (null while the engine’s verdict is out), the engine’s detail on a refusal,
and the account and instrument references the platform projected from it.
GET /algos/instances/{id}/orders is exactly that instance’s orders, in the OrderUpdated
row shape (algoInstanceId set, userId 0). GET /algos/versions is the catalog — the
platform’s, not an organisation’s: every loaded version per engine pool, with its manifest
and the JSON Schema a document for it must conform to. The algos reads have one surface
(?view= other than latest is 400) and no status filter (?status= is 400). Another
organisation’s instance is 404 on every route, indistinguishable from an absent id.
The writes all need algos:manage, judged at the platform (a refusal is a fact with the
position header; the door checks nothing but the request’s shape). On every algos write the
Idempotency-Key is the command’s commandKey — one grammar, 1 to 64 of
[A-Za-z0-9._~-]; a body commandKey may restate it and never contradict it — and a body
userId is refused 400: the principal is stamped. POST /algos/instances creates an instance
with its document in one request (algoVersionId, pool, displayName, remark,
document); the document is one JSON string holding the JSONC text, and a document past
6,144 bytes of UTF-8 is 413 DOCUMENT_TOO_LARGE at the door — nothing is sent, and
the key retries fresh. The create answers 201 with the row once the platform has recorded
both the instance and its first document (configSeq 1), or 202 with Location: /operations/{key} past the sync deadline, as every write; the operation record’s outcome
then names the instanceId and the configSeq the pair minted. PUT /algos/instances/{id}/config stages a new document (remark, document), while the
instance runs too, under If-Match: "<configSeq>" — the instance’s ETag; absent or *
is unconditional, a weak validator is 400 INVALID_IF_MATCH, and a stale one is 412
CONFIG_SEQ_CONFLICT with currentVersion and a fresh ETag (re-read, reapply, retry).
It answers 200 with the staged document — no ETag: documents carry no version — and the
engine’s verdict is a later state, read on …/configs/{seq}; one document awaits a verdict at
a time (409 CONFIG_PENDING). POST /algos/instances/{id}/start, /stop and /delete
take { remark } and no If-Match (one is 400 INVALID_IF_MATCH: stopping is the safe
direction, and no version race may lose it); each answers 200 with the row. A delete is
terminal: the row and its documents stay readable, the id is never reused. POST /algos/halt
stops every started instance of your organisation and answers 200 with count — 0 when
nothing was started, so the call always has an answer.
Error.code grows by the door’s 413 DOCUMENT_TOO_LARGE and 503 ALGOS_UNAVAILABLE,
and by the algos owner’s reasons — the AlgoRejected.reason enum, published in the document;
the guide’s Algos section places each under its
status. OperationRecord.outcome.family gains algos, and outcome gains instanceId and
configSeq. info.x-immix-algos-schema names the wire schema the algos message shapes
derive from (121.v0).
Across the deploy window, a 0.10.0 member answers every /algos path 404 — a route it does
not serve — and a 0.11.0 member deployed without the surface answers them 503 ALGOS_UNAVAILABLE after admission, with no position header. The 503 is the environment’s
posture, not a transient: it clears when the environment gains its algos owner, never by
retrying, so a client renders “not available here” on it rather than an empty table, as for
BALANCES_UNAVAILABLE. Every other route is unaffected on both sides.
0.10.0 — 2026-10-01 — Binance spot accepts balances-only credentials
POST /credentials now accepts a balances-only Binance spot credential — exchangeId 100
with canReadBalances: true and canTrade: false — where it answered 422 VENUE_UNSUPPORTED before. In CredentialMaterial’s x-immix-intake-venues, binance_spot
now names accountGroup: "binance-account" instead of null. Nothing else moves: the legs
are the same two (key, privateKey), and a canTrade row is served by binance-oe as
before. Such a credential’s scopeUnobserved is true on Binance’s demo environment, which
serves no read of a key’s own scope (0.9.0).
Across the deploy window, a 0.9.0 gateway still refuses the row VENUE_UNSUPPORTED and a
0.10.0 gateway accepts it. A client that retries a refused submission after the upgrade
stages it normally.
0.9.0 — 2026-10-01 — scopeUnobserved on the credential
A credential gains scopeUnobserved (boolean, optional — absent reads false). It is
true when the pre-approval probe answered but the venue offers no way to read the key’s
own permissions — today Binance’s Demo Mode. On such a row observedPermissions is empty
because nothing stated a scope, not because the key has none, and the approval’s permission
checks (SCOPE_INSUFFICIENT, SCOPE_MISMATCH) are not applied to it: render it as “scope not
observed”, never as “no permissions”.
false means what observedPermissions always meant: the venue stated the key’s scope, and
approval judges it.
Across the deploy window: a gateway still on 0.8.0 omits the field — read it as false, the
value every credential had before. After it, every credential carries it; only a credential
probed by a venue without a permissions read is ever true. No existing field changes.
0.8.0 — 2026-10-01 — Binance spot credentials
POST /credentials and POST /credentials/{id}/rotate accept a Binance spot API key
(exchangeId 100) beside OKX. CredentialMaterial is now one schema per venue, a
oneOf of OkxCredentialMaterial (key, secret, passphrase — unchanged) and
BinanceSpotCredentialMaterial (key, privateKey). The body’s exchangeId names the
venue, and the material must carry exactly that venue’s legs.
Both sides of the deploy window. An OKX body is unaffected: the same three legs validate
against the same closed schema, now under its own name. A Binance body sent to a member
below 0.8.0 is refused 422 VENUE_UNSUPPORTED, because that member serves no Binance
connector. Roll the fleet before onboarding Binance keys.
What to do.
- Regenerate, and expect generated source to change. The wire is unchanged for OKX; the
generated types are not, and how they change depends on the generator:
- Structural generators (TypeScript — openapi-typescript and the like) emit
CredentialMaterialas a union, so an OKX object literal still type-checks whereCredentialMaterialis expected. Code that reads a leg (.secret) off the union no longer type-checks until it narrows toOkxCredentialMaterial. - Nominal generators (openapi-generator’s Java, Kotlin and C# targets) emit
CredentialMaterialas a oneOf wrapper holding an instance, and the three legs move to the new classOkxCredentialMaterial. Code that built the material directly —new CredentialMaterial().key(k).secret(s).passphrase(p)— stops compiling: build anOkxCredentialMaterialand wrap it inCredentialMaterial.
- Structural generators (TypeScript — openapi-typescript and the like) emit
- For Binance, send an Ed25519 key. Binance’s order-entry session accepts no other key
type, so the intake reads
privateKeyas an Ed25519 PKCS 8 key and refuses anything else — an HMAC secret, an RSA key — with 400MALFORMED_REQUESTnamingmaterial.privateKey, before anything is staged. Send the API key Binance issued askeyand the Ed25519 private key you registered asprivateKey(PKCS 8 form, PEM or its base64 body). - Trading keys only. A Binance credential must set
canTrade. A balances-only Binance credential is refused 422VENUE_UNSUPPORTED, because no Binance balance connector exists yet;x-immix-intake-venuesshows this as a nullaccountGroup.
0.7.0 — 2026-10-01 — algos:manage: the capability enum gains the algos surface’s one scope
The register’s UserCapabilities set gains its eighteenth choice, algosManage, and this
document follows: one value added to the capabilities enum — on the user row, GET /me and the two user bodies — which is why this is a minor (regenerate: a typed
client’s capability enum gains a member). No route, code or request of yours changes.
What changed
capabilitiesmay carryalgos:manage, served in declared order afterorgs:approve. It is the one bit that admits every command of the algos surface (create, configure, start, stop, delete, halt — the routes arrive with their own section) and the organisation’s authority for its robots to trade: an algo instance’s orders carry no user (0.6.0), so noorders:tradesits anywhere in their chain, and this bit is granted as a trading permission. It is in no preset —TRADERstaysorders:trade— so grant it by scope string on the user create and restate routes, as a Trader’s bit is granted outside the maker-checker presets; the door refuses a body naming it beside a preset whose expansion lacks it, as for any other scope.info.versionmoves from0.6.0to0.7.0;info.x-immix-register-schemastays114.v7— a set choice is a bit in a field that already exists, so the wire layout and the schema version are unchanged.
Each side of the deploy window
A 0.6.0 member never serves the scope — the serializer walks the scopes its build names,
as the field’s description states, so a bit it cannot name is dropped from the array — and
refuses a body naming it (400, an unknown scope); a 0.7.0 member serves it on every user
granted it and accepts it in a body. A client generated from 0.6.0 whose capability enum
is closed regenerates before it reads a user holding the new scope (the closed-enum
trade-off 114.v6 recorded); a client that does not validate the enum keeps working against
either. Deploy order is register → gateway → grant: a gateway that admits the scope
forwards it to an owner that recognises it, never the other way round, and the scope is
granted to nobody until every gateway member serves 0.7.0 and the platform’s other readers
of the user record (the orders owner, the persistence writer) run this release — an older
reader drops a bit it cannot name, and keeps it dropped across its restarts. The org-register pair restarts
lockstep (its owner’s snapshot version moves: it now grants the bit its previous version
refused, so a mixed pair would answer one grant two ways). The roll itself is the reason:
a user read through a 0.6.0 member and restated through a 0.7.0 one loses the scope —
PUT replaces the whole set, the array the old member served never carried it, and no
member can tell that restate from a revocation. The scope is in no preset, so a grant is
always an explicit act through a 0.7.0 member; holding grants until the roll completes
leaves nothing an old member could drop, and a grant made mid-roll anyway is exposed until
the roll completes and is restated after it. The loss is of authority, never a grant.
0.6.0 — 2026-10-01 — algoInstanceId on the order reads: an order placed by an algo instance carries the instance and userId 0
The orders wire schema moves to 111.v2, appending algoInstanceId to the four order
messages, and this document follows: one property added on two reads, which is why this
is a minor (regenerate: a generated row type gains an optional property). No route, code or
request of yours changes; what you send on POST /orders and POST /orders/{id}/cancel is
exactly what 0.5.1 took, and the platform’s autonomous algos — the reason for the field —
place their orders on a lane of their own, never through this edge.
What changed
OrderUpdatedandOrderRejectedgainalgoInstanceId(x-since-schema-version: 2, nullable, never required — an appended field). On an order a human placed — every order this edge has ever served, and every order placed through it — it servesnull, anduserIdis the user it always was. On an order an algo instance placed it names the instance, anduserIdserves0: an algo’s order carries no user. Exactly one of the two is ever set.GET /ordersis the organisation’s list and serves them like any other order; a client that filters it by its ownuserIdwill not see them, by design — which robot placed an order is the instance, and which human is behind the robot is that instance’s own record, served by the algos surface when it lands. ReadalgoInstanceId != null(neveruserId == 0) as “machine flow”.- The two write bodies stay as they were, and now say so.
SubmitOrderBodyandCancelOrderBodypublish neitheruserIdnoralgoInstanceId: both are the gateway’s to stamp — the session’s user, and an instance of0— and a body that states either is refused 400MALFORMED_REQUEST, as a bodyuserIdalways was. No session can dress a click order as an algo’s. - A human may still cancel an algo’s order.
POST /orders/{id}/cancelon an order whosealgoInstanceIdis set is accepted for a user of the same organisation, exactly as for any order the organisation holds; the row keeps itsalgoInstanceIdand itsuserIdof0— the cancel’s actor rides the cancel command, never the order. - The trading edge’s WebSocket
orderchannel does not carryalgoInstanceIdyet. Its row keeps servinguserIdas before and no instance field, by choice: no machine-flow order can exist before the platform release that lets the algos place orders, and that release’s version of the trading-ws contract adds the field (nullwhere this document servesnull), amends itsuserIdprose and ships a section of its own changelog. Until then a subscriber there has no machine-flow rows to read, and the rule above — the instance field, neveruserId == 0— is the one to build on for both lanes. info.versionmoves from0.5.1to0.6.0;info.x-immix-orders-schemamoves from111.v1to111.v2. The API guide’s Writes and Orders notes carry the rule.
Each side of the deploy window
A 0.5.1 member serves rows without algoInstanceId — the property is absent, not null —
and a 0.6.0 member serves it on every order row, null on every order placed to date. A
client generated from 0.5.1 keeps working against either (an unknown property is ignored;
a null optional is one it never had to read); one generated from 0.6.0 reads null from
a 0.6.0 member and absent from a 0.5.1 member for the same order — treat the two alike.
Between an older gateway member and the newer orders owner the wire frames carry the field
and the older member reads past it, exactly as an appended field is meant to be read; no
stream reset rides this release. Nothing you send differs between the two.
0.5.1 — 2026-09-25 — the guide and this changelog move with the gateway’s new name
No wire change: no path, parameter, body, schema, status, header, operation id or tag
moves, and info.title is still Immix API. The gateway that serves this document is now
called the trading gateway — it was the org gateway — and the two documents it links
were renamed with it.
What changed
- The links to the API guide and to this changelog — in
info.description, inexternalDocs.urland in every operation description that points into the guide — follow the two documents to their new addresses. Before the deploy the document links the old addresses; after it, the new ones, and the old addresses no longer resolve. POST /credentials’ description opens its list with “Refusals” where it read “Its refusals” — the same six codes and the same link, in fewer characters.
What to do. Nothing, unless you store the guide’s address: take the new one.
0.5.0 — 2026-09-25 — name the account, not just its id
POST /orders gains an account alias for accountId: name the account by its display
name within your organisation ("okx-main") instead of its numeric id. The same rule the
instrument alias already states, spelled the same way — at least one of the two, both
stated must agree — and accountId leaves the body’s required list so a body naming only
the name validates against the published document.
Both sides of the deploy window. Nothing moved: a client sending accountId is
unaffected, and accountId is still the stable address. A client that starts sending
account gets 400 MALFORMED_REQUEST (“the order names no account”) from a member below
0.5.0, because the alias is stripped there and no id is left — so roll the fleet before
switching, or send both spellings through the window. Regenerate: the body’s schema gained a
property and its anyOf became an allOf of two anyOfs, one rule per alias.
Only the id crosses. The edge resolves the name and puts the id on the wire. A name is your organisation’s to change; a command carrying one would name a different account after a rename, and served rows would have to answer in a name the register no longer holds. The same reasoning already keeps the instrument symbol off the wire.
A new 422: ACCOUNT_NOT_ACTIVE — a display name no live account of your organisation
holds. Answered at the door, where nothing has been sent: no position header, and your
Idempotency-Key retries fresh. The orders owner answers the same code, as a fact with the
position header, to an id it will not trade — one meaning for the consumer, whichever
spelling named the account. Error.code has always been open, so a client that treats an
unknown code by its status already handles it.
Resolution is org-scoped by construction. The org is the admitted principal’s, never the body’s. Another organisation holding an account of the same name is invisible here: naming it answers exactly as an unheld name does, so the alias is not a way to discover other tenants. An account still being classified carries whatever the venue called it, binds no name, and is addressable by id alone.
The WebSocket door takes the same alias, resolved through the same predicate. One submit body serves both lanes, which is the reason this landed with the WS door rather than a rung later.
0.4.3 — 2026-09-25 — money is served at the scale it states, and reference data leaves the read path
Sections 0.3.1 through 0.4.2 said every balance, order, execution and policy amount —
each a Decimal on the platform’s wire since 0.3.1, a scaled integer carrying its own
scale — was served at no fewer places than the referenced record’s: an amount widened to
the asset’s unitScale, a quantity to the instrument’s qtyScale, a price or notional to
its priceScale. That widening is removed. Every money value is now served at exactly
the scale its own value states, and the read path consults no asset and no instrument at
all.
What you see change. Only the number of trailing zeros, and only where a value was recorded at fewer places than its reference record. Examples, all of them the same numbers as before:
A value recorded at more places than its record was already served in full and is unchanged. A value recorded at exactly its record’s places — the overwhelming majority, since the order path states the record’s pair at acceptance — is unchanged.
What to do. If you parse money as a decimal and compare numerically, nothing. If you
compare money as strings, or read the digit count as meaning, that breaks: "2.5" and
"2.500000" are the same number, and which one you get is now the precision the platform
recorded rather than a width derived from reference data. The guide has said to parse and
never assume the scale since 0.1.0; this makes it load-bearing.
The other half. 0.3.4 made the way in refdata-free — the door encodes each decimal at its own minimal scale and reads no record to do it. This is the way out. Together they take reference data off this gateway’s money path entirely, in both directions.
Why. The widening was never the design this API meant to have, and removing it restores
the one it did: a read serves each value at its own scale. The widening was bought to keep
the served width independent of the precision a publisher happened to state. It never actually delivered that: it degraded to the stated scale
whenever this member did not hold the asset or instrument — during catch-up, or for a venue
listing an asset before refdata carries it — so the same row could serve two different widths
depending on when you asked. Serving the stated scale is independent of reference data
unconditionally, which is the property the widening was reaching for. It also lets the
websocket edge render money identically without putting a refdata lookup and a BigDecimal
allocation on its per-row push path: one number, one string, whichever edge serves it.
Either side of the deploy window. A reader on the old build and a reader on the new one
see the same numbers, and differ only in trailing zeros on rows recorded coarser than their
record. No field is added, removed or retyped; unscaled stays absent and deprecated as it
has been since 0.3.3.
0.4.2 — 2026-09-22 — fees and fills at the venue’s precision: a fee where null was, executions that used to be refused, more places on some orders
Nothing in this document moves — no route, field, enum member, code or schema keyword — and
the rule you read money by is the one 0.3.2 stated: every value is served at the scale the
platform holds it at, never fewer places than the referenced record’s, more only where a
venue reported finer. What changes is what the platform holds: the order-entry connector now
states every amount at the digits the venue rendered — a working scale per instrument and
family, and per fee asset, learned from the wire and widened, never narrowed — where it used
to read fills against the instrument record’s scales and fees at a fixed eight places. A
patch, by the guide’s Versioning rule: behaviour inside the published shapes, what a value
is served as. Three things you can see:
- A fill’s
feewhere you used to readnull. A fee the connector could not state — finer than eight places, as a BTC-denominated fee on a buy is (the venue charged-0.00000124914: eleven places) — was served asnull, “not stated”, and the execution stood without it. It is now stated exactly, at the fee asset’s working scale, and served at no fewer places than that asset’sunitScale("0.000001249140"for aunitScaleof 12), positive for a charge as before.nullstill means what it meant — the venue named no fee currency this platform holds, or an amount no 64-bit integer carries at 18 places — and both are counted on the connector, never on you. If your reconciliation readnullas “no fee charged”, it was undercounting these fees; expect those totals to move. - Executions that used to be missing arrive. A fill whose quantity or price carried more
places than the instrument record’s
qtyScaleorpriceScalewas refused whole by the connector — the execution, its trade id and the order’s progress lost until a reconcile. It now reaches the order, whose holders widen to take it; the one refusal precision has left is a value no 64-bit integer holds at its scale, theFILL_PRECISION_OVERFLOWrejection0.4.0published. - More places on some orders. An order touched by a value the venue rendered finer than
the instrument record — a fill, or the venue’s derived average price, which can carry one
place more than the tick — reads with those places from then on, on every money property
of the row (
price,qty,cumQty,leavesQty,avgPx,lastFillPx,lastFillQty):"50000.12"may become"50000.120". Exact — trailing zeros only — and0.3.2’s promise; parse money as a decimal, never by counting places.
Across the deploy window the change lands with the order-entry connector’s roll, not the
gateway’s: until it rolls, such fees read null and such fills are refused; after it, they
are stated and admitted, and an order’s places widen the first time the new connector reports
it. Nothing you send changes, nothing already served is rewritten, and no client needs
regenerating.
0.4.1 — 2026-09-22 — the door holds money to the published pattern, and its 64-character bound is published as maxLength
Every money property in this document has always published one grammar — pattern
^-?[0-9]+(\.[0-9]+)?$: an optional minus, digits, and at most one point with digits on both
sides of it — but the gateway never checked it. It handed
your string to a general-purpose decimal parser, which reads more than that grammar does.
And in the other direction the gateway refused a money string longer than 64 characters,
which the schema did not say. Both gaps close: the gateway now accepts exactly what the
schema admits. A patch, by the guide’s Versioning rule — behaviour inside the published
shapes and code set, which requests the door refuses; the one change to the schemas is a
constraint keyword stating a rule the gateway already enforced, a case the rule now names.
No route, field, enum member or code moves.
If every money string you send already matches the published pattern, nothing changes
for you. Check the place your client turns a number into a string: a general-purpose
formatter switches to exponent form for small or large values — JavaScript’s
String(0.0000001) is "1e-7", Java’s BigDecimal.toString() and Python’s str(Decimal)
give "1E-7" — and that spelling is now refused. Format money from an exact decimal type in
plain notation (BigDecimal.toPlainString(), Python’s format(d, 'f')), or carry the string
you were given. In JavaScript keep money in a string or a decimal library, never a Number:
toFixed is no fix — it rounds a binary float, (0.0000001).toFixed() is "0" (which the
gateway reads as “unset”), and from 1e21 it gives exponent form again.
What changed
- A money string off the published
patternis refused 400MALFORMED_REQUEST, on every route that takes money —POST /orders(price,qty) andPOST/PUTof/asset-policies,/chain-policiesand/trading-policies— where it used to be read as the number it spells. The spellings that stop working: an exponent ("1e3","1E-3"), a leading plus ("+5"), a point with no digit on one side (".5","5."), surrounding whitespace (" 1.5 "— the gateway used to trim it, and no longer does), and digits outside ASCII (fullwidth"12.5"used to read as 12.5).error.messagenames the field, the accepted form and the string it was sent. It is the door’s refusal, like every otherMALFORMED_REQUEST: nothing reached the platform, the response carries no position header, and the key is not spent — correct the spelling and retry under the sameIdempotency-Key. - What is still accepted is everything on the grammar: leading zeros (
"007.50"), trailing zeros ("1.50"and"1.5"are one value,0.3.4’s rule), a minus sign, and"-0", which is"0"— “unset”, asnulland an absent field are. What a value is judged for has not moved: more than 18 fractional digits, or past a 64-bit integer at its own scale, is still the door’s 400, and everything else is still the platform’s to judge. - Every money property now publishes
maxLength: 64beside itspattern— request bodies and served rows alike. On a request it is the bound the gateway has enforced since0.3.4, now written where a generated client and a form can read it, so a schema-valid request is no longer one the door can refuse for its length. On a response it is simply true: the widest decimal this API serves is 39 characters. The length is counted over the string as sent; a string past it is refused for its length before anything else reads it. BadRequest’s description (the 400 every write route shares) names the two money rules by their schema keywords instead of the bare 64.info.versionmoves from0.4.0to0.4.1; noinfo.x-immix-*-schemamoves — nothing on the platform’s wire changed. The API guide’s Money section states the grammar.
Each side of the deploy window
For the length of a deploy a request can meet a member on either side. A member below
0.4.1 still accepts "1e3", "+5", ".5", "5.", a string padded with whitespace and
digits outside ASCII, reads each as the number it spells, and sends that value on — so an
off-pattern order or policy write can succeed on one member and be refused on the next. A
member at 0.4.1 answers each of them 400 MALFORMED_REQUEST. Both refuse a money
string longer than 64 characters, and both serve the same rows: nothing you read changes on
either side, only the document’s schemas gain maxLength. A client that sends only
on-pattern strings cannot tell the two apart. No wire schema version moves and no stream
reset rides this release.
0.4.0 — 2026-09-22 — FILL_PRECISION_OVERFLOW joins OrderRejected.reason; nothing you send or read changes
The orders owner gained one refusal reason, named one it already had, and made its ticket-cap comparison exact in every case. The one change to the document is an enum member, which is why this is a minor: a client generated with a closed enum type gains a member when it regenerates. No route, field or status moves, and no request of yours can earn the new reason.
What changed
OrderRejected.reasongainsFILL_PRECISION_OVERFLOW(appended; the enum is append-only — branch on what you know). The orders owner answers it to a connector’s progress report, or to an attribution of a discovered fill — never toPOST /ordersor a cancel — when a venue states a fill at a precision the order cannot hold in a 64-bit integer. The fill is refused whole, never rounded to fit and never applied, the platform’s operators are paged, and none is expected. You meet it only if you read refusals off the platform’s stream; it sits under 422 in the guide’s table because that table is total, not because a request can answer it. Until now the same refusal was the overflow half ofINVALID_FIELD.NO_REFERENCE_PRICEhas a second cause, now written down. An order needs a fresh reference price, and the owner places the quote on the instrument record’s price grid: a quote that grid cannot hold exactly — a venue quoting finer than reference data has registered — is no reference, and every order on that instrument is refused until reference data catches up. That is a decision (fail-closed: the record’s tick, bounds and minimum notional are as stale as its grid), not a fault, and nothing about the refusal changed: the same code, the same 409, no detail. Your handling does not change either — the refusal is the owner’s, so retry under a fresh key (0.3.4’s rule). What is new is on the platform’s side: the owner counts this cause apart from a dead feed.- A ticket cap compares exactly whatever scale it was written at.
0.3.4let a trading policy’smaxOrderQtyandmaxOrderNotionalride at the scale you stated, so a cap and the order it judges differ in scale as a rule. The owner now compares them as products, restating neither — no verdict moved, and the one case it used to refuse on caution (a notional and a cap both past a 64-bit integer at the instrument’spriceScale) is decided exactly. A breach that large states noobservedleg inerror.message— the figure does not fit the platform’s decimal — and readslimit … (notional)alone. info.versionmoves from0.3.4to0.4.0;info.x-immix-orders-schemastays111.v1— an enum member appends without a schema version, asORG_NOT_ACTIVEdid. The API guide’s Orders table lists the reason with its note.
Each side of the deploy window
Nothing a client sends or reads differs between a 0.3.4 member and a 0.4.0 one: the new
reason cannot answer an HTTP request, and the other two items are the owner’s. Between an
older gateway member and a newer orders owner, a FILL_PRECISION_OVERFLOW refusal on the
stream decodes as a reason that member does not know — which it has always treated as the
validation class, and which concludes no operation of yours. No wire schema version moves
and no stream reset rides this release.
0.3.4 — 2026-09-22 — money in crosses at its own scale: the door’s two scale refusals retire
Until now the gateway scaled every decimal you sent to the
referenced record’s scale — an amount to the asset’s unitScale, a quantity to the
instrument’s qtyScale, a price or a notional to its priceScale — which is why it had to
hold that record, and why it refused what that scale could not express. Every money value on
the platform’s wire has stated its own scale since 0.3.2 and 0.3.3, so the gateway now
encodes each decimal at its own minimal scale — the fractional digits you sent, trailing
zeros dropped, so "1.50" and "1.5" are one value — and reads no reference data to do it.
The two refusals 0.3.2 and 0.3.3 said would retire do, and the platform judges the value
instead. No route, field or schema shape moves, and no code leaves the document; request and
response bodies are the same decimal strings. What does move is behaviour inside those
shapes — which requests the door refuses, and one code it answers. A patch: the guide’s
Versioning section now says what 0.3.1 to 0.3.3 already did, that behaviour inside the
published shapes and code set is a patch.
The one thing to do before you deploy against it: requests this door used to stop now reach
the platform, and the platform’s refusal spends the key. A door refusal was never
journaled, so the same Idempotency-Key retried fresh; an owner’s refusal is a journaled
fact, and a retry of its key answers the stored refusal again with Idempotent-Replay: true
for as long as that member’s journal holds the key (a day by default). For
INSTRUMENT_NOT_LIVE, which both the door and the orders owner now answer, the position
header tells them apart — the window section at the end has the rule in full.
What changed
- Digits beyond the record’s scale no longer refuse 400 at the door. On
POST /ordersapricefiner than the instrument’spriceScale, or aqtyfiner than itsqtyScale, reaches the orders owner, whose grid answers: 422TICK_SIZE_VIOLATIONorQTY_STEP_VIOLATION, with the observed value against the tick or step inerror.message(observed 50000.001 against limit 1.00 (price)) — or 422INVALID_FIELDwhere the instrument states no tick or step to be off, or the value is past a 64-bit integer at the instrument’s scale. That refusal is a fact on the platform’s stream: it carries the position header, the operation is journaled, and the key is spent — a retried key replays the refusal, so a corrected order needs a freshIdempotency-Key(a freshclientOrderId), where the door’s 400 left the key free. - A policy amount finer than its record is accepted, on
POSTandPUTof/asset-policies,/chain-policiesand/trading-policies, and compared exactly: amaxOrderQtyof"1.123456789"on an instrument whoseqtyScaleis 8 is that cap, not a refusal. The amounts of one asset or chain policy share one scale — the platform holds a row’s amount family at the finest scale any of its amounts states — which has two consequences you can see. A row reads back at that shared scale or the asset’sunitScale, whichever is finer: on an asset whoseunitScaleis 6, sendmaxTicketAmount"2.5"beside areconciliationToleranceAmountof"0.0000001"and the ticket reads"2.5000000". And amounts that do not fit a 64-bit integer together at that scale — about nineteen digits between the largest amount’s integer part and the finest amount’s places — are refused by the register, 422INVALID_FIELD, where the door used to refuse the oversized one 400 by name. A trading policy’s two caps are two families and do not share. SCALE_UNKNOWN(422) is retired. No member at0.3.4or above answers it. A policy naming an asset or instrument this member does not hold is accepted — its amounts need no record to be encoded — and reads back at the scale the row holds, with no places added for a record the member does not hold (0.3.3’s rule). An order naming aninstrumentIdthis member does not hold now reaches the orders owner, whose own reference data decides: it is accepted if the owner holds the instrument open — an order a lagging member used to stop at the door can now trade — and otherwise answered 422INSTRUMENT_NOT_LIVE, the code the owner has always answered for an instrument it does not hold or that is not open. The retired code stays listed inError.code’s description and the guide’s table until the next major: a member below0.3.4still answers it.POST /ordersby symbol: aninstrumentthis member holds no instrument under answers 422INSTRUMENT_NOT_LIVEat the door, where it answered 422SCALE_UNKNOWN. The symbol resolves to no id, so nothing is sent and nothing is journaled — the same key retries fresh — and the code is the one the owner answers to an unheld id, so you meet one meaning by either spelling. Tell the two apart by the position header, as for every code both the door and an owner answer: withX-Immix-Global-Sequenceit is the owner’s fact and the key is spent; without it, the door’s, and the key is free.INSTRUMENT_NOT_LIVEjoins the door’s codes inError.code’s description and the guide’s table; it was already in the document as anOrderRejected.reason.- What the door still refuses for money, all 400
MALFORMED_REQUEST: a value that is not a decimal string; one no value on the platform can state — more than 18 fractional digits, or past a 64-bit integer at its own scale; a string longer than 64 characters (new: the widest decimal this API serves is 39, and the bound keeps a body-sized string of digits from costing the gateway seconds to parse); a scale of your own (priceScale,maxTicketAmountScale, …); and an amount stated beside anassetIdorinstrumentIdthat is absent or 0, which is still how the organisation-wide default trading policy is told it takes no money caps."0",nulland an absent field still mean “unset”. - A cap breach’s
limitleg reads at the scale the cap was stated at. Theobserved … against limit …text inerror.messagerenders each leg at the scale the platform’s fact states for it (0.3.2), and a trading policy’s cap now rides at the scale its write stated: amaxOrderQtysent as"1"readsobserved 2.00000000 against limit 1 (quantity); trading policy 1 v2where it read… against limit 1.00000000 …. The policy’s row still serves the cap at the instrument’s places ("1.00000000");error.messageis free text by the contract either way, never parsed. - No string you read changes on a row whose every amount sits at or coarser than its
record. A read serves each family from the scale its row holds at no fewer places than
the referenced record’s (
0.3.2,0.3.3):"2.5"sent as a BTC amount crosses as 25 at one place and reads back"2.500000000000", exactly as before, and an order’s values, which the owner aligns to the instrument’s pair on acceptance, read as they always did. What is new is a policy amount stated finer than its record: it reads back with every digit you sent, and its row’s other amounts with it (the shared scale above). - The descriptions of the nine input money properties (
SubmitOrderBody’spriceandqty; the three asset-policy amounts, the two chain-policy bounds and the two trading-policy caps on theirSubmit*bodies) state the input rule above in place of “scaled at the edge to the referenced record’s scale … digits beyond it refuse”; the three policyPOSTdescriptions,POST /orders, theinstrumentalias,Error.code, and theBadRequestandRejectedresponses say the same. The API guide’s Money, Errors, Orders and Versioning sections follow;info.versionmoves from0.3.3to0.3.4.
Each side of the deploy window
A member below 0.3.4 refuses at the door what a 0.3.4 member sends on: digits beyond the
record’s scale (400 MALFORMED_REQUEST) and an asset or instrument it does not hold (422
SCALE_UNKNOWN) — neither journaled, so the same Idempotency-Key retries fresh. A 0.3.4
member accepts the same requests or hands them to the platform, which may accept them or
refuse with TICK_SIZE_VIOLATION, QTY_STEP_VIOLATION, INVALID_FIELD or
INSTRUMENT_NOT_LIVE — facts on the stream, so the key is spent and a retry needs a fresh
one: the member that carried the request replays the stored refusal, with
Idempotent-Replay: true, for as long as its journal holds the key (a day by default; a
restarted member, or another member, judges the key afresh). That is the difference a client
must carry across the window, and it is not new — it is how every owner’s refusal has always
behaved. Do not give INSTRUMENT_NOT_LIVE the handler you gave SCALE_UNKNOWN (“wait
for reference data, send the same request again”) unchanged: on POST /orders the key
is the clientOrderId, and against a 0.3.4 member that loop is answered the owner’s
stored 422 each time, even after the instrument lands. Keep the SCALE_UNKNOWN handler for
as long as a member you reach may report an info.version below 0.3.4. For
INSTRUMENT_NOT_LIVE — one of the codes both the door and an owner answer, like
ORG_NOT_ACTIVE and CAPABILITY_DENIED — the position header says whose it was: without
X-Immix-Global-Sequence it is the door’s (an unresolvable symbol; nothing journaled, the
same key retries once reference data has landed), with it the orders owner’s (retry under a
fresh key). On the platform side there is nothing to coordinate: the owners have aligned a
command’s values to their own holder since 111.v1 and 114.v7 (0.3.2, 0.3.3), so a
gateway at either version may stand beside them, no wire schema moves, and no stream reset
rides this release.
0.3.3 — 2026-09-22 — a policy amount states its own scale: never served raw
On the platform’s wire the seven policy money fields became Decimals — an asset policy’s
maxTicketAmount, approvalThresholdAmount and reconciliationToleranceAmount, a chain
policy’s minTransferAmount and maxTransferAmount, a trading policy’s maxOrderQty and
maxOrderNotional — each carrying its own scale beside its integer, where until now the
gateway looked the scale up in reference data: the asset’s unitScale, the instrument’s
qtyScale and priceScale. With it the last raw fallback on this API stops happening.
No route, field, code or schema shape moves; the seven descriptions say why. A patch, per
the guide’s Versioning section, on the same reasoning as 0.3.1 and 0.3.2.
What changed
GET /asset-policies,GET /chain-policies,GET /trading-policies, their/{id}routes — and the row every policy write answers with (POST,PUT,approve,retire,withdraw,halt,resume) — never serve a row raw. Every amount and cap is read at the scale the row holds for its family and served as an exact decimal at no fewer places than the record’s — the asset’sunitScalefor an amount, the instrument’sqtyScaleformaxOrderQty, itspriceScaleformaxOrderNotional— so a row whose asset or instrument you already saw reads the same string as before ("1.500000","2.5000","12345.00"). What changes is the row whose asset or instrument this member does not hold, or whose asset leavesunitScaleunstated:0.3.2served its money as raw scaled integers ("250") under"unscaled": true;0.3.3serves the decimal ("2.50"), because a value that states its scale needs no reference data to be read exactly.nullstill means unset — no cap, no bound, every transfer requires approval, an exact reconciliation — exactly as before.unscaledis never present anywhere on this API now — not on any register row, not as any collection’s coarse marker; the balance surface lost it at0.3.1, the order surface at0.3.2, and the three policy families were the last rows that could carry it. Everyunscaledproperty stays in the document, markeddeprecated, so a client generated from0.3.2still compiles; all of them go at the next major.- The input side is unchanged.
POSTandPUTbodies take the same decimal strings, scaled at the edge to the record’s pair: digits beyond the asset’sunitScale(or the instrument’sqtyScale/priceScale) refuse 400, an asset or instrument this member does not hold refuses 422SCALE_UNKNOWN, and a cap on the organisation-wide default trading policy refuses 400, exactly as before — the two scale rules retire with a later slice, which will say so here. What moved is on the wire: the edge now states the scale it encoded at beside each value, so no reader guesses. A body that sends a scale of its own (maxTicketAmountScale,maxOrderQtyScale, …) is refused 400, aspriceScaleis onPOST /orders: the document never advertised one. info.x-immix-register-schemamoves114.v6→114.v7. No published shape moves; the descriptions of the seven money properties onAssetPolicyUpdated,ChainPolicyUpdated,TradingPolicyUpdatedand theirSubmit*bodies now sayDecimalinstead of naming a typedef (amount_t,qty_t,notional_t) whose scale to look up — and three of them (minTransferAmount,maxOrderQty,maxOrderNotional) were reworded for consumers with the re-cut, saying what their0means and nothing about the wire’s history.- The API guide’s Money section says the same — its typedef
table is now one rule for every money value on the API;
info.versionmoves from0.3.2to0.3.3.
Each side of the deploy window
A 0.3.2 member serves a policy whose asset or instrument it does not hold as raw integers
under the marker; a 0.3.3 member serves the decimal and no marker. Only a client that reads
the marker is correct against both: under "unscaled": true a money string is the raw scaled
integer ("250" is 2.50 at two places), which no amount of careful decimal parsing recovers
— so keep honouring unscaled for as long as a member you reach may report an info.version
below 0.3.3, and treat every money field as the exact decimal it says from 0.3.3 on. On the
platform side there is no mixed window: 114.v7 is a clean break under a schema floor, so the environment that takes this release takes it with a
stream reset (the gateway and the register it serves are dev-only), and a 0.3.2 member is never live beside a 0.3.3 one
on the same stream.
0.3.2 — 2026-09-22 — an order states its own scales: never served raw, and a refusal’s legs are decimals
On the platform’s
wire every money value on the order surface became a Decimal: each carries its own scale
beside its integer, where until now the gateway looked the scale up in reference data — the
instrument’s priceScale and qtyScale, the fee asset’s unitScale, and, for a refusal’s
detail, a scale chosen by reason code. No route, field, code or schema shape moves; one
fallback stops happening, one free-text detail reads from the fact instead of a table, and
the descriptions say why. A patch, per the guide’s Versioning section, on the same
reasoning as 0.3.1.
What changed
GET /orders,GET /orders/{id},GET /executions,GET /executions/{id}— and the order rowPOST /ordersandPOST /orders/{id}/cancelanswer with — never serve a row raw. Every price and quantity is read at the scale the row holds for its family and served as an exact decimal at no fewer places than the instrument’spriceScaleorqtyScale(a fill’sfee: its asset’sunitScale), so a row whose instrument you already saw reads the same string as before ("50000.00","0.01000000"). What changes is the row whose instrument — or whose fee asset — this member does not hold:0.3.1served its money as raw scaled integers ("5000000") under"unscaled": true;0.3.2serves the decimal, because a value that states its scale needs no reference data to be read exactly.- A fill’s
feeon an asset whoseunitScaleis not 8 reads correctly for the first time. okx-oe parses every fee at scale 8, and until111.v1each reader scaled that integer by the fee asset’sunitScaleinstead: a0.3.1member served such a fee10^(unitScale − 8)out. The connector now states 8 beside the value, and0.3.2serves the fee from the value at no fewer places than the asset’sunitScale. A fee on an asset atunitScale8 reads the same string as before. - A stated zero fee serves as
"0.000000", notnull. A fill’sfeeis unstated only when it names no asset (feeAssetIdnull): a zero fee whose asset is named — a zero-fee promotion — is a measurement, and0.3.2serves it as the decimal the persistence lane already lands.0.3.1served every zero fee asnull, asset or no asset. unscaledis never present on the order surface — not on an order or execution row, not as the two collections’ coarse marker. The collections’ property stays in the document markeddeprecated; the row’s ridesRowExtras, shared with the register rows where it is still live (a trading policy’s caps are scaled from reference data until the register’s own re-cut), so its description says which rows never carry it. All of it goes at the next major. A client generated from0.3.1still compiles.- A refusal’s detail legs are the decimals the owner stamped. The
observed … against limit …text inerror.message— on a refusedPOST /orders(403/409/422) and on the polled operation’s outcome — renders each leg at the scale the fact states for it, whatever the reason. Where this member held the instrument the string is unchanged (observed 2.00000000 against limit 1.00000000 (quantity); trading policy 1 v2); where it did not,0.3.1wrote the raw integer and said so (quantity, raw at the instrument's qtyScale),0.3.2writes the decimal.error.messageis free text by the contract either way, never parsed. POST /orderstakes the same body.priceandqtyare decimal strings, scaled at the edge to the instrument’s pair: digits beyond it refuse 400, and an instrument this member does not hold refuses 422SCALE_UNKNOWN, exactly as before — both retire with a later slice, which will say so here. What moved is on the wire: the edge now states the scale it encoded at beside each value, so no reader guesses. The two properties’ descriptions say they areDecimals on the wire instead of naming a typedef whose scale to look up.info.x-immix-orders-schemamoves111.v0→111.v1. No published shape moves; the descriptions of every money property onOrderUpdatedandExecutionRecordednow sayDecimal, andOrderRejected’sobservedValueandlimitValue— published for the reason value set, never served as JSON — are described and patterned as money, since each leg states its own scale.- The API guide’s Money and Orders sections say the same;
info.versionmoves from0.3.1to0.3.2.
Each side of the deploy window
A 0.3.1 member serves an order or execution whose instrument (or fee asset) it does not
hold as raw integers under the marker, words such a refusal’s detail as raw, and serves a
fee on an asset whose unitScale is not 8 at the wrong magnitude; a 0.3.2 member serves
the decimal, no marker, the decimal detail, and the fee as stated. Only a client that reads the
marker is correct against both: under "unscaled": true a money string is the raw scaled
integer, which careful decimal parsing does not recover (corrected with 0.3.3, whose section
says the same of the policy rows). On the platform side there is no
mixed window: 111.v1 is a clean break under a schema floor,
so the environment that takes this release takes it with a stream reset (the order path is
dev-only), and a 0.3.1 member is never
live beside a 0.3.2 one on the same stream.
0.3.1 — 2026-09-22 — a balance states its own scale: never served raw
On the platform’s wire a balance’s three amounts became
Decimals: each carries its own scale beside its integer, where until now the gateway looked
the scale up in reference data. No route, field, code or schema shape moves; one fallback
stops happening and three descriptions say why. A patch, per the guide’s Versioning section.
What changed
GET /account-balancesnever serves a row raw.total,availableandheldare read at the scale the reading itself stated and served as exact decimals — at no fewer places than the asset’sunitScale, so a row whose asset you already saw reads the same string as before ("2.500000"). What changes is the row whose asset this member does not hold, or whoseunitScalereference data leaves unstated:0.3.0served its amounts as raw scaled integers ("2500000") under"unscaled": true;0.3.1serves the decimal, because a value that states its scale needs no reference data to be read exactly.unscaledis never present on this surface — neither on a balance row nor as the collection’s coarse marker. Both properties stay in the document, markeddeprecated, so a client generated from0.3.0still compiles; they go at the next major. The marker is unchanged everywhere else (register rows, orders, executions), where the scale is still read from reference data.info.x-immix-custody-schemamoves115.v0→115.v1.AccountBalance’s published shape does not move — three decimal strings, as before — but the descriptions oftotal,availableandheldnow say they areDecimals on the wire, served at the value’s own scale and never raw, instead of naming a typedef whose scale to look up.- The API guide’s Money and Balances sections say the same;
info.versionmoves from0.3.0to0.3.1.
Each side of the deploy window
A 0.3.0 member serves a balance whose asset it does not hold as raw integers under the
marker; a 0.3.1 member serves the decimal and no marker. Only a client that reads the marker
is correct against both, mid-roll included: under "unscaled": true a money string is the raw
scaled integer, which careful decimal parsing does not recover (corrected with 0.3.3). A
client that required the marker to be present on a balance row was never promised one.
0.3.0 — 2026-09-17 — account balances: GET /account-balances
Additive: one route, one tag, one query parameter, two schemas, one response and one error code added; nothing existing moves. A minor, per the guide’s Versioning section.
What changed
GET /account-balances(tagAccount balances, operationlistAccountBalances) serves your organisation’s latest observed balance per (account, asset): what each of your accounts holds at its venue, as the venue states it, republished by the platform’s account connector as a reading — keyed latest-wins, a reading superseded by the next and never corrected. Every row is the custody domain’sAccountBalancerecord (schema 115, named by the newinfo.x-immix-custody-schema,115.v0) plus two fields of the gateway’s own:accountId,assetId,credentialId— the references (GET /accountsandGET /credentialsserve the rows they name; a balance carries no venue or symbol of its own — join on them).total,available,held— the venue’s partition of the holding, decimal strings at the asset’sunitScalelike every other asset amount on this API (theamount_trow of the guide’s money table);availableis what the venue would let move now (a withdrawal or transfer amount, never what is usable as margin),heldwhat the venue itself has locked (order margin, pending withdrawals, venue-side freezes),totalthe whole. Zero is a reading: the three are required and nevernull—"0.00000000"is a value.venueTsNs,venueRevision— the venue’s own clock and sequence for the reading where it states one;nullotherwise (the 0 sentinel, as everywhere).observedAtNs(new, the gateway’s) — the platform time of the reading, epoch nanoseconds as a decimal string, nevernull: a changed reading and a restatement on the connector’s heartbeat both move it, so a row whoseobservedAtNsstops advancing is an account the venue has stopped reporting. Judge freshness from it, never fromvenueTsNs.unscaled(new, per row) — present andtrueonly while that row’s amounts are served raw: the asset is not yet held by the member, or itsunitScaleis unstated (typically an asset the venue lists before reference data carries it). The collection also carries the coarse top-level marker every collection carries, while any row carries its own.
- The rows are your organisation’s by construction: a reading names its account, the
account row names the organisation, and a reading whose account the member does not hold
— or another organisation’s — is indistinguishable from absent. A
DISCOVEREDaccount’s balances are served while the account is being classified. ?accountId=narrows the collection to one account’s balances (a positive int32; an account outside your organisation, or one with no reading, yields an empty collection;0, a negative or a non-integer is 400MALFORMED_REQUEST). Rows are ordered byaccountIdthenassetId, ascending.- One surface, no entity route, no
ETag:?view=latestis accepted and?view=approvedrefuses 400, as on/orders;?status=refuses 400, as on every collection but/users; the key is the (account, asset) pair and a reading carries no version, so there is nothing to address by id or to precondition on. - The tag joins the resource list after
Accounts, in onboarding order: a generator that names a class per tag emitsAccountBalancesApibeside the twelve it emitted before, and nothing it emitted moves. X-Immix-Global-Sequencemoves on balance readings too: the position advances on every change the gateway sees — records, orders and, from now on, readings. A reader polling the header for “anything new?” will see it move on every heartbeat restatement (every 30 s per account and asset, by the connector’s default) even when nothing else changed; read the rows it wants rather than treating the token as a change flag.- A member deployed without the balance surface answers
503 BALANCES_UNAVAILABLEon this route — after authentication and admission, before the query is read, with no position header — and every other route exactly as before. Composing the surface is a per-environment choice of the deployment (the gateway’sbalancesknob, on by default): an environment into which no account connector publishes readings yet runs the edge with it off — dev, until the connector is deployed there — and turns it on with no change to this document. The code joinsError.code’s 503 set, and the route’s 503 response names both it andNOT_PRIMED. info.descriptionandexternalDocs.descriptionmention balances; the guide gains a Balances section;info.versionmoves from0.2.1to0.3.0— a minor: a route and its schemas added.
What each side of the deploy window sees: a member on the previous build answers
GET /account-balances with a plain 404 Not Found (no error envelope, no position
header), and its /openapi.json reads 0.2.1 with no x-immix-custody-schema; a member on
this build answers the rows and reads 0.3.0 — or, deployed with the surface off, answers
the route 503 BALANCES_UNAVAILABLE and serves the same 0.3.0 document: the document
describes the contract, the code says what this member composes. Every other route, body,
header and code answers exactly as before. A member on this build also moves the position header on
readings, so two members mid-roll can report different positions for the same register state
— the header is a consistency token, not a change counter, on both.
0.2.1 — 2026-09-17 — the generated property descriptions written for the consumer
No wire change: no path, parameter, body, schema, status or header moves; the text of the generated schemas’ property descriptions changes, and the one refusal detail below.
What changed
- Every property description under
components.schemas— the message shapes generated from the register and orders wire schemas — is one line of prose written for the reader: what the field is, its sentinel where it has one, then the encoding note. The engineering citations the text carried (internal convention, decision and design-section references) are gone from the document; a client that scraped a description for one (none is known) reads the API guide instead. - The encoding notes every generated property ends with are reworded the same way: money
(“a scaled integer on the wire, served as an exact decimal string — the money rules this
document links list the typedef’s scale source and the unscaled fallback”; on input, “a
decimal string on input, scaled at the edge by the typedef’s scale, whose source the money
rules this document links list by typedef”),
int64(“int64 as a decimal JSON string”), boolean (“a boolean; absent reads false”), and the enum and set notes minus their IDs. The API guide’s Money section gains the typedef → scale table those notes point at. - The
400 MALFORMED_REQUESTdetail for anint64field sent as a JSON number (or, where the field is required, absent) reads<field> must be an int64 as a JSON string— the parenthesised internal reference it used to end with is gone. - Two trading-policy ceilings say how they are counted today:
maxOpenOrdersandmaxOrdersPerMinuteare counted over the organisation’s orders as a whole, the tighter of the governing row’s and the organisation-wide default row’s stated ceilings binding — the text had described a per-instrument row’s ceiling as scoped to its instrument, which the orders owner does not do yet. The behaviour is unchanged; the description now matches it. info.versionmoves from0.2.0to0.2.1— a patch: prose only (the guide’s Versioning section).
What each side of the deploy window sees: nothing behavioural on either side — every
route, body, header and code answers exactly as before, and a client generated from the
0.2.0 document carries the same identifiers. A member on the previous build serves the old
descriptions and the old refusal detail.
0.2.0 — 2026-09-17 — tags are resources; update* operationIds; summaries and descriptions rewritten
No wire change: no path, parameter, body, schema, status or header moves; the document’s grouping and prose change, and with them the names a generated client carries.
What changed
- One tag per resource, in onboarding order:
Users,Credentials,Accounts,Account addresses,Portfolios,Asset policies,Chain policies,Trading policies,Orders,Executions,Operations,Organisations— every operation carries exactly one, andOrganisations(the platform’s lifecycle arms) goes last with its audience stated.Organisation,Venue access,PoliciesandTradingretire. A generator that names a class per tag emitsUsersApi,CredentialsApi,AccountsApi,AccountAddressesApi,AssetPoliciesApi,ChainPoliciesApi,TradingPoliciesApi,OrdersApi,ExecutionsApiandOrganisationsApiwhere it emittedOrganisationApi,VenueAccessApi,PoliciesApiandTradingApi;PortfoliosApiandOperationsApiare unchanged. - Six operationIds:
upsertUser,upsertCredential,upsertPortfolio,upsertAssetPolicy,upsertChainPolicyandupsertTradingPolicyareupdateUser,updateCredential,updatePortfolio,updateAssetPolicy,updateChainPolicyandupdateTradingPolicy— a generated client’s method names move with them; every other operationId stays. - Every summary is an imperative of at most six words (
List credentials,Get a credential,Withdraw a credential proposal,Halt trading under a policy) and every description at most three sentences — what the operation does, who may call it (the capability), what it answers — with a link into the guide where the long form lives. The mechanics every write shares (the key, the precondition, the202, the position header) are stated once, on the shared parameters and responses, never per route. The fourteen broken summaries (“Read one of orgs by entity id”, “Disable an user”) go with the rest. - The shared components’ prose (
View,Id,Idempotency-Key,If-Match, the headers, the responses, the nine*Rowschemas,RowExtras,Me,Error,PendingOperation,OperationRecord,CredentialMaterial, the security scheme) is rewritten under the same rule;Error.codelists the gateway’s own codes by status and points at the two owners’ published reason sets. /docsgains a search box over the operations, keeps the bearer across a reload and shows request durations.info.versionmoves from0.1.0to0.2.0— a minor, since before1.0.0a minor may rename generated identifiers (the guide’s Versioning section).
What each side of the deploy window sees: nothing behavioural on either side — every
route, body, header and code answers exactly as before. A client generated from the 0.1.0
document keeps working against a member on this build (paths, parameters and schemas are
unchanged); regenerated from this document it carries the class and method names above, and
nothing else moves. A member on the previous build serves the old grouping and names.
0.1.0 — 2026-09-17 — the document’s own version, title and intro; the API guide
No wire change: no path, parameter, body, schema, status or header moves; this section is about the document’s first screen and where its rules are written.
What changed
info.titleisImmix API— it readimmix org-gateway — the register edge, the module’s name and its design, neither of which a consumer has a word for.info.versionis the document’s own version —0.1.0, semver, bumped by hand with the changelog section that describes the change (the guide’s Versioning section is the rule). It read the register schema’s version (114.v6) before, which the sections below share while the document changed under it — a schema version identifies the wire, not the document. A client that readinfo.versionto learn which schema the messages derive from readsinfo.x-immix-register-schema(114.v6) from now on;info.x-immix-orders-schema(111.v0) is as it was.info.descriptionis an eight-line introduction (Markdown — Swagger UI renders it): what the API covers, then authentication, reads, writes and money in one line each, and the links to the guide and to this changelog. Every rule the previous description stated — one paragraph of 10,529 characters, rendered above the fold at/docs— moves, unchanged in meaning and rewritten for its reader, to the API guide: authentication and admission, principals, reads, writes, maker-checker, money, ids and sentinels, errors, orders, the organisation lifecycle, versioning. Nothing is deleted, and the half that is schema (nullable,pattern,enum,uniqueItems,x-since-schema-version) stays where it was.externalDocs(new, top level) points at the guide.
What each side of the deploy window sees: nothing behavioural on either side — every
route, body, header and code answers exactly as before. A client comparing info.version
across members during the roll reads 114.v6 from a member on the previous build and 0.1.0
from one on this build; a client reading the version for the schema reads
x-immix-register-schema, absent on the previous build (fall back to info.version there).
Vocabulary — 2026-09-17 — connections become credentials: /credentials, credentialId, credentials:propose|approve, CREDENTIAL_*, material, /rotate
The register’s venue-key entity is a credential — what a user holds when they set a venue up: one venue API key, what the platform may do with it, which connector serves it, and what the venue says about it. “Connection” now names only the runtime axis: the health a connector reports for the connection it holds with a credential. Nothing on the SBE wire moves — every template id, field id, ordinal and byte offset is unchanged (114 stays v6, 111 and 115 at their versions) — but every client-visible name does:
- Routes.
/connections→/credentials, and the arms under it (approve,suspend,reactivate,revoke,withdraw);POST /connections/{id}/rotate-credential→POST /credentials/{id}/rotate.PUT /credentials/{id}is the amend. - Bodies.
SubmitConnectionBody→SubmitCredentialBody,AmendConnectionBody→AmendCredentialBody; the legs object ismaterial(CredentialMaterial, formerlyConnectionCredential) on the create and on the rotation. - Rows.
ConnectionUpdated→CredentialUpdatedwithcredentialId;connectionIdiscredentialIdon every row that carries it —AccountUpdated(andtradingConnectionId→tradingCredentialId),OrderUpdated,ExecutionRecorded— and on the stream factsConnectionHealth(114) andAccountBalance(custody, schema 115);ConnectionStatus→CredentialStatus(values unchanged);pendingTransitionreadsROTATEwhere it readROTATE_CREDENTIAL. - Capabilities.
connections:propose→credentials:propose,connections:approve→credentials:approve(bits 1 and 2 unchanged; the presets follow). - Refusal codes.
CONNECTION_IN_USE→CREDENTIAL_IN_USE,WRONG_CONNECTION→WRONG_CREDENTIAL,CONNECTION_NOT_ACTIVE→CREDENTIAL_NOT_ACTIVE,CONNECTION_UNASSIGNED→CREDENTIAL_UNASSIGNED; on orders,CONNECTION_UNAVAILABLE→CREDENTIAL_UNAVAILABLE.CONNECTION_DOWNkeeps its name — it is the pipe’s health. - The operation journal reports
"family":"credentials"; the command kinds readSUBMIT_CREDENTIAL…ROTATE_CREDENTIALandREPORT_CREDENTIAL_PROBE(REPORT_CONNECTION_HEALTHandConnectionHealthkeep their names). - The tag
ConnectivityisVenue access: a generator naming a class by tag emitsVenueAccessApiwhere it emittedConnectivityApi, and one row type per family, nowCredential. - Metrics. The lane gauges (
oe_*,custody_lane_*) label lanes bycredentialwhere they saidconnection; the alert rules and their scenarios follow. - The service principal’s kind is described as an M2M client (
UserKind.SERVICE’s description); the wire value is unchanged.
Deploy window: none is offered. No environment runs the org tier with rows — dev’s org
is not bootstrapped and prod has no org tier — so dev takes a flag-day and no member serves both shapes. A client on the old
shape answers 404 on /connections, and a create body still naming credential answers
400 MALFORMED_REQUEST (material is required) — nothing is staged either way; a client on
the new shape against an old member sees the mirror image. Pin clients to upgraded members.
Entity ids — 2026-09-17 — minted from the sequencer timestamp
The owners now mint every id — the register’s int32 keys (orgId, userId,
connectionId, accountId, accountAddressId, portfolioId, assetPolicyId,
chainPolicyId, tradingPolicyId) and the orders’ int64 keys (orderId, executionId) —
from the
sequenced time of the creating command rather than from a dense counter, so an id no
longer tells its holder how many rows other orgs created before it.
What changed
- Values, not types. Every id keeps its JSON shape — int32 ids as integers,
orderIdandexecutionIdas decimal strings — and its place in every path, body and operation outcome. What changes is the magnitude: a register id minted after this deploy is the whole seconds since 2023-01-01T00:00:00Z (about 117,000,000 in September 2026, reaching 2,147,483,647 in 2091); an order or execution id is 16.384 µs ticks since the same epoch (thirteen digits today). Ids are strictly increasing in mint order and are never0. - Treat ids as opaque. They were never promised small or contiguous; now they are neither. Do not size columns, pre-allocate by id, or infer counts from the gaps.
- Both sides of the deploy window. Ids minted before the deploy keep their small
values and stay addressable forever — the bootstrap org and its first admin remain
1; ids minted after are large. An id a client stored does not change. Nothing re-numbers. - Unique within a family, never across them. A user and an account minted in the same second carry the same number: keys are per-family, so storage and every route are unaffected, but a bare id in a log line, a ticket or a URL is ambiguous without its family, and a mistyped cross-family lookup can now find a real row instead of a 404.
- Why. Under the dense counter an org’s own ids revealed platform-wide populations and rates: how many users, accounts or orders every other org created between two of its own. The sequenced-time tick carries elapsed time instead.
114.v6 — 2026-09-15 — the credential intake: POST /connections takes the venue key’s legs, the edge mints secretRef and fingerprints the material; AmendConnectionBody; rotate-credential carries material
No wire change: schema 114 stays at v6 — SubmitConnection and
RotateConnectionCredential carry the same fields; ConnectionUpdated.keyFingerprint’s
description now states the one
fingerprint (fp- + 16 hex of SHA-256 over the canonical payload). This section is about
three request bodies that changed shape — a breaking change for any client that built
them — six new door codes, and one door gate.
What changed
POST /connectionstakes the material, once.SubmitConnectionBodygains a requiredcredentialobject (ConnectionCredential: the venue’s legs — for OKXkey,secret,passphrase, each a non-blank write-only string, no other leg) and losessecretRef,appGroup,venueKeyIdandkeyFingerprint: the edge stages the legs in its write-only store, mintssecretRef(org<orgId>-<ulid>), resolvesappGroupfrom the venue and the flags (canTrade→ the order-entry groupokx-oe;canReadBalancesalone →okx-account), fingerprints the material and fillsvenueKeyIdfrom the key leg — and only then proposes the command with references and metadata. A body carrying any of the four derived fields answers 400MALFORMED_REQUEST; the row (200) carries all four as derived.PUT /connections/{id}has its own body,AmendConnectionBody:SubmitConnectionminus the same four fields and with nocredential— an amend restates the row’ssecretRef,venueKeyIdandkeyFingerprintfrom the row and re-derivesappGroupfrom the flags (a row at a venue the intake does not serve keeps its group); acredentialin an amend answers 400. An amend that would move a pinned row — one whosekeyFingerprintthe intake stamped — to another connector group (canTradeflipped on an OKX row) answers 400MALFORMED_REQUESTtoo: the credential store addresses the material by group, so the row’s new connector could not read it; the road is a new connection throughPOST /connections.POST /connections/{id}/rotate-credentialrequires a body:credential(the new legs) replacesvenueKeyIdandkeyFingerprint, which the edge now derives; the legs are staged under the row’s existingsecretRefbeforeRotateConnectionCredentialcrosses. An empty body — legal before — answers 400.- One door gate. The intake checks
connections:proposeon the caller’s row before any material is staged: a principal without the bit answers 403CAPABILITY_DENIEDat the door — the owner’s code, the same meaning, but noX-Immix-Global-Sequence(nothing reached the stream). Every other route is gated by the owner alone, as before. - Six door codes, all in
Error.code’s description:INTAKE_UNAVAILABLE(503 — the member runs no sink,secrets.sink=none, or its store refused the write),VENUE_UNSUPPORTED(422 — anexchangeIdthe intake’s venue registry does not name, aproviderKindother thanEXCHANGE, or a row that neither trades nor reads balances),INTAKE_KEY_REUSED(409 — theIdempotency-Keyalready staged different material),PAYLOAD_TOO_LARGE(413 — the 16 KiB intake body cap),RATE_LIMITED(429, withRetry-After— 20 stagings a minute per principal), andCAPABILITY_DENIEDas the door’s. Staging is idempotent by key: a retried key restates the samesecretRefand adds no second version of the material — a version a refusal withdrew is staged again before the command re-crosses. ConnectionUpdated.keyFingerprintis described as the intake’s fingerprint (fp-and the first 16 hex characters of SHA-256 over the canonical payload), the pin a connector lane adopts material at; empty on a row seeded before the intake. Prose only; the value set for rows the intake wrote is thefp-…form.- The off-contract
/statusgains anintakesection (the sink, stagings, replays, one counter per refusal).
The deploy window (member by member): a client on the previous body shape against a
member on this build answers 400 MALFORMED_REQUEST (it sent secretRef, or no
credential); a client on the new shape against a member on the previous build answers 400
too (the old body’s required secretRef, venueKeyId and keyFingerprint are absent,
and credential is an unknown field it ignores) — nothing is staged either way. Pin
connection creates, amends and rotations to upgraded members until the roll completes (a
member on the previous build also accepts an amend that moves a pinned row to another
connector group); every other route is unchanged.
Row state on the connectors’ side: a connector lane now adopts material only at the
row’s keyFingerprint in the fp-… form; an empty pin keeps the unpinned bind. A row pinned
under a retired scheme — the oe connector’s key tail, the account connector’s 64-hex digest
— would refuse its lane (CONFIG_ERROR, never a session under a mismatched pin) until it is
rotated through POST /connections/{id}/rotate-credential or its pin cleared by an amend
the owner accepts. No environment is known to hold such a row: every seed and every
runbook walk wrote an empty keyFingerprint (the old schemes were only ever checked, never
stamped). The observed axis (observedKeyFingerprint, the probe’s readout) is not a pin —
a probe lane simply re-reports a pending row once in the new form.
114.v6 — 2026-09-15 — org activation at the edge: POST /orgs and the five /orgs/{id} arms, the platform’s read exception, ReportDiscoveredOrg at the door
No wire change: schema 114 stays at v6; the six org command
messages and ReportDiscoveredOrg were on the wire since the v6 break and are now served and
proposed. This section is about six new operations, one widened predicate and one new door
behaviour.
What changed
- Six org operations, under the
Organisationtag:POST /orgs(SubmitOrg— a new org and its first admin, two facts: the orgPENDING_APPROVALunder dual control and the admin’sDISCOVEREDrow),POST /orgs/{id}/approve(ApproveOrg— on an activation the body namesfirstAdminUserIdamong the org’sDISCOVEREDrows and fixesrequiredApprovals, and two facts land: the orgACTIVE, the first adminACTIVEwith the Admin bits; on a reactivation or retirement the transition concludes),/suspend(SuspendOrg, immediate),/reactivateand/retire(ReactivateOrg,RetireOrg— proposals an approver other than the maker concludes),/withdraw(WithdrawOrgProposal). They takeIdempotency-Key, and the entity armsIf-Match, exactly as every command route;200is the org row (OrgAnswer—OrgRowwith the fact’s position andETag). The bodies are the commands’ derived bodies (SubmitOrgBody…WithdrawOrgProposalBody):userIdremoved,targetOrgIdandexpectedVersionroute-sourced. Two defaults at the door:SubmitOrgBody.idpOrgRefandfirstAdminIdpUserRefabsent read as""(unbound — the roster-mode shape), andApproveOrgBody.requiredApprovalsabsent reads as1(dual control — the stricter side;0is stated, never defaulted). The owner’s reasons on these arms ride as ever:CAPABILITY_DENIED(403 — also the platform clause: every org arm needs an internal platform org actor holding the arm’sorgsbit),SELF_APPROVAL,NOT_PROPOSER(403),NAME_TAKEN,ALREADY_DISCOVERED,ILLEGAL_TRANSITION,PROPOSAL_PENDING,NOTHING_PENDING(409),UNKNOWN_ENTITY,INVALID_FIELD(422),VERSION_CONFLICT(412). - The read predicate’s one exception. An internal platform org
member holding an
orgsbit (orgs:proposeororgs:approve) now reads every org’s row onGET /orgsandGET /orgs/{id}—PENDING_APPROVALones included — and, onGET /usersandGET /users/{id}, the members of everyPENDING_APPROVALorg (theDISCOVEREDrows an activation names its first admin among;?status=DISCOVEREDnarrows to them plus the caller’s own queue). Nothing else widens: anACTIVEorg’s members and every other family stay the caller’s own org’s. What moved:GET /orgswas “at most one row” for every caller; for these principals it is the list. The org entity arms ride the same predicate — to any other principal another org’s/orgs/{id}/…is404 NOT_FOUND, indistinguishable from an absent id, exactly as its row is;POST /orgshas no path entity and reaches the owner, whose platform clause refuses a non-platform makerCAPABILITY_DENIED. The users write arms ride the same predicate too: a platform orgs reader’sPUT /users/{id}orPOST /users/{id}/disableon a pending org’sDISCOVEREDrow passes the door (the row is theirs to read) and is refused by the owner —CAPABILITY_DENIEDwithoutusers:manage,ORG_MISMATCHwith it: the platform activates an org, it admits nobody inside it; every other principal keeps the404. Both operations’ descriptions say so. - The door reports an unknown organization. In
jwtmode a token whoseorg_idbinds no org used to be refused403 ORG_NOT_ACTIVEwith awaiting activation and nothing else. Now the door first proposesReportDiscoveredOrg— theorg_idwith theorgNameandproductProfileclaims the tenant’s post-login Action stamps from the Auth0 organization’s metadata — andReportDiscoveredUserunder it, then refuses the same code; the message says the door has reported the organization ”…” (or could not report — a replica). A token whoseorg_idbinds a parked org — an activation the platform withdrew, its name and binding held — is reported the same way, and the owner re-proposes on that row: the member’s next login re-queues it, once per withdrawal (the report is keyed on the parked row’s version, so a burst of logins proposes once); the refusal is a bound org’s —orgNotActiveon/status, the message naming the withdrawal and its remedies — and the member is reported under the parked org even when the token carries no metadata claims (then the message says the door cannot re-propose it, and what does). The org landsPENDING_APPROVAL, its first memberDISCOVERED, and a platform approver activates onPOST /orgs/{id}/approve. A token carrying noorgNameor no knownproductProfileclaim is refusedORG_NOT_ACTIVEwith cannot report the organization naming the missing claim — nothing is proposed. A non-ASCIIorg_idis refused403 FORBIDDEN_PRINCIPALas a non-ASCIIsubalready was (the wire’s rule). TheForbiddenresponse’s andError.code’s descriptions say so;/status’sadmissionsection gainsdiscoveredOrgs. info.description’s Authentication paragraph and theOrganisationtag describe the org lifecycle surface.- The order surface’s reason set grows by one (schema 111, appended):
ORG_NOT_ACTIVE, answered403onPOST /orderswhen the acting user’s org is notACTIVE(the orders owner’s admission gained an org-liveness check: a suspended org’s members trade nothing, andPOST /orders/{id}/cancelstays ungated). The same string the door and the register answer, one meaning at all three; it rideserror.codewith no detail pair, likeORG_MISMATCH. A client switching on the reason set adds the case; one that maps unknown reasons to “refused” needs nothing. SubmitOrgBodyandApproveOrgBodydescribe their optional fields as optional. The document had calledidpOrgRef,firstAdminIdpUserRefandrequiredApprovals“route-sourced — the route injects them”, the wording of the path-sourced ids; they are body fields a client may set, read as the door’s default when absent (unbound; dual control), and each property and the body’s description now say so (a fourth derivation arm,optional). Prose only: the wire, the routes and the defaults are as they were.
The deploy window (member by member, no wire change): a member on the previous build
answers 404 on the six org routes for everyone, serves GET /orgs as the caller’s own org
alone, and refuses an unbound org_id without reporting it; a member on this build serves
the routes and the widened reads to the platform’s orgs readers and reports the
organization. A client of the new surface should be pinned to upgraded members until the
roll completes; nothing changes for any other caller.
114.v6 — 2026-09-15 — admission at the edge: jwt mode, AWAITING_ADMISSION and ORG_NOT_ACTIVE, GET /users?status=, the preset expansion
No wire change: schema 114 stays at v6 and every message shape is as the section below states; this section is about how a caller is admitted and two additions to the document.
What changed
- Authentication has two lanes, and the deployed one is the IdP’s. In
jwtmode (dev and prod) the bearer is a JWT verified against the tenant’s JWKS withaudandisspinned; itsorg_idclaim names the organization (the org row’sidpOrgRef), itssubthe member (the user row’sidpUserRef— an M2M client’s is<client_id>@clientson aSERVICErow). Inrostermode (local and test) a static per-user token binds to a register user by name, as before. Either way the register decides: nothing on a token is a role, a permission or an org id.info.description’s Authentication paragraph now says exactly this — a client reading the old paragraph (“static per-user bearer tokens”) is not wrong about the local lane, only incomplete. - Two new door codes, both 403.
AWAITING_ADMISSION: a verified subject the register holds no row for, or holds aDISCOVEREDrow (aDISABLEDrow isFORBIDDEN_PRINCIPAL, below) — on its first request the door reports the discovery (ReportDiscoveredUser, a machine proposal; aDISCOVEREDrow lands holding no capabilities) and until ausers:manageholder admits the row every route butGET /meanswers this code;GET /meserves the row so a UI can say “awaiting admission”.ORG_NOT_ACTIVE: the bearer’s org is notACTIVE— bound but suspended, pending or retired, or not registered at all (the message says awaiting activation; the section above makes the door report such an organization, the code stays) — its members are refused whatever their rows say. What moved: aDISCOVEREDrow was refusedFORBIDDEN_PRINCIPALbefore this change and is refusedAWAITING_ADMISSIONnow, in both lanes — a client that branched onFORBIDDEN_PRINCIPALto mean “not admitted” branches onAWAITING_ADMISSIONfor that case and keepsFORBIDDEN_PRINCIPALfor the outright refusals (a roster binding with no row, a token with noorg_id, aDISABLEDrow). Both codes are named inError.code’s description;ORG_NOT_ACTIVEis the same string the owner already answers to a command whose actor’s org is not active — one meaning at both ends. Also moved, in both lanes: a principal whose org is bound but notACTIVE(SUSPENDED,PENDING_APPROVAL) was admitted to the reads before this change and is refusedORG_NOT_ACTIVEnow — the org’s status gates its members at the door as it already gated their commands at the owner. GET /users?status=— a new optional query parameter on the user collection alone (DISCOVERED|ACTIVE|DISABLED, the wire enum less its sentinel):status=DISCOVEREDis the admission queue. Absent, every row as before; an unknown value, or the parameter on any other collection, refuses400 MALFORMED_REQUEST.UpsertUserBody.presetcarriesx-immix-preset-capabilities: the expansion of each preset name to its scope strings, in declared order — what a UI’s preset picker pre-fills before the human edits the bits; pinned to the view library’s bundles by the contract test. An extension keyword: generators ignore it, and thepresetenum is unchanged.- The alias bodies state their “at least one” rule in schema.
UpsertUserBodyandSubmitOrderBodycarry ananyOfwith one branch per spelling (preset|capabilities;instrument|instrumentId), so a body naming neither fails schema validation at the client where before it validated and met the door’s400 MALFORMED_REQUEST. The “both stated must agree” half stays prose (JSON Schema has no cross-field equality). Nothing changes for a conforming client: every body the gateway accepted still validates.
The deploy window (member by member, no wire change): a member on the previous build
answers a DISCOVERED row FORBIDDEN_PRINCIPAL, admits a member of a non-ACTIVE org to
the reads, and ignores ?status= (it serves the whole collection); a member on this build
answers AWAITING_ADMISSION, refuses that member ORG_NOT_ACTIVE, and honors the filter.
Nothing else differs in roster mode. jwt mode is new — a gateway is switched to it by
configuration (auth.mode, auth.jwk.domain, auth.audience, auth.issuer), and a member so configured refuses every static token
401 from its first request.
114.v6 — 2026-09-15 — capabilities replace roles; the user’s kind, subject and display name; the org’s approval count; GET /me
This is a clean break, not an append: schema 114 was
re-baselined at v6, the stream it fed was reset, and the role string is gone
from every user row and body. It is the last such break — from v6 the wire is append-only.
What changed
UserUpdatedlosesroleand gainskind,capabilitiesanddisplayName.capabilitiesis an array of scope strings —users:manage,connections:propose,connections:approve,accounts:propose,accounts:approve,account-addresses:propose,account-addresses:approve,portfolios:manage,asset-policies:propose,asset-policies:approve,chain-policies:propose,chain-policies:approve,trading-policies:propose,trading-policies:approve,orders:trade,orgs:propose,orgs:approve— served in that (declared) order, unique, never null: an empty array is a read-only member. Every bit stands alone (approvedoes not implypropose). The set is append-only on the wire, and the published schema is anarrayof anenumlisting the seventeen, so a generated client gets a typed set — which also means a scope appended later arrives with a contract version bump and its own changelog section, and a client that validates responses against an older document regenerates before it meets the new scope (the closed-enum trade-offError.codeonce made and114.v3reversed).kindisHUMAN(logs in through the IdP) orSERVICE(an M2M credential).displayNameis what the IdP stated at discovery — informational,""when none was.UserStatusrenumbers around a new value:DISCOVERED(a subject the door met, no capabilities yet) →ACTIVE⇄DISABLED. Names are what the contract carries; a client comparing names is unaffected, one that hard-coded ordinals is not.UpsertUserBody(POST /users,PUT /users/{id}) takeskind(required),capabilities(the scope strings above, a strict allow-list — an unknown or repeated scope refuses 400) orpreset, an edge alias the door expands and never forwards:INITIATOR,APPROVER,ADMIN,TRADER,TREASURY,PLATFORM. At least one of the two is required; both stated must agree (the preset’s expansion equals the listed set) or the body refuses 400. The row records bits, never the preset —GET /usersrenders the expansion.rolein a body is ignored like any unknown property, so a114.v5body withoutkindandcapabilitiesrefuses 400 at the generated parse.OrgUpdatedgainsrequiredApprovals(integer, 0..255): how many approvals a maker-checker mutation in the org needs —1is the dual-control pair;0means the maker’s fact is the concluded row (local and throwaway stacks; a production guard refuses it elsewhere). It is a plain count:0is a value, nevernull.GET /me(new): the caller’s own row joined to its org —userId,orgId,orgName,name,displayName,kind,status,capabilities,requiredApprovals,isInternalPlatform,globalSequence. The one read aDISCOVEREDprincipal may make; every UI configures itself from it and from nothing in a token.- Refusal codes:
ROLE_DENIEDis renamedCAPABILITY_DENIED(403 — the owner’s register reason now spells the same as the orders owner’s); newUSER_NOT_ADMITTED(403, the actor isDISCOVERED) andORG_NOT_ACTIVE(403, the actor’s org is notACTIVE).IDP_REF_TAKEN(409) stays. TheOrgRegisterRejected.reasonenum publishes them all. info.versionmoves from114.v5to114.v6.
What each side of the deploy window sees
- There is no mixed window on the register: the break resets the stream, and the
org-register pair, the orders owner, the gateway and the persistence pair roll together
onto v6. Nothing fail-stops a
114.v5member that meets a v6 frame — it misreads it silently (UserUpdated.statusrenumbered: a v6DISCOVEREDrow reads as v5ACTIVE, thekindbyte reads as arole), which is why the group rolls as one. The other direction is refused: a v6 owner answers a114.v5UpsertUseror bootstrap frameINVALID_FIELD(detailschema version 5 is below the 114 v6 floor) rather than reading a grant out of its bytes, and a v6 users or orgs fold fail-stops on a pre-v6UserUpdated/OrgUpdated— a stream the break did not reset. Roll the clients with them. - A client on
114.v5parsingrolefinds no such property on a114.v6gateway: readcapabilitiesinstead, and branch onCAPABILITY_DENIEDwhereROLE_DENIEDwas. A client posting a114.v5user body (role, nokind) is refused 400 (MALFORMED_REQUEST) — loudly, never with a silently defaulted grant.
114.v5 — 2026-09-15 — the IdP subject binding
The register records which IdP subject a principal is; the read edge serves it.
What changed
UserUpdatedgainsidpUserRef(string,x-since-schema-version: 5): the IdP subject bound to the user — the token’ssub(an Auth0 user id such asgoogle-oauth2|1111…orauth0|6aa2…), opaque: compare it, never parse it. Empty means unbound — a principal admitted before the IdP, or a local lane without one — served as"", nevernull. A subject resolves to at most one live user per org.UpsertUserBodygainsidpUserRef(string,x-since-schema-version: 5, not required) onPOST /usersandPUT /users/{id}— the subject to bind, read off the IdP’s dashboard (the token’ssub). An appended field is lenient: absent,nulland""all mean unstated. On a create that leaves the new user unbound; on aPUTrestate it keeps the row’s current binding (unstated, never unbind — a rename never strands a login), and a value re-binds. A row read fromGET /users/{id}round-trips into itsPUTbody unchanged.- A new register refusal,
IDP_REF_TAKEN, inNAME_TAKEN’s class (409): anUpsertUserstating a subject another live user of the org already carries. ADISABLEDuser’s subject is free; a row restating its own subject is no collision. info.versionmoves from114.v4to114.v5.
What each side of the deploy window sees
- A client on
114.v4ignores the new property; nothing it already parses changes. - A gateway still serving
114.v4renders a user the register has already bound without the property — so “absent = unbound” only holds once the serving gateway is on114.v5; checkinfo.versionbefore concluding a user carries no subject. - A gateway on
114.v5servesidpUserRef: ""for every row the register wrote before v5 and every row noADMINhas bound; the bootstrap of a deployment an IdP fronts binds its firstADMINat birth. - A client posting a
114.v4user body (noidpUserRef) to a114.v5gateway is accepted unchanged — the appended field reads as unstated. The other direction is the hazard: a114.v5body against a114.v4gateway is accepted too, and the binding is silently dropped (the edge ignores properties it does not know; onlyuserIdis refused). Checkinfo.versionbefore binding a subject through the edge. - The org-register pair restarts lockstep (its owner’s snapshot version moves); roll the
gateway after the register, so the property appears as soon as the register can set it —
and before any subject is bound through the edge. A
114.v4gateway’s generated codec does not degrade on a reason it does not know: its lookup throws on the ordinal (IDP_REF_TAKENis 26) rather than answeringUNKNOWN, so anIDP_REF_TAKENrefusal reaching a114.v4gateway fail-stops that member — it rejoins and replays, and the request that earned the refusal concludes on the 202 sweep, never as a 409. From114.v5the command lane reads the reason through a guard that maps an unknown ordinal toUNKNOWN, so the next appended reason is served 422 (the outcome table’s documented arm) until the gateway rolls, instead of stopping it.
114.v4 — 2026-09-15 — the HTTP order surface: POST /orders, the cancel, the orders and executions reads
The document’s info.version stays 114.v4 — no register message moves — and gains
info.x-immix-orders-schema: "111.v0", the orders schema the new messages derive from.
What changed
- Six routes under a new
Tradingtag.GET /ordersandGET /orders/{id}serve the orders domain’s rows;GET /executions(with an optional?orderId=join filter) andGET /executions/{id}serve the executions;POST /ordersproposesSubmitOrder;POST /orders/{id}/cancelproposesCancelOrder. Org-predicated exactly like the register families (a foreign id is 404, indistinguishable from absent). The two reads have one surface: noviewparameter —view=approvedrefuses 400MALFORMED_REQUEST. Order ids are int64 and cross as decimal strings, in the path too. - The submit’s
Idempotency-KeyIS the order’sclientOrderId— the orders owner’s business idempotency key: 1 to 36 characters of the key grammar (its own parameter,OrderIdempotencyKey; a longer key refuses 400INVALID_IDEMPOTENCY_KEY). A bodyclientOrderIdmay restate it, never contradict it (400). A retried key replays or attaches at the edge as every key does; a retry that reaches the owner afresh (a restarted instance, an evicted key) restates the same order under it — the same key can never mint a second order — and another user’s key answers 409CLIENT_ORDER_ID_CONFLICT. - A submit must name its instrument, by id or by name.
instrumentId(the int64, as a decimal string) andinstrument(refdata’s platform symbol —OKX@BTC/USDT, with the venue’s product suffix where it has one) are two spellings of one field: at least one is required, both stated must agree (400MALFORMED_REQUEST), and the symbol is resolved at the edge against the instruments that member holds — only the id ever rides the command, and every served row carries the id.SubmitOrderBodypublishes both properties and requires neither on its own; a client that sendsinstrumentIdtoday is unaffected. Symbols are refdata’s to change, so an id remains the stable address and a name the convenience; a symbol two instruments share resolves to the higher id, the same rule refdata’s own symbol index keeps. - The named instrument is the scale source:
price(LIMIT only; absent,nullor"0"for MARKET) scales by the instrument’spriceScale,qtyby itsqtyScale, exactly — more fractional digits than the scale refuses 400; an instrument the projection does not hold refuses 422SCALE_UNKNOWNunsequenced, by either spelling (the message names what was asked for). - Money on orders and executions serves like the register’s: price-shaped fields
(
price,avgPx,lastFillPx,fillPx) as decimals atpriceScale, quantity-shaped fields (qty,cumQty,leavesQty,lastFillQty,venueCumQty,fillQty) atqtyScale, an execution’sfeeat the fee asset’sunitScale; a required money field always serves a decimal ("0.00000000"is a quantity), the optional ones servenullat their sentinel —venueCumQty’s is-1, the one non-zero sentinel on the served surface; a value whose scale source is not held serves raw underunscaled, as the register’s does. - The cancel takes no
If-Match(a cancel must never lose a version race — no version precondition applies; a stated one refuses 400INVALID_IF_MATCH); its body is optional, a bodyorderIdmay restate the path, andCancelOrderBodycarries noclientOrderId— the path names the order, and a stated key refuses 400 (an empty string is that field’s unset sentinel: tolerated, never addressing). The accept answers the row withcancelRequestedAtNsset and the status unchanged — the venue-async window;CANCELEDarrives later on the venue’s word as an ordinary order update the reads serve. An order’sETagis its per-orderversion, a read token only; executions carry no version and noETag. A replayed 200 whose order has since been released (the owner’s retention outran the journal window) answers 404NOT_FOUNDunderIdempotent-Replay— the read’s own answer, never a fabricated row. - The orders owner’s reasons ride verbatim as
error.code(theOrderRejectReasonvalue set, published onOrderRejected.reason), mapped by class: 403CAPABILITY_DENIED(the edge pre-checks no role — the owner refuses a principal who is not a liveTRADER); 404UNKNOWN_ORDER; 409CLIENT_ORDER_ID_CONFLICT,ACCOUNT_NOT_ACTIVE,CONNECTION_DOWN,TRADING_HALTED,NO_REFERENCE_PRICE,OPEN_ORDER_CEILING,ORDER_TERMINAL,ORDER_STATUS_UNKNOWN; 429RATE_CEILING(withRetry-After: 60, the window); 422 the field, reference, sizing and cap refusals. A refusal’serror.messagerestates the owner’s detail — the observed value against the limit in the reason’s own unit, and the trading-policy pin — and a replay keeps it. - The projection position moves on every fact this edge folds, the orders domain’s
included: a reader observing position S observes every register row AND every order and
execution at or before S (before, the token moved on register facts only). The
GlobalSequenceheader’s andGlobalSequenceValue’s descriptions say so. OperationRecord.outcomegainsorderId(an int64 string) andfamilyadmitsorders: an orders operation names its order there, never in the int32entityId. It also gainsmessage— the free text the same outcome answered synchronously aserror.message— so a client that polled a202reads the refusal’s detail too; absent when the outcome stated none (every accept, the register owner’s refusals, the door’s).- New components: parameters
OrderIdempotencyKey,OrderId,ExecutionId,OrderIdFilter; schemasOrderUpdated,ExecutionRecorded,OrderRejected,SubmitOrder/SubmitOrderBody,CancelOrder/CancelOrderBody; responsesOrderAnswer,OrderRow,ExecutionRow.Error.code’s description names the order reasons;ForbiddenandBackpressuredescribe their order-side arms.
What each side of the deploy window sees
- A client of the register contract keeps working unchanged: no path, property, type or
requiredset of any register route moves. The only shared change is the token’s meaning — it now also advances on order facts — which a client comparing tokens for monotonicity never notices. - A gateway still on the previous build answers every order route 404 (the route does
not exist); a gateway on this build answers the contract above. The gateway now
borrows the orders owner’s two folds (
orders,executions): it must be deployed where the orders owner runs, and its boot order follows the owner (the genesis-floor rule — a borrower refuses to cold-start until the owner it borrows has an acknowledged snapshot where a store is wired). Locally, start the orders owner before the gateway. - The owner-liveness gate is the register owner’s: an absent orders owner does
not refuse at the door — a submit commits and answers 202 at the sync deadline, its
record standing
COMMITTEDuntil the owner returns. - The instrument alias is additive on both sides of the window: a body naming
instrumentIdis the shape a gateway on either build accepts, and one naminginstrumentis refused 400 by a gateway still on the previous build (the body then names no instrument at all). A client that wants the name must therefore know it is talking to this build — readinfo.x-immix-orders-schemaand the presence ofSubmitOrderBody.properties.instrument, or simply keep sending the id.
114.v4 — 2026-09-15 — the two freshness arms’ 0 sentinels are described correctly
A prose correction only: no message shape moves, no served value changes and
info.version stays 114.v4 — the published descriptions of two TradingPolicyUpdated
fields stated the opposite of what the orders owner and the oe connector do with them.
What changed
TradingPolicyUpdated.refFreshnessNs’s description.0now reads as disables the age comparison alone — the reference itself stays mandatory for every order type, so an absent reference refuses either way, where it read “0 = nothing admits (the strict arm — state a window to trade)”. The owner’s admission arm guards the comparison withrefFreshnessNs > 0, so0is the permissive setting — no staleness bound at all — not a lockout.NO_REFERENCE_PRICEstill refuses when no reference exists, at anyrefFreshnessNs.TradingPolicyUpdated.instructFreshnessNs’s description.0now reads as the connector’s configured default window (instruct.freshness.default.ms), never a lockout, where it read “0 = nothing instructs (strict arm)”. The oe connector substitutes its configured default whenever the governing policy states0; the first-instruction gate always applies, only its window is operator-set.degradedGraceNsis untouched — its “0 = any DEGRADED refuses” was, and stays, accurate. The three duration sentinels on this row genuinely differ from one another; read each field’s own description rather than generalising from a neighbour.
What each side of the deploy window sees
- Nothing behavioural, on either side. No arm changes, so a client sends the same values and gets the same answers from a gateway on either build; the document is catching up to arms that have always worked this way.
- What moves is what a reader believes
0means, and the old text pointed the wrong way on a risk control. An operator who read it and leftrefFreshnessNsat0believing it refused everything was in fact running with no reference-staleness bound; one who leftinstructFreshnessNsat0for the same reason was running the connector’s default window rather than a lockout. Both are worth re-checking against the intended policy — state a non-zerorefFreshnessNsto bound reference age (5 s is the recommended bound) and a non-zeroinstructFreshnessNsto override the connector’s default.
114.v4 — 2026-09-15 — the command surface: 40 routes over the register’s commands
The document’s info.version stays 114.v4: no message shape moves; the document grows
routes, request bodies and components.
What changed
- Three route shapes per family, 40 routes over 34 commands.
POST /{family}creates (users,connections,account-addresses,portfolios,asset-policies,chain-policies,trading-policies);PUT /{family}/{id}amends or restates (the same families minusaccount-addresses);POST /{family}/{id}/{verb}acts —users/disable;connections/approve|suspend|reactivate|revoke|rotate-credential|withdraw;accounts/classify|approve|freeze|unfreeze|retire|withdraw;account-addresses/verify|retire;portfolios/retire;asset-policiesandchain-policiesapprove|retire|withdraw;trading-policies/approve|retire|withdraw|halt|resume.orgsandaccountshave no create; the connector-proposed commands and the bootstrap never ride this edge. - Request bodies are the command schemas minus what the edge supplies, published as
derived
<Command>Bodyschemas beside the generated<Command>schemas:userIdis absent from every body schema and refused if sent (400MALFORMED_REQUEST) — it is stamped from the admitted principal; the entity ref (connectionId,targetUserId, …) andexpectedVersionare route-sourced (the path,If-Match) and not required — a body may restate them, never contradict them (400). A create’s ref must be absent,nullor0. String fields are required as the schema states them (send""for none — the empty-not-null rule the rows already follow); optional wire fields are lenient (absent ornull= the 0 sentinel); enums by name, strictly (the sentinel refuses 400); booleans as booleans, absent =false. An action’s body is optional ({}or empty is legal). Idempotency-Keyis required on every unsafe route: a URL-safe string of 1–64 characters from[A-Za-z0-9._~-](a UUID fits), scoped by the admitted principal. A retry of a concluded key replays the stored outcome withIdempotent-Replay: trueand publishes nothing; a retry of a live key attaches (one publish total); a key reused on another family or entity refuses 409IDEMPOTENCY_KEY_REUSED.If-Matchon entity routes carries the command’sexpectedVersion: theETagan entity read minted, quoted or bare; absent or*= unconditional; weak validators refuse 400INVALID_IF_MATCH. A stale precondition answers 412VERSION_CONFLICTwith the entity’scurrentVersionin the envelope and a freshETag: re-read, reapply, retry.- Every write is a full-record restatement — send
If-Match. A register command carries the whole record and the answering fact replaceslatestwholesale, so a write with no precondition restates every field from the client’s copy, including the ones it never meant to touch: two admins editing one row from stale reads silently revert each other, both answered 200 (the owner’s pending book keeps two commands from clobbering each other inside the owner; it cannot know the second was assembled from a stale read — only the token can). Round-trip theETagof the read the edit was based on, and on 412 re-read before retrying. The same shape one step less visible: an amend against an entity whose proposal is still pending replaces that proposal wholesale — aclassifyon an account with a pendingAMENDis a revision of that proposal, not a collision with it, so withoutIf-Matchthe second maker’s body simply becomes the proposal. TheIf-Matchparameter’s description states both, on every entity route. - Responses are the owner’s answer from the stream. 200 is the family’s row (the same
shape the reads serve, plus
globalSequence) with the answering fact’s position inX-Immix-Global-Sequenceand the row’sETag; the same instance’s reads already reflect it. A refusal the owner answered carries the refusal’s own position, and the same instance’s reads are at or past it too (every register fact moves the position, a refusal folding to nothing); a replayed 200 restates its original answer’s position, no newer than the projection’s. 202 is committed-or-pending:{"status":"PENDING","key":k}withLocation: /operations/{key}. The owner’s rejection reasons ride verbatim aserror.code, mapped to statuses by class: 412VERSION_CONFLICT; 403SELF_APPROVAL,ROLE_DENIED,NOT_PROPOSER,USER_DISABLED; 409 for state and uniqueness refusals (ILLEGAL_TRANSITION,PROPOSAL_PENDING,NOTHING_PENDING,NAME_TAKEN,DUPLICATE_POLICY,DUPLICATE_ADDRESS,CONNECTION_IN_USE,PORTFOLIO_IN_USE,LAST_ADMIN,CONNECTION_NOT_ACTIVE,ALREADY_DISCOVERED,ALREADY_BOOTSTRAPPED— and, from114.v5,IDP_REF_TAKEN); 422 for field and reference refusals (UNKNOWN_ENTITY,INVALID_FIELD,MISSING_VENUE,WRONG_CONNECTION,ORG_MISMATCH). Door refusals: 400 (MISSING_IDEMPOTENCY_KEY,INVALID_IDEMPOTENCY_KEY,INVALID_IF_MATCH,MALFORMED_REQUEST), 422SCALE_UNKNOWN, 404NOT_FOUND(an entity route’s id absent from, or outside, the principal’s org — exactly as the read answers), 429INBOX_FULL/IN_FLIGHT_FULL(Retry-After), 503NOT_PRIMED/NOT_SERVING/OWNER_ABSENT. A replayed refusal (403, 409, 412, 422 — the owner’s rejections, sequenced and journaled) carriesIdempotent-Replay: trueand the position of the rejection fact exactly as a replayed 200 does; the edge’s and the platform’s own refusals (the 400s, 409IDEMPOTENCY_KEY_REUSED, 413TOO_LARGE, 422SCALE_UNKNOWN, 429, 503) are never journaled and carry neither header — a retry takes fresh. In the document each family’s row is one named schema (OrgRow,UserRow, …: the fact plusRowExtras) that both its read’s 200 and its commands’ 200 reference, and every operation carries anoperationId(listOrgs,createUser,approveConnection, …): generated clients get one row type per family, by construction, and stable method names. - Money in mirrors money out.
amount_t,qty_tandnotional_tfields are decimal strings scaled at the edge by the same source the reads render by — the referenced asset’sunitScale, the referenced instrument’s qty and price scales — exactly: more fractional digits than the scale refuses 400; a scale the projection does not hold for the asset or instrument the body names refuses 422SCALE_UNKNOWNnaming the ref (a client may retry once refdata lands); a cap stated with no ref at all (assetIdorinstrumentIdabsent), or a ref off its grammar, can never scale and refuses 400; absent,nullor"0"is the 0 sentinel, so a row read fromGETround-trips into aPUTbody. The org-default trading policy (instrumentIdabsent) takes no caps (400). GET /operations/{key}serves the principal’s own operation records (key,phase,commandGlobalSequence,origin{memberId, instanceId}, timestamps,outcome{httpStatus, code, family, entityId, globalSequence}); another principal’s key is 404UNKNOWN_OPERATION, indistinguishable from an unknown one. The journal is member-local: poll the instance that carried the request (session affinity behind N instances); a restarted instance answers 404, and a retried key then republishes — safe, because every create is natural-key-unique and every action state-gated at the owner.Error.codeopens. It was a closed enum of five read-side codes; it is now a string whose description lists the door codes and points atOrgRegisterRejected.reason(now published) for the owner’s set — append-only.ErrorgainscurrentVersion(412 only). TheRejectedresponses declare the position header (the refusing fact’s).- New components: parameters
IdempotencyKey,IfMatch,OperationKey; headerIdempotentReplay; schemasPendingOperation,OperationRecord,OrgRegisterRejectedand the 34 command/body pairs; responsesPending,BadRequest,Rejected,VersionConflict,Backpressure,Unavailable,InternalError,UnknownOperation(ForbiddenPrincipalis renamedForbidden; the response is unchanged). The document’s title and description say the edge is the register’s reads and commands.
What each side of the deploy window sees
- A client of the read contract keeps working unchanged: no path, property, type or
requiredset of anyGETmoves. A client whose generatedError.codetype was a closed enum must regenerate before it meets the new codes (a stale enum would have refused them as unparseable — that is why the field opened). - A gateway still on the previous build answers every unsafe route 404 (the route does not exist); a gateway on this build answers the contract above. Roll the gateway before any client that writes; the register owner needs no change for this one (the commands are the ones it has always accepted).
- The 202 path is honest about a quiet or slow owner: a command committed but not yet
answered within the sync deadline answers 202 and concludes on the record later; a
client that needs the row polls
/operations/{key}on the same instance, or re-reads.
114.v4 — 2026-09-15 — the connection’s observed axis
The register records what the venue said about a staged key, on the connection row itself; the read edge serves it.
What changed
ConnectionUpdatedgains the connection’s observed axis — eleven properties, allx-since-schema-version: 4, filled by the register from the connector’s pre-approval key readout (ReportConnectionProbe, a machine-proposed command no gateway route prepares):observedPermissions(arrayofstring; items enumeratedread/trade/withdraw;uniqueItems): the key scopes the venue stated, served in that order. Empty means no scope observed — served as[], nevernull. The choice set is append-only: skip an unknown name, never error on it.observedAllowlist(string, enumUNKNOWN/NONE/COVERS/MISSES): the key’s IP allowlist judged against the cluster’s egress set.probeOutcome(string, theHealthReasonenum):UNKNOWN= never probed;NONE= the venue’s read answered, and the observed scopes are its word; any other value is the probe’s own failure (AUTH_FAILED,INVALID_KEY,IP_NOT_WHITELISTED, …), withprobeDetailsaying what the connector could.probedAtNs(int64 epoch-ns as a decimal string, nullable): the consensus time of the probe report the row records — the register stamped it, so it is the same on every member;null= never probed.observedKeyCreatedAtNs(int64 epoch-ns as a decimal string, nullable): the key creation time the venue stated;null= the venue states none, or never probed.observedKeyId,observedKeyFingerprint,venueAccountRef,permissionDetail,allowlistDetail,probeDetail(string, empty until a probe answers): the venue-facing key identifier (the public half — never material), the fingerprint of the material the probe adopted (compare withkeyFingerprint, the intake’s pin), the venue’s account identifier behind the key, the venue’s own permission and allowlist strings verbatim, and redacted diagnostic text for a non-NONEoutcome.
- “Never probed” is one shape:
probeOutcome: "UNKNOWN",probedAtNs: null,observedPermissions: [],observedAllowlist: "UNKNOWN",observedKeyCreatedAtNs: nulland six empty strings — what every row the register wrote before v4 serves, and every v4 row until a probe answers. - Where an answer shows: a probe on a
PENDING_APPROVALproposal shows onview=latestonly, andview=approvedgains it with the conclusion — the observed axis is content, like the proposal it measured. A probe on anACTIVEorSUSPENDEDrow with nothing pending shows on both views at once. A later probe supersedes an earlier one whole (probedAtNsmoves). - The register now refuses an activation, reactivation or credential rotation whose
answered probe (
probeOutcome: "NONE") shows the key scoped short of the row’s declared capabilities (SCOPE_INSUFFICIENT—canReadBalancesneedsread,canTradeneedstrade,canExecuteTransfersneedswithdraw) or beyond them (SCOPE_MISMATCH— a Read-only pipe must not carrytradeorwithdraw). A reader sees such a row stayPENDING_APPROVALwith itsobservedPermissionsexplaining why, until a re-probe answers with the right scopes. An unprobed row and a failed probe approve as before. - The document description states the set rule beside the enum rule; no existing property
changes name, type, nullability or
requiredmembership. info.versionmoves from114.v3to114.v4.
What each side of the deploy window sees
- A client on
114.v3ignores the eleven new properties; nothing it already parses changes. - A gateway still serving
114.v3renders a connection the register has already probed without the properties — so “absent = never probed” only holds once the serving gateway is on114.v4; from then on the row itself says so (probedAtNs: null,probeOutcome: "UNKNOWN"). Checkinfo.versionbefore concluding a key was never read. - A gateway on
114.v4serves the never-probed shape for every row the register wrote before v4 and every row not yet probed: the properties are present from the first v4 response, with their defaults, and fill in as probes answer. - The org-register pair restarts lockstep (its owner’s snapshot version moves); roll the
gateway after the register, so the properties appear as soon as the register can set
them. Treat an unknown
probeOutcomeorobservedAllowlistname asUNKNOWNand skip an unknownobservedPermissionschoice — every one of these sets is append-only.
114.v2 — 2026-09-07 — amount_t values serve as exact decimals off the asset’s unitScale
A rendering change under the same schema: no message shape moves and info.version stays
114.v2 — the unscaled marker is what tells the two sides apart.
What changed
AssetPolicyUpdated.maxTicketAmount/approvalThresholdAmount/reconciliationToleranceAmountandChainPolicyUpdated.minTransferAmount/maxTransferAmountare served as exact decimal strings scaled by the referenced asset’s refdataunitScale—"2.500000000000"for a 2.5 BTC ticket cap at BTC’s scale of 12 — where they were raw scaled-integer strings ("2500000000000") under"unscaled": truebefore.- The
unscaledmarker narrows to the missing-scale fallback, uniform across both money sources: a row carries it only while the projection does not hold a money field’s scale source — the referenced asset not yet folded or itsunitScaleunstated, the referenced instrument not yet folded (the catch-up window, or an absent row). Absent, every money value on the row is an exact decimal. The collection-level marker keeps its coarse meaning (present while any item’s row is flagged). - The document description and
RowExtras.unscaled’s description say so; no path, property, type orrequiredset changes.
What each side of the deploy window sees
- A client that branches on
unscaled(the contract said to) keeps working unchanged, because the marker’s two meanings are what they were. On an entity response it says some money field on that row is a raw scaled integer. On a collection response it is coarse: at least one item’s row would carry it, and the items themselves carry no per-row marker, so a client that must know which item is raw re-reads those entities. What changes is only where it appears: no longer onamount_trows whose asset the gateway holds with a stated scale. - A client that parsed
amount_tvalues as raw integers without reading the marker was already off-contract; on the new gateway it reads"2.500000000000"where it read"2500000000000". Read the marker, or parse every money field as a decimal string. - A gateway still on the previous build serves
amount_traw under the marker whatever the refdata says; a gateway on this build serves decimals for every asset whoseAssetUpdatedfact states aunitScale. An asset stated before the refdataunitScalefield existed decodesunitScale0 (not stated) and its amounts stay raw under the marker until the refdata owner restates it — honest at every point of the window, and no lockstep restart: refdata and the gateway roll independently.
114.v2 — 2026-09-07 — a connection names the connector app group that serves it
What changed
ConnectionUpdatedgainsappGroup(string,x-since-schema-version: 2): the connector app group serving this connection — one group per connection by construction. Empty means unassigned, and nothing serves it; that is the fail-safe, so orders on an unassigned pipe are refused rather than silently routed.info.versionmoves from114.v1to114.v2.
Why its own version, and not part of 114.v1
appGroup first landed alongside 114.v1’s tradingConnectionId, both marked
x-since-schema-version: 1. That is not a legal append: a 114.v1 encoder writes
ConnectionUpdated with no appGroup bytes, but a reader gating on
actingVersion < sinceVersion (1 < 1) reads a length prefix past the end of the message —
a buffer overrun on replay, or a garbage value. Two wire shapes cannot share one version
number. Every append takes the version that introduces it, which is what makes the
append-only guarantee hold at all.
What each side of the deploy window sees
- A client on
114.v1ignores the new property; nothing it already parses changes. - A gateway still serving
114.v1renders a connection the register has already assigned without the property — so “absent = unassigned” only holds once the serving gateway is on114.v2. Checkinfo.versionbefore concluding a connection has no connector. - The org-register pair restarts lockstep (its owner’s snapshot version moves); roll the gateway after the register, so the property appears as soon as the register can set it.
114.v1 — 2026-09-02 — accounts designate their trading pipe
What changed
AccountUpdatedgainstradingConnectionId(integer/int32, nullable,x-since-schema-version: 1): the trading pipe this account’s orders instruct through when set. Absent ornullmeans orders route through the account’s ownconnectionId— the default, and the only shape the contract had before.ConnectionUpdated.canTrade’s description is reworded to say that orders route through the account’s trading pipe and that any number of trading connections may share one (org × venue). No shape change.info.versionmoves from114.v0to114.v1.
What each side of the deploy window sees
- A client on the old contract ignores the new property; nothing it already parses changes.
- A gateway still serving
114.v0renders an account the register has already designated without the property — so “absent = own pipe” only holds once the serving gateway is on114.v1. Checkinfo.versionbefore inferring a route from absence. The org-register pair restarts lockstep for this change (its owner’s snapshot version moves); roll the gateway after the register, so the property appears as soon as the register can set it.
114.v0 — 2026-09-01 — the initial contract
The nine register facts as read models; no client-visible change was recorded before this file existed.
Data
Consumer-facing changes to this API document, newest first. Each heading is the document’s
version, and the document’s info.version always equals the newest heading. Within a major
version, changes are additive only.
0.6.0 — 2026-10-12 — Instruments rank by USD turnover; the enum filters list their values
Additive: one new ticker field and sort, and value lists on parameters that took them in prose.
ticker.turnoverUsd24histurnover24hvalued in USD at the quote asset’s current USD rate: the last 24 hours of quote turnover at today’s rate, an approximation. It is the one statistic comparable across quote assets.volume24his in base units andturnover24hin quote units, so neither ranksOKX@SOL/BTCagainstOKX@BTC/USDT. It isnullwheneverturnover24his, and when no USD rate is current for the quote asset. The name and the rule are the market-data stream’stickerEvent’s.sort=turnoverUsd24h(and-turnoverUsd24h) orders the instrument list by it, absent values last in both directions, as every statistic sorts.-turnoverUsd24his the “biggest markets first” order for a list that mixes quote assets.- The enum filters list their values, so a reference or a generated client offers them
rather than a free string.
typeandstatusonGET /instrumentsandtypeonGET /assetsare open: each item isanyOfthe documented values and any string, because each filter also takes a value its rows carry beyond the list, so a value you were served stays valid.includeon every list is a closedenum: anything else is400. Nothing about what is accepted changes. A generator that readsanyOfan enum and a string as an open enum offers the values and still sends another. The rows’ owntypeandstatusstay open strings: those value sets only grow, so tolerate a value you don’t know. - The facets say which filter each dimension ignores. Each facet is counted under every
filter but its own dimension’s, as since 0.4.0, so with
type=SPOTthetypesfacet still counts every type the rest of the query matches. The descriptions now say so field by field.
During the deploy window a server on 0.5.0 answers sort=turnoverUsd24h with
400 MALFORMED_REQUEST and sends no turnoverUsd24h on the ticker, and one on 0.6.0 serves
both: retry such a 400 once the deploy is through. Everything else is the same on both.
0.5.0 — 2026-10-09 — The reference reads as the wire: property order and examples
No shape changes: every schema, parameter and response is as published in 0.4.0. Two things change how the document reads.
- Properties are listed in the order the service sends them, where they were listed
alphabetically. A page reads
items,nextCursor,prevCursor,total, thenfacetson instruments, andasOfSeqTsNslast; a row opens with its id, then its symbol or code; a ticker withlastPx. JSON member order carries no meaning, so a client that reads members by name sees no difference.requiredlists keep their order. - Every operation’s
200carries an example: the service’s own answer to a small request over a fixed sample of the catalog, members in the order they are sent. The venue and asset lists are a first page of two withinclude=total; the instrument list is OKX’s BTC markets withinclude=total,facets,ticker; the lookups by id answer for OKX, BTC and OKX’s BTC/USDT perpetual. The values are real in shape, not live: the cursors in them are valid on no deployment. - Each property’s own example agrees with them: where a property shows a value in an
operation’s example, its schema’s example is one of those values, and a decimal’s is spelt
plain, as the wire spells it (
"0.0002", wheremakerFeeRateandtakerFeeRateread"2.0E-4"and"5.0E-4").
0.4.0 — 2026-10-08 — The instrument list is complete: search, tickers and facets
GET /instruments now serves everything it published: search, include=ticker and
include=facets no longer answer 501 NOT_IMPLEMENTED, and the operation no longer declares
that response. No shape changes: the parameters and the Ticker, Facets and FacetCount
schemas are as published since 0.2.0.
searchmatches word prefixes: every word sent must start one of the instrument’s own words, those of its symbol, of the venue’s own symbol, of its type, its id and, for a dated future, its expiry (jun,2027,25,jun27). Anything but ASCII letters and digits separates words and is otherwise ignored, sobtc/usdtisbtc usdt, and a search with no ASCII letter or digit filters nothing. One character is enough, and every match is listed. It combines with every filter, sort and lookup bysymbol, and a cursor is bound to it as its words.include=tickerrenders each row’stickerfrom the same statistics the sorts read: exact decimal strings at display precision;nullfor a value the statistics cannot state and for a window still warming (pctChange1huntil the one-hour window is warm, the five 24-hour fields until the 24-hour window is), never0;windowCompleteis the 24-hour window’s flag; andtickerisnullfor an instrument that has never traded or whose last price cannot be stated.include=facetscounts the matching instruments per venue, type, base asset and quote asset, each dimension under the request’s filters minus that dimension’s own (venueId,venueandvenueId.notInare the venue dimension’s;search,statusandsymbolapply to every facet), so a filter panel keeps showing its alternatives. Eachvalueis what the matching filter takes, with the display alias aslabel(nullfor types); values with no matches are omitted; within a dimension the values sort by count, most first, then by value, ids as numbers and type names as text. A lookup bysymbolcounts its own rows.
During the deploy window a server on 0.3.0 still answers search, include=ticker and
include=facets with 501 NOT_IMPLEMENTED, and one on 0.4.0 serves them: retry a 501 once
the deploy is through. Everything else is the same on both.
0.3.0 — 2026-10-06 — The error envelope and headers match the rest of the REST API
The error envelope and the headers every exchange carries are now declared as the rest of the Immix REST API declares them, so a client generated from either document reads them the same way. One answer changes: a path no route serves has a code of its own.
ROUTE_NOT_FOUND(404, not retryable) answers a path no route serves: a misspelt path, or an operation this version does not serve yet. It wasNOT_FOUND, which now means one thing only: the id names nothing served here.Error.currentVersionis declared: an optional decimal string besideerror, which a conditional write’s412 VERSION_CONFLICTcarries (the row’s current version, as in itsETag). No operation of this API is a conditional write yet, so no response carries it.X-Request-Idis declared on every operation, as an optional request header and as a header of every response. The service already took and echoed it: send 8 to 128 of[A-Za-z0-9._-]to name a request yourself; a value off that grammar is replaced with a fresh id, never refused.X-Immix-As-Of-Seq-Tsis declared on the200of every read, which already sent it.retryableis described by the rule both REST APIs share: sending the identical request again, afterRetry-Afteror a short pause, can succeed with nothing else changed. No code’s value changes.
In the document, every operation gains the parameter RequestId, every response the header
RequestId, and every read’s 200 the header AsOfSeqTs. A regenerated client gains one
optional argument per operation and two response headers it can read, and Error gains an
optional currentVersion.
During the deploy window a server on 0.2.0 answers a path no route serves NOT_FOUND,
and one on 0.3.0 answers it ROUTE_NOT_FOUND, both with status 404: treat a code you do not
know by its status. Everything else is the same on both.
0.2.0 — 2026-09-25
Contract stubs, published ahead of their implementations. Every operation added here is
published with its full shape and answers 501 NOT_IMPLEMENTED until its implementation ships;
nothing already published changes.
GET /instruments(stubbed, pending): the instrument catalogue withsearch(word prefixes of the symbol, the venue’s own symbol, the type and the id), lookup bysymbol(at most 100 per call), the filtersvenueId,venueId.notIn,venue,type,status,baseAssetId,baseAsset,quoteAssetIdandquoteAsset, sorts bysymbol,instrumentId,type,listedAtNs,expiresAtNsand the 24h statistics, andinclude=total,facets,ticker.GET /instruments/{instrumentId}(stubbed, pending).GET /assetsandGET /assets/{assetId}(stubbed, pending):searchas a substring of the symbol, the filterssymbolandtype, sorts bysymbol,assetIdandtype,include=total.- New schemas:
Instrument,InstrumentPage,Ticker,Facets,FacetCount,Asset,AssetPage. - The error code
NOT_IMPLEMENTED(501, not retryable): a published operation whose implementation has not shipped. - Conventions, in the guide: the per-resource
searchrule (word prefixes for instruments, a substring for venues and assets), the.notInfilter suffix,include=facetsandinclude=ticker, and the symbol grammar. - Amended 27 Sep 2026, before any
v0.2.0bundle existed: thetypesort on both lists,searchover the venue’s own symbol and the id, and clearer field descriptions. - Amended 28 Sep 2026:
GET /instruments/{instrumentId}is live, and no longer answers501. A removed instrument is served with its laststatus; another environment’s instrument, and any id that is not exactly an instrument’s (0300,+300), is404. (Thev0.1.0release bundle carries this document as it stood on 28 Sep, with every phase-1 operation stubbed.) - Amended 29 Sep 2026:
GET /assetsandGET /assets/{assetId}are live, and no longer answer501. A removed asset is absent from the list and404by id, as is any id that is not exactly an asset’s (0201,+201).symbolsorts ignore case.typetakes the eight documented classes and any other class an asset carries, a removed asset’s included; a value that is neither is400 MALFORMED_REQUEST, which the guide now states for every enum filter. (Thev0.2.0-rc.1release bundle carries this document as it stood on 28 Sep, with both asset operations stubbed.) - Amended 1 Oct 2026:
GET /instrumentsserves lookups bysymbol, which no longer answer501: at most 100 symbols a request, matched case-insensitively against each instrument’s canonical symbol, sorted bysymbol(the default, ignoring case),instrumentId,type,listedAtNsorexpiresAtNs, withinclude=total. A symbol that names nothing served here is absent from the page, a legacyYYYYMMDDmaturity (…-20270625) included; more than 100 symbols, a repeat counted, is400 MALFORMED_REQUEST, naming the limit. A cursor is bound to the symbols as a set, whatever their order or case. The list withoutsymbol, every other filter, the statistics sorts andinclude=facets,tickerstill answer501, as the501response now describes. On every list, an “any of” parameter may be repeated and comma-separated at once. - Amended 1 Oct 2026, the list:
GET /instrumentsserves it, and onlysearch,include=facetsandinclude=tickerstill answer501. Every filter narrows it:venueId,venueId.notIn,venue(a venue code),type,status(SUSPENDEDamong them),baseAssetId,baseAsset,quoteAssetIdandquoteAsset(asset symbols), andsymbol, which combines with every other filter and sort. Codes and symbols match whatever their case, and one that names nothing matches nothing, as does an id that is not exactly one (0300); atypeorstatusneither documented nor carried by an instrument is400 MALFORMED_REQUEST. Every sort is served: the statistics sorts (lastPx,pctChange24h,volume24h,turnover24h) read the statisticsinclude=tickerwill show, with absent values last in both directions: a 24-hour statistic is absent while its window warms, as is any the projection cannot state.include=totalis exact. A cursor is bound to the filters and the sort as normalized, so the lookups’ cursors stay valid.asOfSeqTsNsandX-Immix-As-Of-Seq-Tsreport the older of the catalogue’s and the statistics’ positions. Thetypeparameter lists every documented type,QUANTO_FUTURESandQUANTO_PERPETUAL_SWAPincluded.
0.1.0 — 2026-09-24
GET /venuesandGET /venues/{venueId}: the trading venues IMMIX connects to.- The list envelope shared by every collection:
items,nextCursor,prevCursor,total(withinclude=total) andasOfSeqTsNs. - The error envelope shared by every response:
{"error": {"code", "message", "retryable", "requestId"}}.