Revision 28, 2026-10-24, and revision 27, 2026-10-21 (numbered before revision 28, which reserved the number, and published after it). Wire contract of the barriers Partner API, /partner/v1/*, served by the Order Router (Barriers.Router) behind the brokerage edge host. The design it implements is docs/design/partner-api-design-2026-09-02.md with the binding custody addendum docs/design/partner-api-custody-addendum-2026-09-02.md, the binding addendum of 2026-09-06 docs/design/partner-api-events-and-clients-2026-09-06.md (the life-cycle events, legal entities, professional clients) and the binding addendum of 2026-09-08 docs/design/partner-fix-gateway-2026-09-08.md (the FIX gateway); the code is normative (PartnerAuth.cs, Partners.cs, PartnerOnboarding.cs, PartnerEndpoints.cs, PartnerCustody.cs, PartnerWebhooks.cs, PartnerEvents.cs, Fix/*.cs, and for the app order commands of section 22 AppJwt.cs, AppJwks.cs, AppOrderIssuers.cs, AppOrderAuth.cs, AppOrders.cs, AppOrderCommands.cs and PartnerAppSettings.cs, and for the perpetual demo accounts of section 23 PartnerDemo.cs, DemoPerp*.cs and DemoPerpMath.cs); this document is the partner-facing description of it. The machine-readable OpenAPI 3.1 document is served at /partner/openapi.json by the router and at /openapi.json by the edge host. A self-contained integration guide that walks a partner's engineers (or their coding assistant) through every flow of this contract under PARTNER custody with a USD stablecoin, with complete request and response examples, is docs/12-partner-integration-guide.md. The FIX 4.4 gateway beside these routes (section 20) has its own rules of engagement, docs/14-partner-fix-gateway.md, served at /fix by the edge host beside this reference at /docs.
Contents
The Partner API lets a licensed partner firm (a MiCA crypto-asset service provider, a MiFID investment firm, an electronic money institution, a credit institution or another obliged entity whose licence makes it eligible for third-party reliance) onboard its own end clients with us over an API and run a neobroker business under our MiFID licence, fully integrated into the partner's own application. The partner executes no order itself: every account, every order and the ledger record of every balance live with us. Whether the partner holds the client money is its custody model (section 13): under BROKER custody we hold it, under PARTNER custody the partner holds it under its own MiCA licence and reports every movement to us. A partner integrates over the REST routes and the webhooks of this document and, since revision 9, may enter orders and barrier requests over FIX 4.4 as well (section 20, docs/14-partner-fix-gateway.md): the same functions, sentences and codes on every transport. Since revision 21 your app may also send its users' perpetual orders, cancels, replacements and leverage changes to us DIRECTLY, each request carrying a short-lived token you sign for the signed-in user (section 22): the same pipelines, sentences and codes again, on a route family of its own that is not the Partner API's.
The legal model, in plain terms:
relianceStatus APPROVED); onboarding is refused with code 4036 until then.attestation.documentsAvailableOnRequest) and provides them through the documents route (section 10). We may reject reliance for a client (the client then has to be re-onboarded with fresh data or documents) and we may suspend or revoke reliance for the partner as a whole.clientType NATURAL) and, since revision 8, legal entities (LEGAL, section 9.7). Every client is onboarded as RETAIL; a professional categorisation is requested AFTER onboarding on the client's behalf (section 11.6) and decided by our compliance function. Eligible counterparties have no path.partner:<CODE>. Our compliance and finance staff see the partner, its packages, its money flows and its webhook deliveries in our back office.BROKER custody (the default: client money sits with us, deposits and withdrawals are confirmed by our finance desk, section 14) or PARTNER custody (the partner is the custodian of its clients' money under its own MiCA licence, a USD stablecoin valued 1:1 to USDC for margin; it reports collateral movements to us and settles every economic result with its client on its own rails, section 15). GET /partner/v1/me shows the model.PARTNER_CHANNEL ("This account is serviced by your provider."); our back office never pays such a client out through our own custody. Money moves only through the routes of sections 14 and 15, under the custody model of your partner record.Nothing in this document is legal advice. The reliance agreement and our client terms govern; this reference describes the wire contract that implements them.
| Environment | Host | Notes |
|---|---|---|
| Development (sandbox) | https://dev.brokerage.perpetuals.com | Connected to the Kronos X development venue. Marks come from the venue's reference feeds and simulator; order books are thin. Partners flagged sandbox settle money instantly (section 18). |
| Production | to be announced | Same contract, production venue, real money. |
Base path: /partner/v1. Every route in this document is relative to it unless written in full. The edge host also serves /openapi.json (this contract as OpenAPI 3.1), /docs (this reference rendered) and /healthz.
Since revision 21 the same host serves a second base path, /app/v1: the four app order commands your app sends directly to us (section 22, https://dev.brokerage.perpetuals.com/app/v1/orders on the development environment). It is not part of the Partner API: your partner key is never accepted there, it authenticates with the short-lived token you sign for the signed-in user, and none of its requests counts against the limits of section 6. Every route of this document outside section 22 stays under /partner/v1.
TLS is mandatory. Plain HTTP is not served.
Every request carries a partner API key, issued by our operations team in the back office (section 9 of the design: an ADMIN issues keys, a key is shown ONCE at issue and only its SHA-256 is stored). Present it as a bearer token, or in the X-Partner-Key header:
Authorization: Bearer pbk_1f9c2d0b3e7a4c5d6e7f8091a2b3c4d5e6f7a8b9
X-Partner-Key: pbk_1f9c2d0b3e7a4c5d6e7f8091a2b3c4d5e6f7a8b9
pbk_. The first 10 characters (pbk_ plus six) are the key's display prefix; that prefix, never the key, appears in our audit lines ("placed by partner NEOBROKER1 key pbk_1f9c2d...") and in GET /partner/v1/me.lastUsedAt on the key is refreshed at most once per minute.| Scope | May call |
|---|---|
READ | Every GET (and HEAD) route, the webhook reads included. |
TRADE | Everything: the onboarding and KYC writes, the trading writes (orders, leverage, RFQs, takes), the money writes (deposit notices, withdrawal requests, collateral reports, settlement reports), the webhook PUT, DELETE and test. |
A READ key on a write route answers 403 code 4034 ("This partner key cannot use this endpoint.").
HEAD (revision 5) is accepted wherever GET is, on every route of this document and on /openapi.json: the edge answers it with the status and headers the GET would produce and no body (it reaches our router as a GET, so it counts against readPerMinute and appears in our logs as one). OPTIONS, PATCH and every other verb answer 404 code 4004 at the edge.
A partner may be configured with a comma list of CIDR ranges (allowedCidrs, shown on GET /partner/v1/me). When the list is not empty, a request from an address outside it answers 401 code 4010 with exactly the answer an unknown key gets ("Sign in again.", revision 6); neither the list nor the fact that the key itself is valid is revealed to the caller. The source address is the connection's remote address; the X-Forwarded-For header is honoured only when the request reached the router from our own edge host, so a caller cannot forward its way onto an allowlist.
| Partner status | Effect |
|---|---|
ACTIVE | Normal service. |
SUSPENDED | Every request with the partner's keys answers 403 code 4037 PARTNER_SUSPENDED. |
CLOSED | The partner's keys act as unknown (401 code 4010). |
The same partner status governs the app order commands of section 22: a SUSPENDED partner's tokens answer 403 code 4037 there too (decided only after the token's signature verified), a CLOSED partner's tokens the one 401 of 22.6.
Reliance status (NONE, REQUESTED, APPROVED, SUSPENDED, REVOKED) is separate: only APPROVED allows the onboarding writes (POST /clients, PUT /clients/{id}/kyc, PUT /clients/{id}/mifid); everything else keeps working for the clients already onboarded.
Close-only under the settlement tier (revision 10, section 15.7) is not a partner status but a settlement measure: while it holds, an opening order, a barrier request or accept and a staking take answer 403 code 4041 PARTNER_CLOSE_ONLY, everything else keeps working, GET /me is unchanged, and the measure lifts on our hourly check once the overdue items are settled and the unsettled sum is back under the agreed cap.
The app order commands of section 22 do not use your partner key at all. Each request your app sends to /app/v1 carries Authorization: Bearer with a compact JWS your backend signed with ES256 for the user signed in to your app: the claims iss, aud, sub (our client uid exactly as issued, section 7), iat, exp (at most 5 minutes after iat) and jti, nothing else, verified against the key set you publish at the https URL our operations configure for you under four eyes. The token profile is 22.3, the key set and its rotation 22.4. A token never authenticates a /partner/v1 route, and a partner key never authenticates an /app/v1 route.
Success answers are {"code": 0, "data": ...}. Error answers are
{"code": 4036, "msg": "KYC reliance is not approved for this partner. Onboarding is not available until compliance approves the reliance agreement.", "error": "KYC reliance is not approved for this partner. Onboarding is not available until compliance approves the reliance agreement.", "data": {"error": "RELIANCE_NOT_APPROVED"}}
code is a stable four-digit number, msg a sentence a person can read, error repeats the sentence (for older readers), and data carries the machine facts when there are any (the validation map, the missing blocks, the machine name of a partner refusal, the retake time).
A few routes delegate to client API routes that predate the envelope and keep that route's own body shape without the {code, data} wrapper; each such route is marked "(bare)" in its section. Their error answers still use the envelope above.
| Code | HTTP | When | data |
|---|---|---|---|
| 0 | 200, 201 | Success. | the payload |
| 4000 | 400 | Validation, a refused order or request, a malformed paging value, a bad Idempotency-Key header. On the onboarding writes, PUT .../mifid and the categorisation request the message is "Please check the highlighted fields." and data.errors maps dotted field paths to one sentence each (representativePersonId included on PUT .../mifid); the two appropriateness routes answer their refusals as the sentence in msg (11.2). Since revision 12 a barrier request or preview worth less than the market's minNotional at the current mark is refused before anything is written, with the figure in the market's settlement currency: "The request value is below the minimum for this market (100.00 USD)." (12.2; no data, no rfqId, no rfq.rejected webhook). Since revision 17 the two appropriateness routes carry a data.error label on the 400s a machine can act on: LOCALE_MALFORMED (a locale or clientLanguage that is no language tag; data.member names clientLanguage when it is that one), APPROPRIATENESS_LOCALE_REQUIRED, APPROPRIATENESS_TEXT_HASH_REQUIRED, APPROPRIATENESS_TEXT_HASH_UNKNOWN (data {version, locale}), APPROPRIATENESS_LOCALE_NOT_AVAILABLE (data {locale, availableLocales}) and LANGUAGE_ELECTION_UNKNOWN (11.2); the required version 400 carries its sentence alone, and GET /appropriateness-test?version= answers 400 for a value that is not a positive integer (11.1). | {errors} on validation; {error, ...} on the labelled appropriateness refusals |
| 4000 | 413 | A request body over the size cap (256 KB on the onboarding and KYC writes, 8 MB on a document). | |
| 4000 | 415 | Revision 21, the app order commands (22.8): a body, on any of the four routes (the cancel included), that is not sent as Content-Type: application/json ("The request body must be sent as application/json."). Not stored under the Idempotency-Key (22.7). | |
| 4130 | 413 | The edge's own answer when the whole request body exceeds 11.5 MiB (12,058,624 bytes; revision 5, sized so an 8 MB document fits after base64 and JSON framing): "The request body exceeds the 11.5 MB limit." A declared Content-Length above it is refused before a byte is read. Since revision 21 the cap under /app/, the app order commands (22.5), is 64 KiB (65,536 bytes), refused the same way with "The request body exceeds the 64 KB limit."; nothing reaches us, so nothing is stored under the Idempotency-Key. | |
| 4004 | 404 | Unknown client (a client of another partner is indistinguishable from a non-existent one), unknown request, contract, swap, order, statement, notice, document, settlement item, categorisation request (one of another client, section 11.6) or webhook; a market that is not offered on the leverage and preview routes; since revision 17 a questionnaire version that GET /appropriateness-test?version= does not serve ("Unknown questionnaire version.": a number we never published, a withdrawn one, or an announced one that was overtaken, 11.1). Since revision 28, on the perpetual demo routes of section 23, also "This client has no demo account." and "This demo order was not found.", each with a data.error to branch on. | {error: "NO_DEMO_ACCOUNT"} and {error: "DEMO_ORDER_NOT_FOUND"} on the two demo answers |
| 4010 | 401 | No key, an unknown or revoked key, the key of a CLOSED partner, or a source address outside the allowlist. Since revision 21 also EVERY authentication failure of an app order command (22.6): no token, a malformed, forged, expired or foreign token, an unknown issuer or key id, a partner key presented there, app orders switched off for you; one answer, byte for byte, with the header WWW-Authenticate: Bearer error="invalid_token": fetch a fresh token and send the same request with the SAME Idempotency-Key. | |
| 4030 | 403 | The verification gate: the client's level or blocks do not allow the action (an opening order in a MiFID family, a money movement before MICA, the EDD source-of-wealth ask). The EDD (enhanced due diligence) source-of-wealth ask is the SOURCE_OF_WEALTH block of PUT .../mifid (11.4) and nothing else: it is asked before the next money movement of a client whose cumulative deposits reached our threshold while that block is not on file, data.missingBlocks then reads ["SOURCE_OF_WEALTH"], and a client whose four MiFID blocks you sent does not meet it unless our compliance later rejects that block (the rule with its figures is in 15.1). | {action, level, requiredLevel, missingBlocks} |
| 4034 | 403 | The key's scope does not cover the route (a READ key on a write). | |
| 4035 | 403 | CONSENT_REQUIRED (revision 10, section 10.5): the TERMS re-consent gate: the client has to accept the version in force of a gated consent document (TERMS) before opening a new position; an opening order, a barrier request, the accept of an opening request and a swap take are refused from the version's gateFrom instant while our switch is on (off on the development environment); a reducing order, an early close, a deposit and a withdrawal never. Record the acceptance with POST .../consents. The same number carries two more refusals of the barrier request, told apart by data.error (never a second meaning of the number): KID_REQUIRED (revision 10, BROKER-95, section 12.2: a RETAIL client's opening request without an acknowledgement of the market's current key information document, the normal or the strict variant; data.currentVersion null while none is published, the request is refused then too; the preview, the accept, the cancel and the early close are never gated; fetch the document from the kid / kidStrict block of GET /market-info, show it and record the acknowledgement with POST .../consents {doc, version}) and RISK_DISCLOSURE_CONSENT_REQUIRED (BROKER-96, section 12.2: a strict take profit request or preview while the client has not accepted the current Risk Disclosure version; record the acceptance with the same POST, or our CRM does). A fourth label of the number is RESERVED since revision 17: LANGUAGE_ELECTION_REQUIRED on POST .../appropriateness (reserved, null today; do not build on it; a later revision defines it; not returned today, a date would be announced under section 21). | {doc, currentVersion, gateFrom}; {error, doc, currentVersion, symbol, strict} on KID_REQUIRED; {error, doc, currentVersion} on RISK_DISCLOSURE_CONSENT_REQUIRED |
| 4036 | 403 | RELIANCE_NOT_APPROVED: an onboarding, KYC, MiFID or categorisation write (11.6) while the partner's reliance is not APPROVED. | {error: "RELIANCE_NOT_APPROVED"} |
| 4037 | 403 | PARTNER_SUSPENDED: any request while the partner is suspended. | {error: "PARTNER_SUSPENDED"} |
| 4041 | 403 | PARTNER_CLOSE_ONLY (revision 10): an opening order, barrier request or accept, or a staking take while the partner's clients are close-only under the settlement tier (15.7); closes, reducing orders, collateral and settlement reports and every read unaffected. | {error: "PARTNER_CLOSE_ONLY"} |
| 4043 | 403 | SERVER_ORDER_ENTRY_CLOSED (revision 21, 22.10): POST .../orders, DELETE .../orders/{orderId}, PUT or POST .../leverage of a partner whose server-side order entry our operations hold CLOSED, so its clients' orders, cancels and leverage changes are entered through the app order commands alone ("Server-side order entry is closed for this partner: orders, cancels and leverage changes are entered through the app order commands."). Decided before the client, the Idempotency-Key and the pipeline: nothing is stored, the same key runs as a first use once the switch is back. Every read and every other write unaffected. The code 4042 is not assigned. | {error: "SERVER_ORDER_ENTRY_CLOSED"} |
| 4044 | 403 | TRADING_RESTRICTED (revision 21, the app order commands alone, 22.6): an opening order, or the new order of a replace, refused because the client's ACCOUNT cannot open new risk: a client restriction (frozen, new orders disabled on the market, close-only), a trading halt in scope, an account that is not active for trading, or a legal-entity account (perpetuals are not yet open to legal entities). The sentence is the one the Partner API's routes answer with 400 code 4000 for the same refusal, unchanged; on a replace nothing changed and data.cancelled is false. Answered under /app/v1 alone, on the place and the replace (never on the cancel or the leverage change): your backend's own routes keep their 400 code 4000, and FIX its 9110=4000. | {error: "TRADING_RESTRICTED"}; {error: "TRADING_RESTRICTED", cancelled: false} on a replace |
| 4047 | 403 | DEMO_NOT_ENABLED (revision 28, section 23): any route under /partner/v1/clients/{clientId}/demo/perp while your partner switch of the perpetual demo is OFF, the default ("The perpetual demo is not enabled for this partner."). Decided before the client, the Idempotency-Key and our store: nothing is stored, the same key runs as a first use once the switch is ON. The demo's alone: no demo route answers 4030, 4035, 4036, 4041, 4043 or 4044. | {error: "DEMO_NOT_ENABLED"} |
| 4090 | 409 | An OPEN client already uses the e-mail address (onboarding); or a leverage change that would put the account in margin call or close-out (PUT .../leverage without force). | {wouldBreach, initialMarginAfter, maintenanceMarginAfter, freeMarginAfter, marginLevelAfter, riskStateAfter} on the leverage refusal |
| 4091 | 409 | An order's leverage hint no longer matches the stored choice; nothing was written. | {leverageSent, leverageStored} |
| 4092 | 409 | A conflict: the partnerClientRef is already used, an Idempotency-Key reused for another request (another method, path or body, section 5), a partnerRef reused, a stale appropriateness test version (data.error APPROPRIATENESS_VERSION_STALE with currentVersion, acceptedVersions and renderingChanged; the refusal records no result, numbers no attempt and starts no cooling period, 11.2), since revision 17 an announced questionnaire version submitted before its instant (APPROPRIATENESS_VERSION_NOT_IN_FORCE with currentVersion, effectiveAt and serverTime; the ONE refusal that gives the Idempotency-Key back, section 5), an acknowledgement naming another version than the result it acknowledges (APPROPRIATENESS_ACK_VERSION_MISMATCH with resultVersion and submittedVersion, never currentVersion, 11.3) and a rendered text that is no longer accepted (APPROPRIATENESS_TEXT_WITHDRAWN with the current text, 11.2; each of the three records nothing and numbers no attempt), a locked verification section, a state that does not allow the action (a decided notice, a withdrawal that is not REQUESTED, an inactive webhook, a closed account, a money restriction), the document cap; a BROKER-custody money route used by a PARTNER-custody partner and the reverse; a collateral withdrawal refused for an economic reason (section 15.1, the REFUSED report rides in data); a settlement item reported FAILED after SETTLED; a second categorisation request while one is open, a request whose target equals the categorisation in force, or a request that can no longer be withdrawn (section 11.6); the compliance lock of the MiFID writes (revision 14, section 11): PUT .../mifid, POST .../appropriateness and POST .../appropriateness/acknowledge while our compliance function holds a MiFID block of the client REJECTED (data.error MIFID_BLOCKS_REJECTED, until our compliance function releases the blocks), and PUT .../mifid while the blocks are SUBMITTED for its decision (data.error MIFID_UNDER_REVIEW). Since revision 28, on the perpetual demo routes of section 23, a CLOSED client's demo write ("This account is closed.", data.error ACCOUNT_CLOSED) and the demo's capacity ("The demo is at capacity.", data.error DEMO_AT_CAPACITY). | sometimes {currentVersion}; {reportId, status, refusal, report} on a refused collateral report; {item} on the settlement conflict; {requestId} on the open categorisation request; {error, lockedBlocks} on the compliance lock of the MiFID writes; {error: "ACCOUNT_CLOSED"} and {error: "DEMO_AT_CAPACITY"} on the two demo answers |
| 4290 | 429 | The partner's rate limit, or an appropriateness retake before the cooling period elapsed (the refusal numbers no attempt and moves retakeAt by nothing, 11.2). Since revision 21 also the windows of the app order commands (22.9: per user, per caller address, your app ceiling), always with Retry-After in whole seconds; they never draw on the partner's own limits of section 6. | {retakeAt} on the retake |
| 5020 | 502 | The trading venue cannot be reached. On a trading write the row usually stays in flight and our poll resolves it (the RFQ, the accept, the swap take). Since revision 19 also the pending answer of a barrier request, close request or staking take whose venue call succeeded while our side failed after it: read the row back; the Idempotency-Key replays this answer (section 5). | route specific |
| 5030 | 503 | The service the route needs is not built on this router (a test or api-only deployment). Since revision 21, on the app order commands (22.6), also no usable key set of yours (none fetched yet, or the last good one older than 4 hours while your key set URL fails) or a fault of our store on the way, with Retry-After; nothing was executed and the Idempotency-Key is not used. Since revision 28, on the perpetual demo routes of section 23, also our global demo switch off ("The demo account is not available.") and a router without the venue's public ticker read ("The demo cannot price orders right now. Please try again shortly."). |
Over the FIX gateway (section 20, docs/14-partner-fix-gateway.md) a refusal carries the SAME code in the custom tag 9110 and the same sentence in Text(58): a Logon refused for an unknown key reads Sign in again. with 9110=4010, a suspended partner 9110=4037, a READ key 9110=4034, an unknown client on a business message 9110=4004, a router without the service 9110=5030, the TERMS re-consent gate on an opening order, an opening request or its accept 9110=4035 with the sentence in Text(58) and no 9118 (revision 10), the KID gate and the strict consent gate on an opening request 9110=4035 likewise (their sentences in Text(58)), a close-only partner's opening order, opening request or its accept 9110=4041 with the sentence in Text(58) (revision 10, section 15.7), and a NewOrderSingle(35=D) or OrderCancelRequest(35=F) of a partner whose server-side order entry is CLOSED 9110=4043 (revision 21, 22.10). One code table serves both transports.
Every POST and PUT that creates something or moves money honours the Idempotency-Key header (1 to 128 characters of your choosing, a UUID is fine): POST /clients, PUT /clients/{id}/kyc, POST .../documents, POST .../appropriateness, POST .../appropriateness/acknowledge, PUT .../mifid, POST .../rfq, POST .../rfq/{rfqId}/accept, POST .../barriers/{contractId}/close, POST .../staking/{offerId}/accept, POST .../orders, PUT .../leverage, POST .../statements, POST .../deposits, POST .../withdrawals, POST .../withdrawals/{rid}/cancel, POST .../collateral, POST /settlement/items/{itemId}/report, POST /settlement/report and POST /webhook/test (the document upload, the two appropriateness writes, the MiFID blocks, the statement generation and the webhook test since revision 6). The ONE creating write without the key is PUT /webhook, by design: every PUT rotates the signing secret and the secret is answered once, never stored for a replay; a retried PUT is a second rotation, so send it once and keep the answer.
PUT .../mifid with a NEW key, or the stored 409 answers again.POST .../appropriateness (revision 15, 11.2). Mint the Idempotency-Key when the user taps Submit and store it before the call. Reuse it only to retry that same call after a timeout or a 5xx. After ANY refusal of this route (400, every 409, 429) the next submission is a new request and carries a NEW key: the stored refusal answers again for 24 hours under the old one, whatever cured it in the meantime (a re-read after a stale version, the end of the cooling period after a 429, the release of the compliance lock). The ONE exception is the 409 "still in progress" of the fourth bullet below, which is an answer about the KEY and not about the submission: retry it with the SAME key after 1 to 2 seconds. A key derived from your assessment record alone (the pattern the integration guide recommends for the trading writes) would replay a refusal to you for a day: derive it from the record AND a submission counter, for example appr:<recordId>:<submitSeq>.APPROPRIATENESS_VERSION_NOT_IN_FORCE (the announced version submitted a moment before its instant, a clock a little ahead of ours) is GIVEN BACK, so the SAME key may be sent again once the version is in force and scores then; every other refusal of the route is stored and replayed as above (revision 15's rule is unchanged).1, 1.0 and "1" are three requests); a body that is not JSON is compared byte for byte. So a retry your HTTP library serialised again replays, and a second order sent under the first order's key is refused instead of being answered with the first order's orderId. The 409 means the second request was NOT executed: send it under a new key. Whether the first request is still in flight or answered, the conflict answers at once, without the 5 second wait described below. Before revision 20 the comparison was the method and the path only, so a second request under a used key on the same path was answered with the FIRST request's stored answer and never executed; a key first used before the deployment of revision 20 keeps that comparison for the rest of its 24 hours.POST .../orders, POST .../rfq, POST .../barriers/{contractId}/close and POST .../staking/{offerId}/accept mark their key just before the venue call. When our side fails after that point, the request answers its pending answer, the same one a venue transport failure gives (section 12): an order 200 with state SENT; a barrier request or close request 502 code 5020 with data.rfqId; a staking take 502 code 5020 with data {swapId, offerId, accepting: true}. That answer is stored and replayed under the key like any other, so a retry with the same key never reaches the venue a second time: read the object back for its outcome. When even that answer could not be stored, the key answers 409 code 4092 "still in progress" for up to five minutes and then replays the pending answer. Never re-send such a request under a NEW key before you read the state back.partnerClientRef uniqueness.POST .../deposits, POST .../withdrawals or POST .../collateral whose partnerRef you already filed for the SAME client answers the ORIGINAL notice, request or report with 200 (a REFUSED collateral report answers its original 409 code 4092 again) and writes nothing, whatever the rest of the body says. A partnerRef you spent on ANOTHER client answers 409 code 4092 ("This partnerRef was already used for another ..."). The reference is checked before the money gates, so a retry of a request the first call already reserved or booked is never refused for the funds the first call took. On POST .../deposits and POST .../withdrawals the reference is checked before every gate. On POST .../collateral the closed-account check and the verification gate come first (15.1) and the economic gates after the reference: a retry for a client who meets the source-of-wealth ask by then answers 403 code 4030, not the replay.Idempotency-Key is REQUIRED on each of the four routes, scoped to the client the token names (two users' equal keys never meet), and it is looked at only AFTER the token was accepted, so a request refused with 401, 403 4037, 429 or 503 by the authentication uses nothing under its key and the retry with a fresh token and the SAME key runs as a first attempt; nor are the answers of the key, size, media type and body shape checks stored, which run before the command (22.7).POST .../demo/perp/account, POST .../demo/perp/orders, POST .../demo/perp/orders/{orderId}/replace, POST .../demo/perp/positions/{symbol}/close, PUT (and POST) .../demo/perp/leverage, POST .../demo/perp/top-up and POST .../demo/perp/reset, with the rules above; a demo write never reaches the venue and marks no point of no return, and the demo records the key on the row it wrote as well. Its DELETE routes and its preview take no key.Two sliding 60 second windows per partner, configured per partner and shown on GET /partner/v1/me under limits: readPerMinute (default 600) counts GET and HEAD requests, tradePerMinute (default 120) counts everything else. Beyond the limit the router answers 429 code 4290 ("Too many requests. Please slow down and try again."). The limits are per partner, not per key.
The app order commands (revision 21, section 22) count against NEITHER window, and your FIX sessions' bucket is not touched by them either. They have windows of their own, sliding over 60 seconds (22.9): 120 commands per user, 300 requests per caller address before the token is read, and an app ceiling of 6,000 commands per partner. Each answers 429 code 4290 with the same sentence and a Retry-After header in whole seconds (at least 1), the wait until that window admits again.
The perpetual demo (revision 28, section 23) counts against NEITHER window either: every request under /partner/v1/clients/{clientId}/demo counts in two demo windows of your partner of their own, sliding over 60 seconds, 600 GET and HEAD requests and 120 other requests per minute (shown on GET /me demo.perp.limits), answered beyond with the same 429 code 4290. Live traffic never draws on the demo windows, and demo traffic never on yours.
POST /partner/v1/clients and on every client read, both permanent and one to one with the client: clientId, our numeric account number of the client (412 in the examples), and clientUid, the client's stable pseudonymous identifier, the one under which the client's orders reach the Kronos X MTF (BARR-00000412 in the examples). The third identifier, partnerClientRef, is yours. The clientUid is OPAQUE: store clientId and clientUid exactly as we return them, send the uid back exactly as we issued it, and never compute one from the other (neither the uid from the id nor the id from the uid). Its format is not part of this contract and may change for clients created later; a uid in this document is an example, not a format. Every /clients/{clientId} route accepts either our numeric clientId or the clientUid. The uid is accepted only EXACTLY as issued, the letter case and the digits as served: any other spelling (BARR-412, barr-00000412, one leading zero more or fewer) names no client and is refused like an unknown one, with 404 code 4004 ("Unknown client.") on a route, with a BusinessMessageReject(35=j) 380=1, Text(58) Unknown client. and 9110=4004 as the PartyID(448) of a FIX message (docs/14 section 2), and with the one 401 code 4010 ("Sign in again.") as the sub of an app order token (22.3). A client of another partner is refused the same way on each channel (404 code 4004 on a route, the 35=j on FIX, the one 401 as a token's sub). GET /clients/by-ref/{partnerClientRef} resolves your own reference. Every object that names a client carries clientId; most carry clientUid beside it, and the client reads, the onboarding and KYC revision answers, the verification view, the categorisation answers, the settlement items and the webhook delivery envelope (16.2) also carry your partnerClientRef (the rows of the delivery log, 16.1, carry clientId and clientUid only). The margin read (12.3) and the statement list rows (section 17) carry clientId only; the model of a single statement (GET .../statements/{sid}) names the client by clientId and by the uid as model.account.clientRef, which its PDF prints too: match that clientRef as served, like any uid. BARR- also begins references of ours that are NOT client identifiers: our order reference, the clOrdId BARR-{orderId} of an order (the order id unpadded, BARR-4185; 12.3), the clOrdId BARR-RFQ-{rfqId} of a barrier request (12.2), and the venue reference BARR-SWP-{swapId} of a staking swap take. Never send one of them where a client is named (a route's {clientId}, PartyID(448) on FIX, a token's sub): it is not a client identifier."1500.000000" USDC, "0.05000000" BTC). The client currencies are USDC (the cash currency, 6 places) and the asset currencies BTC, ETH (8 places), SOL, BNB, XRP, EUR, USD1 (6 places); GET /partner/v1/currencies is the registry. Since revision 10 each registry row carries valuationBasis: CASH on the cash currency, MARK on a currency valued at its market's mark, PAR on a currency our registry values at 1.000000 USDC per unit without a market price (no currency is PAR yet).1788310800000), except the webhook signature timestamp, which is Unix seconds.limit (1 to a per-route maximum) and before (the nextBefore of the previous page); answers carry hasMore and nextBefore (null on the last page).YYYY-MM-DD), countries ISO 3166-1 alpha-2, block field names snake_case exactly as our own onboarding wizard sends them, envelope field names camelCase.Content-Type: application/json).GET /texts, 11.7, since revision 18), every refusal sentence in msg. No other language is announced; availableLocales is the truth. The parameter exists so that a language can be added without a wire change. A text we serve in more than one language takes a locale query parameter (a language tag xx or xx-YY, resolved to xx; _ is accepted for -; the Accept-Language header is not read; today the questionnaire read, 11.1). The answer always states locale (what was served), requestedLocale, localeFallback and availableLocales. A language we do not serve answers the English text as a whole, never a mix. Served text is precomposed Unicode. A write never falls back: send the locale and the textHash of the read you rendered (11.2). Question and option ids are the same in every language. The rule of section 8 stands for every text you render as served, the operator disclosure, the key information documents, the declaration sentences and the questionnaire (never rephrase, translate or shorten them yourself): do not translate the served regulated texts yourself.GET /partner/v1/meThe partner profile, reliance status, limits, the calling key, the webhook state, since revision 9 the FIX gateway's state (fix), since revision 10 the operator disclosure as we serve it (disclosures), beside the retail risk warning members that revision 16 deprecates (still served, see the display duty below), since revision 17 the questionnaire's schedule in one glance (appropriatenessTest, 11.1), since revision 21 the state of your app order commands (appOrders, section 22), and since revision 28 the perpetual demo as it applies to you (demo, section 23).
{
"code": 0,
"data": {
"partner": {
"partnerId": 7, "code": "NEOBROKER1", "name": "Neobroker One", "legalName": "Neobroker One GmbH", "country": "DE",
"licenceType": "MICA_CASP", "relianceStatus": "APPROVED", "status": "ACTIVE", "sandbox": true,
"custodyModel": "PARTNER", "collateralAsset": "USDe",
"limits": {"readPerMinute": 600, "tradePerMinute": 120},
"allowedCidrs": ["203.0.113.0/24"]
},
"key": {"keyId": 31, "label": "production backend", "prefix": "pbk_1f9c2d", "scope": "TRADE", "createdAt": 1788307200000, "lastUsedAt": 1788310800000},
"webhook": {"url": "https://partner.example/hooks/barriers", "active": true},
"fix": {"enabled": true, "port": 9850, "sessions": [{"keyPrefix": "pbk_1f9c2d", "connectedAt": 1788483600000, "lastInboundAt": 1788483660000}]},
"disclosures": {
"legalEntity": "PM MTF Ltd",
"riskWarning": {
"standard": "Perpetual contracts and barrier contracts are complex instruments and come with a high risk of losing money rapidly due to leverage. [the percentage sentence with our signed figure] You should consider whether you understand how these products work and whether you can afford to take the high risk of losing your money.",
"abbreviated": "[the percentage sentence with our signed figure] You should consider whether you can afford to take the high risk of losing your money.",
"reducedCharacter": "[the reduced-character percentage sentence with our signed figure]",
"lossPercent": 67, "figureDate": "2026-07-01", "windowFrom": "2025-07-01", "windowTo": "2026-07-01", "fallback": false
},
"operatorDisclosure": {
"signpost": "PM MTF Ltd provides this service as your broker and also operates the Kronos X MTF on which your orders are executed. Read our conflicts of interest summary.",
"conflictsSummaryUrl": "https://barriers.com/legal/conflicts-summary"
},
"asOf": 1788310800000
},
"appropriatenessTest": {"version": 1, "effectiveAt": 1786865712000, "availableLocales": ["en"], "next": null},
"appOrders": {"enabled": true, "issuer": "https://id.neobroker.example", "audience": "https://dev.brokerage.perpetuals.com/app", "jwksUrl": "https://id.neobroker.example/.well-known/app-order-keys.json", "kids": ["2026-10-a"], "lastFetchAt": 1788310500000, "lastError": null},
"demo": {"perp": {"enabled": true, "available": true, "virtual": true, "currency": "USD", "startingBalance": "10000.000000", "topUpMaxPerCall": "10000.000000",
"topUpMaxTotal": "90000.000000", "retentionDays": 30, "maxOpenOrders": 50, "limits": {"readPerMinute": 600, "tradePerMinute": 120}}},
"asOf": 1788310800000
}
}
appOrders (revision 21, additive, read only; section 22): enabled is true when our operations switched your app order commands ON AND the host you reached serves them (an audience is configured there); issuer is the exact iss your tokens must carry, audience the exact aud (null while the host serves no app order command), jwksUrl the https URL we fetch your public keys from, kids the key ids of our last good fetch of that set, lastFetchAt its time (Unix ms, null before the first good fetch) and lastError the error of our last failed fetch (null when the last fetch succeeded). The settings are agreed with us and set by our operations under four eyes; nothing here is written through this API. Read it after you publish a new key (22.4): the new kid appears here once we fetched it.
demo (revision 28, additive, read only; section 23): perp.enabled is your partner switch of the perpetual demo (OFF by default, set by our operations), perp.available is true when that switch is ON AND our global demo switch is on AND the host you reached runs the demo; virtual is always true and currency always USD; startingBalance, topUpMaxPerCall and topUpMaxTotal are the account's figures in virtual USD (23.3), retentionDays the retention of 23.3, maxOpenOrders the live order cap of an account, limits the demo's own rate windows (23.8).
appropriatenessTest (revision 17, additive): the questionnaire version in force at asOf with its effectiveAt and availableLocales, and next, the announced version's {version, effectiveAt, previousAcceptedUntil, changeClass, renderingChanged} while one is announced, else null; the whole schedule is on GET /appropriateness-test (11.1).
licenceType is one of MICA_CASP, MIFID_IF, EMI, CREDIT_INSTITUTION, OTHER. webhook is null until one is registered. custodyModel is BROKER or PARTNER (section 13) and collateralAsset the display name of the partner's stablecoin under PARTNER custody (empty otherwise). fix (section 20): enabled says whether the router you reached serves the FIX gateway, port is its TCP port (null when disabled; the hostname is the Partner API hostname of section 20, told at onboarding), sessions lists your live FIX sessions with the prefix of the key each logged on with and the two times, nothing else. Since revision 11 enabled means our acceptor is LISTENING on that host: it reads false, with port null, while the acceptor is not listening (a start failure our operations are retrying), so a refused connection beside enabled: false is ours to fix (a value rule, no field moved).
disclosures (revision 10; optional and additive, null on a host without the service) is the SAME document our own clients read on our public route, so what you render of it is exactly what we serve. In the example above the three deprecated sentences are abridged: [...] stands for the percentage sentence carrying our signed figure.
| Field | Meaning |
|---|---|
legalEntity | PM MTF Ltd, the firm the client contracts with. |
riskWarning.standard | DEPRECATED since revision 16 (section 21; still served, removed on /partner/v2). The standard retail risk warning: the complex-instruments sentence, the percentage sentence and the consideration sentence. |
riskWarning.abbreviated | DEPRECATED since revision 16 (still served, removed on /partner/v2). The abbreviated form: the percentage sentence and the shorter consideration sentence. |
riskWarning.reducedCharacter | DEPRECATED since revision 16 (still served, removed on /partner/v2). The reduced-character form for a space-limited surface. |
riskWarning.lossPercent | DEPRECATED since revision 16 (still served, removed on /partner/v2). The rounded loss percentage of our signed quarterly figure (a whole number today); null while the published range applies. |
riskWarning.figureDate | DEPRECATED since revision 16 (still served, removed on /partner/v2). YYYY-MM-DD, the first day of the quarter the figure was computed on; null on the range. |
riskWarning.windowFrom, riskWarning.windowTo | DEPRECATED since revision 16 (still served, removed on /partner/v2). YYYY-MM-DD, the twelve-month window of the figure (from inclusive, to exclusive); null on the range. |
riskWarning.fallback | DEPRECATED since revision 16 (still served, removed on /partner/v2). true while no figure is signed and the sentences carry the published new-provider range (74 to 89 percent). |
operatorDisclosure.signpost | The fixed sentence naming PM MTF Ltd as your clients' broker and as the operator of the Kronos X MTF their orders are executed on. |
operatorDisclosure.conflictsSummaryUrl | The link to the current conflicts of interest summary; null while none is published. |
asOf | The instant the document was read (Unix ms). |
THE DISPLAY DUTY (revision 16, 2026-09-22). By the operator's decision of 2026-09-22 the CFD-related standard retail risk warning is NOT REQUIRED for barrier contracts, and this reference no longer requires you to display it for barrier contracts or for any other product we offer through this API. The eight riskWarning members of disclosures on GET /me (standard, abbreviated, reducedCharacter, lossPercent, figureDate, windowFrom, windowTo, fallback) are DEPRECATED under section 21: still served for compatibility with their shape and meaning unchanged, removed on /partner/v2; you MAY stop rendering them at once, nothing on our side reads whether you do, and a client that still renders them renders them as served. The same members of the document our own clients read on our public route stay served unchanged (a change there is a revision of that route's own changelog, never of this reference). Our own portal stopped rendering the three forms on the same decision, and our app does from its next build; our compliance function still computes and signs the quarterly figure, which is still served in the deprecated members. What STANDS: show operatorDisclosure.signpost with the conflicts summary link on the account screen; re-read GET /me at least daily so a changed signpost or link reaches your clients within a day; put nothing of it on a push notification. The texts you must still render as served, the operator disclosure of this section, the key information documents (12.2), the declaration sentences (9.2, 11.4) and the questionnaire (11.1), change only with our compliance function's sign-off or a re-characterisation of the products; never rephrase, translate or shorten them yourself. Whether a barrier-specific risk sentence set (the guaranteed stop at the barrier and the leverage definition our own surfaces show) will be served here and required of you is an open question of ours; until it is decided nothing of it is served and nothing of it is required. Whether item (4) of the categorisation warning CAT-WARN-2026-09 v1 (11.6.1), which names the standardised risk warning and the loss-percentage disclosure among the protections a retail client receives, is re-versioned after this decision is a second open question of ours, for our compliance function; the version in force stands as served and you show it verbatim until a new version is published and announced (11.6.1).
GET /partner/v1/clients?limit&before&q&state&levelThe partner's clients, newest first. limit 1 to 200 (default 50), before a client id, q a case-insensitive text filter over e-mail, display name and partnerClientRef, state a lifecycle state, level one of NONE, MICA, MICA_MIFID.
{
"code": 0,
"data": {
"clients": [
{
"clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001",
"email": "jan.kowalski@example.com", "displayName": "Jan Kowalski", "clientType": "NATURAL",
"verificationLevel": "MICA", "lifecycleState": "ACTIVE", "categorization": "RETAIL", "riskState": "NORMAL",
"cashUsdc": "1500.000000", "createdAt": 1788307300000, "legalEntity": null,
"pendingCategorizationRequest": {"requestId": 77, "target": "PROFESSIONAL", "basis": "ELECTIVE", "status": "PENDING"}
}
],
"hasMore": false,
"nextBefore": null
}
}
riskState is the margin engine's state for the account (null when the engine is not attached). cashUsdc is the spendable USDC cash. clientType (revision 8) is NATURAL or LEGAL; a legal entity (section 9.7) carries legalEntity {legalName, lei, leiStatus, registrationNumber, registrationCountry, entityStatus} (the file's LEI status FORMAT_OK, ISSUED, LAPSED, ...; entityStatus SUBMITTED, APPROVED or REJECTED), a natural person null. pendingCategorizationRequest (revision 8, section 11.6) is the client's categorisation request that is PENDING or APPROVED and not yet applied (the cooling period), as {requestId, target, basis, status}, else null; categorization is the categorisation in force (RETAIL or PROFESSIONAL).
GET /partner/v1/clients/{clientId} and GET /partner/v1/clients/by-ref/{partnerClientRef}One client row in the shape above; 404 code 4004 for an unknown or foreign client.
GET /partner/v1/summary{
"code": 0,
"data": {
"asOf": 1788310800000,
"clients": {"total": 128, "byState": {"ACTIVE": 121, "KYC_PENDING": 5, "RESTRICTED": 2}, "byLevel": {"MICA": 80, "MICA_MIFID": 43, "NONE": 5}, "byType": {"NATURAL": 120, "LEGAL": 8}},
"clientCash": [{"currency": "USDC", "cash": "184210.500000", "reserved": "2500.000000", "total": "186710.500000"}],
"openBarriers": 14, "openSwaps": 3, "pendingDeposits": 2, "pendingWithdrawals": 1, "pendingReviews": 1, "pendingCategorizationRequests": 1
}
}
pendingReviews counts KYC packages in SPOT_CHECK or DOCUMENTS_REQUESTED; pendingCategorizationRequests (revision 8) the categorisation requests of your clients that are PENDING or APPROVED and not yet applied (section 11.6).
POST /partner/v1/clientsScope TRADE, reliance APPROVED, Idempotency-Key recommended. Creates the client, records the six MiCA blocks as approved under the partner's attestation, records the partner's identity check as a verified identity session, stores the KYC package (revision 1), runs OUR screening and grants verification level MICA through exactly the same decision function our compliance officers use. Answer 201.
Body (envelope fields camelCase, block payloads snake_case):
| Field | Required | Rules |
|---|---|---|
partnerClientRef | yes | Your own client reference, 1 to 64 printable ASCII characters (0x20 to 0x7E: it rides on the FIX wire as PartyID(448), docs/14 section 2; any other character answers the field sentence partnerClientRef must use printable ASCII characters only (0x20 to 0x7E): it is carried on FIX as PartyID(448).), never starting with BARR- (the prefix of our own identifiers: our client uids, and also our order, barrier request and swap references, which are not client uids, section 7; reserved case-insensitively since revision 11 like every FIX reference, because a 448 that is one of our uids resolves as our uid: such a value answers the field sentence partnerClientRef must not start with BARR-: that prefix is our own client uid namespace and PartyID(448) on FIX reads such a value as our uid.), unique per partner (409 code 4092 when reused). When the reference names a client whose reliance our compliance REJECTED (level NONE, latest package REJECTED), the 409 code 4092 sentence names the client and points at PUT /partner/v1/clients/{clientId}/kyc, the re-onboarding (section 10.1), with data: {clientId, clientUid, kycPackageStatus: "REJECTED", reonboardWith}; no second account is created. |
email | yes | A valid address; 409 code 4090 when an OPEN account already uses it. Normalised to lower case. |
clientType | no | NATURAL (default), the body of this section. LEGAL (revision 8) takes the legal-entity body of section 9.7: the same kyc and identityCheck for the representative who operates the account, plus the kyb object. CORPORATE answers the field error "Use LEGAL for a legal entity." |
categorization | no | RETAIL (default); anything else is the field error "Only RETAIL clients can be onboarded through the Partner API; a professional categorisation is requested after onboarding with POST /partner/v1/clients/{clientId}/categorization." (section 11.6). |
residencyCountry | no | ISO alpha-2; defaults to kyc.personalInfo.address.country. A blocked jurisdiction is a field error. |
kyc | yes | The six MiCA sections, section 9.2. |
identityCheck | yes | How the partner verified the identity, section 9.3. |
attestation | yes | The reliance attestation, section 9.4. |
Blocked jurisdictions (registration and residential address), the default list: US, KP, IR, MM, SY, CU, RU, UA.
Validation failures answer 400 code 4000 with data.errors, a map of dotted field paths to one sentence each, for example "kyc.personalInfo.address.country": "We are unable to accept applications from this jurisdiction at this time.", "kyc.taxResidency.tax_residencies.0.tin": "Invalid TIN format for the selected country.", "identityCheck.verifiedAt": "The identity check must be no older than 12 months.". List items are addressed by their index (citizenships.1, associations.0.dob). Nothing is created when any field fails.
kyc sections (the six MiCA blocks)The block payloads are validated by the same rules as our own onboarding wizard. Every string is trimmed; enumerations are exact lower-case tokens. Two document references are optional under reliance (utility_bill_document_id, w9_document_id), as is the identification block's document_ids list. On POST /clients they must be OMITTED: no document can be on file before the client exists, so a reference there answers 400 code 4000 with the field sentence "Omit document references on POST /partner/v1/clients: a document is uploaded to the client AFTER it exists (POST /partner/v1/clients/{clientId}/documents) and referenced in a later PUT /partner/v1/clients/{clientId}/kyc." and nothing is created (revision 6; the upload route needs the client, so no id can exist before it). On PUT .../kyc (section 10.1) a reference must name a document uploaded for this client through section 10.3; an unknown id answers "This document was not found among your uploads.".
personalInfo (block PERSONAL_INFO)
| Field | Required | Rules |
|---|---|---|
first_name | yes | at most 50 characters |
middle_name | no | at most 50 |
last_name | yes | at most 50 |
date_of_birth | yes | YYYY-MM-DD, year 1900 or later, the person must be at least 18 |
country_of_birth | yes | ISO alpha-2 |
citizenships | yes | array of ISO alpha-2, at least one, no duplicates |
phone_country_code | yes | + followed by 1 to 4 digits |
phone_number | yes | 4 to 15 digits |
address | yes | object: country (ISO alpha-2, not a blocked jurisdiction), street_name (at most 150), number (optional, at most 20), city (at most 50), state (optional, at most 50), postal_code (at most 20) |
utility_bill_document_id | no | a document id of this client (proof of address); optional under reliance; PUT .../kyc only, omitted on POST /clients |
identification (block IDENTIFICATION)
| Field | Required | Rules |
|---|---|---|
type | yes | passport, national_id, drivers_license, residence_permit |
country_of_issuance | yes | ISO alpha-2 |
number | yes | at most 50 characters |
expiration_date | yes | YYYY-MM-DD, after today, year 2100 or earlier; the date we keep tracking after onboarding (10.6) |
document_ids | no | array of document ids of this client; PUT .../kyc only, omitted on POST /clients |
taxResidency (block TAX_RESIDENCY)
tax_residencies: array of at least one object, no country twice:
| Field | Required | Rules |
|---|---|---|
country | yes | ISO alpha-2 |
has_tin | yes | boolean |
tin | when has_tin is true | 5 to 20 letters and digits once spaces and dashes are removed |
no_tin_reasons | when has_tin is false | array of not_issued, not_required, pending, other |
no_tin_reason_other | when the reasons contain other | at most 200 characters |
fatca (block FATCA)
| Field | Required | Rules |
|---|---|---|
us_person_status | yes | boolean |
ssn_itin | when true | a 9-digit SSN, ITIN or EIN in a valid structural shape |
w9_document_id | no | the W-9 document id; optional under reliance; PUT .../kyc only, omitted on POST /clients |
pep (block PEP)
| Field | Required | Rules |
|---|---|---|
is_pep | yes | boolean |
details | when is_pep is true | a public function object (below) |
has_associations | yes | boolean |
associations | when true | array of at least one association (below) |
A public function object: position (one of head_of_state, head_of_government, minister, deputy_or_assistant_minister, member_of_parliament, party_governing_body_member, high_judicial_body_member, auditors_or_central_bank_board, ambassador_or_charge_daffaires, high_ranking_armed_forces_officer, state_owned_enterprise_body_member, international_organisation_director, mayor), country (ISO alpha-2), institution (at most 100), start_date (YYYY-MM-DD, 1900 or later, not in the future), end_date (optional, not before the start).
An association: full_name (at most 100), dob (YYYY-MM-DD, at least 18), nationality (ISO alpha-2), address (at most 200), the public function fields above, and relationship (one of spouse, child, son_daughter_in_law, parent, joint_beneficial_ownership, other_close_business_relations, sole_beneficial_ownership).
declarationsMica (block DECLARATIONS_MICA)
Fifteen booleans, every one true: fatca_crs_accurate, fatca_crs_responsible, fatca_crs_notify_changes, fatca_crs_disclosure, fatca_crs_consequences, accept_legal_docs, accuracy_of_info, market_conduct, aml_compliance, recording_consent, data_protection, notify_changes, cooperate_authorities, authorised_client, crypto_asset_risk_acknowledged. Add acceptedAt (Unix ms, when the client made the declarations in your interface); it is stored with the package as received.
The fifteenth key (revision 10, the crypto-asset risk acknowledgement: the client acknowledges that crypto-assets are volatile and unprotected and that the spot books are traded prefunded without leverage) is REQUIRED from the published cut-over date, 00:00 UTC. Until then a declarationsMica block WITHOUT the key (absent) is accepted on POST /clients and on PUT /clients/{clientId}/kyc and stored as received; a PRESENT value other than true is refused at any time with the field's own sentence (kyc.declarationsMica.crypto_asset_risk_acknowledged: "This declaration must be acknowledged."). After the cut-over an absent key is refused the same way. Ship the key with your next release: it is accepted today.
The sentences the fifteen keys stand for (revision 15). Each key is the client's acknowledgement of one declaration sentence our own onboarding shows. The wording of that set is TO BE CONFIRMED by our compliance function before it is printed here: it is not final, this revision prints none of the fifteen sentences, and no text id is assigned to them yet (the MiFID set of 11.4 is printed where confirmed). Until the confirmed set is printed, the declarations your interface shows for these keys are your own wording under your attestation, as today; a later revision prints the confirmed sentences with a text id each and, once served, a language convention (section 7).
identityCheck| Field | Required | Rules |
|---|---|---|
provider | yes | ondato, sumsub, manual, other |
reference | no | the provider's reference, at most 200 characters |
method | yes | video, nfc, document, in_person |
verifiedAt | yes | Unix ms, not in the future (5 minutes of skew tolerated), no older than 12 months (366 days) |
documentType | no | at most 40 characters (passport, ...) |
documentCountry | no | ISO alpha-2 |
documentExpiry | no | YYYY-MM-DD, after today |
The record is stored as a verified identity session of provider partner and mirrored into the client's IDENTITY_CHECK block as APPROVED.
The age of verifiedAt (no older than 12 months, 366 days) is checked at SUBMISSION only: on POST /clients, on a PUT .../kyc that carries an identityCheck (10.1) and per person of a legal entity's file (9.7). Nothing reads the value again afterwards: the IDENTITY_CHECK block stays APPROVED and no re-verification cadence follows from the 12 months. An identityCheck you choose to send with a later revision records a further verified session and must itself be no older than 366 days at that moment. documentExpiry is validated and kept with the record, but it is NOT the date we track after onboarding: that is kyc.identification.expiration_date (10.6).
attestation| Field | Required | Rules |
|---|---|---|
cddPerformedAt | yes | Unix ms |
cddStandard | yes | the standard the CDD was performed to, at most 64 characters; use MICA_CASP_CDD, MIFID_IF_CDD, EMI_CDD, CREDIT_INSTITUTION_CDD or OTHER |
screening.sanctionsClear | yes | must be true (a client who did not pass sanctions screening cannot be onboarded) |
screening.pepChecked | yes | boolean |
screening.adverseMediaChecked | yes | boolean |
screening.screenedAt | yes | Unix ms |
screening.provider | no | at most 100 characters |
riskRating | yes | LOW, MEDIUM, HIGH (the partner's own rating) |
attestedBy | yes | the attesting person or function, at most 200 characters |
documentsAvailableOnRequest | yes | must be true |
Request:
POST /partner/v1/clients HTTP/1.1
Host: dev.brokerage.perpetuals.com
Authorization: Bearer pbk_1f9c2d0b3e7a4c5d6e7f8091a2b3c4d5e6f7a8b9
Idempotency-Key: 5b1c0f6e-2a7d-4d0e-9d3e-7a2f1c9b8e10
Content-Type: application/json
{
"partnerClientRef": "cust-001",
"email": "jan.kowalski@example.com",
"clientType": "NATURAL",
"categorization": "RETAIL",
"kyc": {
"personalInfo": {
"first_name": "Jan", "last_name": "Kowalski", "date_of_birth": "1980-05-14",
"country_of_birth": "PL", "citizenships": ["PL"],
"phone_country_code": "+48", "phone_number": "600123456",
"address": {"country": "DE", "street_name": "Hauptstrasse", "number": "1", "city": "Berlin", "postal_code": "10115"}
},
"identification": {"type": "passport", "country_of_issuance": "PL", "number": "AB123456", "expiration_date": "2031-01-01"},
"taxResidency": {"tax_residencies": [{"country": "DE", "has_tin": true, "tin": "12345678901"}]},
"fatca": {"us_person_status": false},
"pep": {"is_pep": false, "has_associations": false},
"declarationsMica": {
"fatca_crs_accurate": true, "fatca_crs_responsible": true, "fatca_crs_notify_changes": true, "fatca_crs_disclosure": true,
"fatca_crs_consequences": true, "accept_legal_docs": true, "accuracy_of_info": true, "market_conduct": true,
"aml_compliance": true, "recording_consent": true, "data_protection": true, "notify_changes": true,
"cooperate_authorities": true, "authorised_client": true, "crypto_asset_risk_acknowledged": true, "acceptedAt": 1788307200000
}
},
"identityCheck": {
"provider": "ondato", "reference": "idv-7f3a9c", "method": "video", "verifiedAt": 1788220800000,
"documentType": "passport", "documentCountry": "PL", "documentExpiry": "2031-01-01"
},
"attestation": {
"cddPerformedAt": 1788220800000, "cddStandard": "MICA_CASP_CDD",
"screening": {"sanctionsClear": true, "pepChecked": true, "adverseMediaChecked": true, "screenedAt": 1788220900000, "provider": "sumsub"},
"riskRating": "LOW", "attestedBy": "compliance@neobroker.example", "documentsAvailableOnRequest": true
}
}
Response 201 Created:
{
"code": 0,
"data": {
"clientId": 412,
"clientUid": "BARR-00000412",
"partnerClientRef": "cust-001",
"verificationLevel": "MICA",
"lifecycleState": "ACTIVE",
"status": "ACTIVE",
"kycPackageId": 58,
"requirements": {
"TRADE:PERP": ["ACCOUNT_PURPOSE", "EMPLOYMENT_FINANCIAL", "SOURCE_OF_WEALTH", "APPROPRIATENESS", "DECLARATIONS_MIFID"],
"TRADE:SPOT": [],
"TRADE:BARRIER": ["ACCOUNT_PURPOSE", "EMPLOYMENT_FINANCIAL", "SOURCE_OF_WEALTH", "APPROPRIATENESS", "DECLARATIONS_MIFID"],
"TRADE:STAKING": ["ACCOUNT_PURPOSE", "EMPLOYMENT_FINANCIAL", "SOURCE_OF_WEALTH", "APPROPRIATENESS", "DECLARATIONS_MIFID"],
"DEPOSIT:CRYPTO": [],
"WITHDRAW:CRYPTO": [],
"DEPOSIT:FIAT": ["BANK_ACCOUNT"],
"WITHDRAW:FIAT": ["BANK_ACCOUNT"]
},
"review": null
}
}
requirements maps every gated action to the blocks the client still lacks; an empty list means the action is open. At MICA the client may already deposit, withdraw and trade crypto spot; the MiFID families wait for section 11. (DEPOSIT:FIAT and WITHDRAW:FIAT are the portal's fiat rails; partner money moves through sections 14 and 15 and never asks for a bank block.)
ACTIVE and reliance APPROVED (else 4037 / 4036), scope TRADE.onboardingSource PARTNER_RELIANCE, your reference).decidedBy partner:<CODE> on our records (the verification view of section 11.5 shows the label PARTNER); the identity session is recorded and the IDENTITY_CHECK block approved.ACCEPTED) is stored.MICA, lifecycle ACTIVE, a decision row with actor partner:<CODE> and reason "reliance onboarding", the webhook client.onboarded.KYC_PENDING at level NONE, the package becomes SPOT_CHECK, a compliance case is opened and the answer is still 201 with status: "PENDING_REVIEW" and review: {reason, caseId}; the webhook client.review_required fires (client.onboarded does not: it marks the grant). Our compliance officers disposition the hit and decide the level; the client's level then changes on our side and you learn of it through client.verification_decision and GET .../verification. Since revision 10 (2026-09-10) a politically exposed person takes the same path: a client whose pep section declares is_pep or has_associations, or who carries a PEP true match on our screening (for a legal entity, a PEP among its persons too), also lands PENDING_REVIEW until our senior management approves the MiCA level under our maker-checker (review.reason names MICA_PEP_APPROVAL); the event and the fields are the ones above, no new value is introduced.{
"code": 0,
"data": {
"clientId": 413, "clientUid": "BARR-00000413", "partnerClientRef": "cust-002",
"verificationLevel": "NONE", "lifecycleState": "KYC_PENDING", "status": "PENDING_REVIEW", "kycPackageId": 59,
"requirements": {"TRADE:PERP": ["PERSONAL_INFO", "..."], "DEPOSIT:CRYPTO": ["..."]},
"review": {"reason": "screening HIT: 1 hit(s) await a disposition", "caseId": 3021}
}
}
Revision 8 (the addendum of 2026-09-06, section 3). A legal entity is onboarded under reliance with clientType: "LEGAL". The body carries TWO things: the natural-person kyc sections and identityCheck of the REPRESENTATIVE WHO OPERATES THE ACCOUNT (the account holder: the person must be identified and verified like any client, the rules of 9.2 to 9.4 unchanged), plus the kyb object with the entity file: the entity itself (the LEI is required), the CRS and FATCA classification of the entity, the persons (beneficial owners, intermediate holdings, directors and authorised representatives) and the documents you hold. We write the entity file exactly as our onboarding team would, check the LEI, screen the account holder, the entity itself and every person against OUR lists, and grant level MICA through the same decision function as for a natural person: status: "ACTIVE" when every screening is clean, PENDING_REVIEW (a case for our compliance officers) otherwise. The client's display name is the legal name; the entity contracts with PM MTF Ltd, the e-mail is the entity's contact address (partner clients have no login). The natural-person body and answer are unchanged.
Top-level fields for LEGAL (beside partnerClientRef, email, categorization and residencyCountry of 9.1):
| Field | Required | Rule and sentence |
|---|---|---|
clientType | yes for this shape | LEGAL. CORPORATE answers "Use LEGAL for a legal entity." |
email | yes | the entity's contact address; one OPEN account per address (409 code 4090). The representative's address is not a login and lives on the person row. |
residencyCountry | no | defaults to kyb.entity.registeredAddress.country. |
kyc | yes | the ACCOUNT HOLDER's six MiCA sections (9.2); the person is the one persons[] entry with accountHolder: true. |
identityCheck | yes | the account holder's (9.3); equal to that person's own identityCheck when both are given ("The account holder's identityCheck must equal the top-level identityCheck."); it may be omitted on the person row and is then taken from the top level. |
kyb | yes | "The kyb object with the entity, the persons and the documents is required for a legal entity." On a NATURAL body a kyb object answers "The kyb object applies to LEGAL clients only." |
attestation | yes | 9.4 plus entityCdd (below). cddStandard covers the identification of the beneficial owners and the representative's authority for a legal entity. |
kyb.entity (the file's own vocabulary, camelCase):
| Field | Required | Rule and sentence |
|---|---|---|
legalName | yes | at most 200 |
tradingName | no | at most 200 |
legalForm | yes | at most 80 (Ltd, GmbH, SA, LLC, ...); a text naming a trust, foundation or partnership form (trust, foundation, Stiftung, fondation, partnership, LP, LLP, GP, OHG, KG, SNC, SCS as whole words) is refused: "Trusts, foundations and partnerships cannot be onboarded at this time." (revision 10) |
legalFormKind | no | COMPANY (the default when absent), PARTNERSHIP, TRUST, FOUNDATION or OTHER; only a company is onboarded at launch: PARTNERSHIP, TRUST and FOUNDATION answer "Trusts, foundations and partnerships cannot be onboarded at this time.", OTHER answers "Only companies can be onboarded at this time; describe the legal form to our onboarding team.", another value "legalFormKind must be one of COMPANY, PARTNERSHIP, TRUST, FOUNDATION, OTHER." (revision 10) |
registrationNumber | yes | at most 80 |
registrationCountry | yes | ISO alpha-2, not a blocked jurisdiction ("We cannot onboard entities registered in this country.") |
registrationAuthority | no | at most 200 |
incorporationDate | yes | YYYY-MM-DD, not in the future ("The incorporation date is required (YYYY-MM-DD, not in the future).") |
lei | yes | 20 characters with valid ISO 17442 check digits: "The LEI must be 20 characters with valid ISO 17442 check digits." The RTS 22 transaction-reporting identity of a legal entity is its LEI, so it is required from the first day; MiFIR Article 26(6) makes an LEI in good standing a condition of the MiFID level (11.4). |
registeredAddress | yes | {country, street_name, number?, city, postal_code, state?}: the country an ISO code outside the blocked list, street_name (150), city (50) and postal_code (20) required ("The address is required (country, street_name, city, postal_code).") |
businessAddress | no | the same shape |
businessActivity | yes | at most 1000 |
industry | no | at most 80; use the wizard's industry tokens of 11.4 where one fits |
website, taxId, taxCountry | no | 200, 80, ISO alpha-2 |
isRegulated | yes | boolean ("Please state whether the entity is regulated."); regulator (200) required when true ("Name the regulator of a regulated entity.") |
listedExchange | no | at most 200; the ISO 10383 OPERATING MIC of the regulated market the entity is listed on (XETR, XCYS, ...): when it is on our configured list of regulated markets the listed-company carve-out of Directive (EU) 2015/849 Article 3(6)(a)(i) applies and the file passes without a declared beneficial owner (the directors and the account-holder representative stay required); the list is empty until our compliance function seeds it (revision 10) |
ownershipNote | no | 4000 |
kyb.entityClassification (the CRS and FATCA facts of the entity; the FATCA block of 9.2 describes the account holder, not the entity):
| Field | Required | Rule |
|---|---|---|
crsEntityType | yes | FI, ACTIVE_NFE, PASSIVE_NFE |
fatcaStatus | yes | US_PERSON, NON_US_FI, ACTIVE_NFFE, PASSIVE_NFFE, EXEMPT_BENEFICIAL_OWNER |
giin | when fatcaStatus is NON_US_FI | the 19-character GIIN shape XXXXXX.XXXXX.XX.XXX |
usTin | when fatcaStatus is US_PERSON | the EIN or SSN shape |
taxResidencies | yes | at least one {country, has_tin, tin, no_tin_reasons, no_tin_reason_other} by the TAX_RESIDENCY rules of 9.2 (errors keyed kyb.entityClassification.taxResidencies.<i>.<field>); the first one fills the entity's taxId / taxCountry when those are absent |
controllingPersonsDeclared | when crsEntityType is PASSIVE_NFE | must be true: the beneficial owners in persons are the controlling persons of the CRS self-certification |
The classification is kept as received (the package) and recorded on the file (an ENTITY_CLASSIFICATION_SET event); it never blocks the grant.
kyb.persons[] (1 to 50 entries; each role one of UBO, SHAREHOLDER_ENTITY, DIRECTOR, REPRESENTATIVE; SENIOR_MANAGING_OFFICIAL is accepted as an alias and recorded as a UBO with controlType SENIOR_MANAGING_OFFICIAL):
firstName, lastName (100 each), birthDate (YYYY-MM-DD, at least 18), nationality (ISO), isPep (boolean; pepDetail required when true), optional email (the person's, never a login), phone (40).UBO: ownershipPct (0 to 100), controlType (OWNERSHIP default, VOTING_RIGHTS, OTHER_CONTROL, SENIOR_MANAGING_OFFICIAL), optional parentIndex (the zero-based index of an EARLIER SHAREHOLDER_ENTITY entry the stake is held through: "parentIndex must name an earlier SHAREHOLDER_ENTITY entry."), and the identity facts below.SHAREHOLDER_ENTITY (an intermediate holding in the chain): entityName (200), ownershipPct (0 to 100), optional entityLei (check digits), entityRegistration, parentIndex. An identityCheck on a holding is refused: "An intermediate holding has no identity check."DIRECTOR: position (120), appointedOn (YYYY-MM-DD); identityCheck optional (a director is identified and name-screened, verified when you have the record).REPRESENTATIVE: powers (SOLE, JOINT, LIMITED; powerLimits required with LIMITED), powerSource (ARTICLES, BOARD_RESOLUTION, POWER_OF_ATTORNEY, REGISTER), optional position, appointedOn, and the identity facts below.UBO and a REPRESENTATIVE (the persons the law requires to be identified AND verified): countryOfResidence (ISO), idType (passport, national_id, drivers_license, residence_permit), idNumber (80), idCountry (ISO), idExpiry (after today), address (the shape of registeredAddress) and identityCheck (the shape of 9.3: "The identity check record is required for a beneficial owner and for a representative.").accountHolder: true; it must be a REPRESENTATIVE ("Exactly one REPRESENTATIVE must be marked accountHolder.") whose firstName, lastName and birthDate equal kyc.personalInfo.first_name, last_name and date_of_birth ("The account holder must be the person of kyc.personalInfo."). Its identity check is the top-level identityCheck.kyb.persons (for example "Name at least one director.").kyb.documents[] (1 to 50 entries, {kind, reference, title?, issuedOn?, expiresOn?, note?}): kind one of CERTIFICATE_OF_INCORPORATION, REGISTER_EXTRACT, ARTICLES_OF_ASSOCIATION, SHAREHOLDER_REGISTER, DIRECTOR_REGISTER, UBO_REGISTER_EXTRACT, BOARD_RESOLUTION, POWER_OF_ATTORNEY, FINANCIAL_STATEMENTS, LEI_CERTIFICATE, REGULATORY_LICENCE, OTHER. The package must hold the document set of the table below (revision 10); a missing group answers kyb.documents with the sentence "Missing documents: ..." naming every missing group, lower-cased (a group with two kinds joined by "or", the groups joined by commas: "Missing documents: ubo register extract or shareholder register." for a package carrying only the three old kinds; "Missing documents: board resolution." for a third-country package without it). The REGISTER EXTRACT must carry issuedOn and it must lie within the last 90 days at the submit ("The register extract must have been issued within the last 90 days (issued on 2025-10-01).", or "The register extract needs its issue date (YYYY-MM-DD, within the last 90 days)." without a date); the age is our parameter and may change. reference is your own reference of the document you hold under the documents-on-request undertaking (1 to 200, required on POST /clients); a documentId is refused on POST /clients (no upload can exist before the client) and honoured on PUT .../kyc (10.1) and through purpose: "entity" on the upload route (10.3), which replaces the held row by your upload. A revision's full list has to keep the set satisfied (a list that drops a required group is refused with the same sentence).
| Group | Required | Kinds that satisfy it |
|---|---|---|
| The certificate of incorporation | every entity | CERTIFICATE_OF_INCORPORATION |
| The register extract | every entity | REGISTER_EXTRACT, issuedOn within the last 90 days |
| The articles of association | every entity | ARTICLES_OF_ASSOCIATION |
| The beneficial-ownership evidence, the fourth group | every entity | UBO_REGISTER_EXTRACT (the extract of the beneficial-ownership register, the kind expected of an entity registered in the EU, the EEA or the UK) or SHAREHOLDER_REGISTER (the kind expected elsewhere); either satisfies the group; where no register exists the signed declaration of the beneficial owners is the UBO declaration itself (kyb.persons), not a document kind |
| The board resolution | an entity registered outside the EU, the EEA and the UK | BOARD_RESOLUTION (the director-signed declaration of the beneficial owners is filed under this kind) |
| The regulatory licence | isRegulated: true | REGULATORY_LICENCE |
| The shareholder register, strictly | a higher-risk entity: industry on our higher-risk list, registrationCountry on our high-risk country list, or our own risk rating of the client HIGH | SHAREHOLDER_REGISTER (we verify every owner from the disclosure floor, 10 percent by default, against it; the beneficial-ownership test itself stays at more than 25 percent) |
| The financial statements | our own risk rating of the client HIGH | FINANCIAL_STATEMENTS |
A file already submitted keeps the document set it was submitted under; the table binds packages received from revision 10 on.
kyb.contact (optional): email (defaults to the top-level e-mail), phone (40), personIndex (a REPRESENTATIVE entry). Recorded on the file, never a login.
attestation.entityCdd (required for LEGAL, refused on a NATURAL body with "entityCdd applies to LEGAL clients only."):
| Field | Required | Sentence |
|---|---|---|
uboIdentified | true | "Every beneficial owner must have been identified." |
uboVerified | true | "The beneficial owners' identities must have been verified." |
representativeAuthorityVerified | true | "The representative's authority to act must have been verified against the register or a power of attorney." |
registerCheckedAt | Unix ms, not in the future | "The time the company register was checked is required." |
registerSource | no | at most 200 |
Worked example:
{
"partnerClientRef": "corp-001",
"email": "treasury@acme-trading.example",
"clientType": "LEGAL",
"kyc": {"personalInfo": {"first_name": "Jan", "last_name": "Kowalski", "date_of_birth": "1980-05-14", "...": "..."}, "identification": {"...": "..."}, "taxResidency": {"...": "..."}, "fatca": {"...": "..."}, "pep": {"...": "..."}, "declarationsMica": {"...": "..."}},
"identityCheck": {"provider": "ondato", "reference": "idv-9a1", "method": "video", "verifiedAt": 1788220800000, "documentType": "passport", "documentCountry": "PL", "documentExpiry": "2031-01-01"},
"kyb": {
"entity": {
"legalName": "Acme Trading GmbH", "tradingName": "Acme", "legalForm": "GmbH",
"registrationNumber": "HRB 123456", "registrationCountry": "DE", "registrationAuthority": "Amtsgericht Berlin-Charlottenburg",
"incorporationDate": "2015-03-02", "lei": "5299000J2N45DDNE4Y28",
"registeredAddress": {"country": "DE", "street_name": "Hauptstrasse", "number": "1", "city": "Berlin", "postal_code": "10115", "state": null},
"businessAddress": null, "businessActivity": "proprietary trading of crypto-assets and derivatives",
"industry": "banking_financial", "website": "https://acme-trading.example", "taxId": "DE123456789", "taxCountry": "DE",
"isRegulated": false, "regulator": null, "listedExchange": null, "ownershipNote": null
},
"entityClassification": {"crsEntityType": "ACTIVE_NFE", "fatcaStatus": "ACTIVE_NFFE", "giin": null, "usTin": null, "taxResidencies": [{"country": "DE", "has_tin": true, "tin": "DE123456789"}], "controllingPersonsDeclared": true},
"persons": [
{"role": "UBO", "firstName": "Anna", "lastName": "Muster", "birthDate": "1975-06-01", "nationality": "DE", "countryOfResidence": "DE",
"idType": "passport", "idNumber": "C01X00T47", "idCountry": "DE", "idExpiry": "2030-05-01",
"address": {"country": "DE", "street_name": "Ringstrasse", "number": "7", "city": "Berlin", "postal_code": "10117"},
"ownershipPct": 60, "controlType": "OWNERSHIP", "parentIndex": null, "isPep": false, "email": "anna@example", "phone": "+49301234567", "accountHolder": false,
"identityCheck": {"provider": "ondato", "reference": "idv-9a2", "method": "video", "verifiedAt": 1788220800000, "documentType": "passport", "documentCountry": "DE", "documentExpiry": "2030-05-01"}},
{"role": "SHAREHOLDER_ENTITY", "entityName": "Muster Holding AG", "entityLei": null, "entityRegistration": "CHE-123.456.789", "ownershipPct": 40, "parentIndex": null},
{"role": "UBO", "firstName": "Peter", "lastName": "Muster", "birthDate": "1970-01-15", "nationality": "CH", "countryOfResidence": "CH",
"idType": "passport", "idNumber": "X1234567", "idCountry": "CH", "idExpiry": "2029-12-31", "address": {"...": "..."},
"ownershipPct": 100, "controlType": "OWNERSHIP", "parentIndex": 1, "isPep": false, "accountHolder": false, "identityCheck": {"...": "..."}},
{"role": "DIRECTOR", "firstName": "Anna", "lastName": "Muster", "birthDate": "1975-06-01", "nationality": "DE", "position": "Geschaeftsfuehrerin", "appointedOn": "2015-03-02", "isPep": false},
{"role": "REPRESENTATIVE", "firstName": "Jan", "lastName": "Kowalski", "birthDate": "1980-05-14", "nationality": "PL", "countryOfResidence": "DE",
"idType": "passport", "idNumber": "AB123456", "idCountry": "PL", "idExpiry": "2031-01-01", "address": {"...": "..."},
"position": "Head of Treasury", "powers": "SOLE", "powerSource": "POWER_OF_ATTORNEY", "powerLimits": null, "appointedOn": "2024-01-10",
"isPep": false, "email": "jan.kowalski@example.com", "phone": "+48600123456", "accountHolder": true}
],
"documents": [
{"kind": "CERTIFICATE_OF_INCORPORATION", "reference": "acme/coi/2015", "title": "Certificate of incorporation", "issuedOn": "2015-03-02"},
{"kind": "REGISTER_EXTRACT", "reference": "acme/hrb/2026-08", "issuedOn": "2026-08-20"},
{"kind": "ARTICLES_OF_ASSOCIATION", "reference": "acme/aoa/2022"},
{"kind": "POWER_OF_ATTORNEY", "reference": "acme/poa/kowalski", "issuedOn": "2024-01-10"},
{"kind": "UBO_REGISTER_EXTRACT", "reference": "acme/transparenzregister/2026-08"}
],
"contact": {"email": "treasury@acme-trading.example", "phone": "+49301234500", "personIndex": 4}
},
"attestation": {
"cddPerformedAt": 1788220800000, "cddStandard": "MICA_CASP_CDD",
"screening": {"sanctionsClear": true, "pepChecked": true, "adverseMediaChecked": true, "screenedAt": 1788220900000, "provider": "sumsub"},
"riskRating": "MEDIUM", "attestedBy": "compliance@neobroker.example", "documentsAvailableOnRequest": true,
"entityCdd": {"uboIdentified": true, "uboVerified": true, "representativeAuthorityVerified": true, "registerCheckedAt": 1788220800000, "registerSource": "Handelsregister"}
}
}
What happens on our side, in order: the validation of every part above (400 with the map, nothing created); in ONE transaction the client row (LEGAL, the legal name as the display name, the residency from the registered address), the account holder's six blocks and identity session as in 9.6, the entity file (the entity SUBMITTED with the LEI status FORMAT_OK, every person with the account holder linked to the account, every document as a row held by you, the four KYB blocks ENTITY_INFO, ENTITY_DOCUMENTS, UBO_DECLARATION, REPRESENTATIVES submitted, the file's events), the package (revision 1, payload {kyc, kyb}); after the commit the LEI check (the format always; the GLEIF registration status when we have the lookup configured, recorded on the file, never blocking this grant: a LAPSED or RETIRED status refuses the MiFID level later, 11.4), OUR screening of the account holder, of the entity itself (by legal name and LEI) and of every person, synchronously and, for the persons of a large file, several at a time within a fixed budget for the whole file (a person our provider has not answered for inside it counts as a screening that could not run), then the MiCA grant exactly as 9.6 step 6: ACTIVE at MICA with the four KYB blocks approved, our RTS 22 identifier record built with the LEI, the webhook client.onboarded; or PENDING_REVIEW as 9.6 step 7 (the case names the person or the entity whose screening awaits a disposition or could not run, an unavailable screening provider included; our compliance officers disposition the hit or re-run the screening and decide the level).
The 201 answer is the shape of 9.1 plus clientType and kyb:
{
"code": 0,
"data": {
"clientId": 512, "clientUid": "BARR-00000512", "partnerClientRef": "corp-001", "clientType": "LEGAL",
"verificationLevel": "MICA", "lifecycleState": "ACTIVE", "status": "ACTIVE", "kycPackageId": 61,
"requirements": {"TRADE:PERP": ["ACCOUNT_PURPOSE", "..."], "TRADE:SPOT": [], "DEPOSIT:CRYPTO": []},
"review": null,
"kyb": {
"entityStatus": "APPROVED", "lei": "5299000J2N45DDNE4Y28", "leiStatus": "FORMAT_OK", "leiCheckedAt": 1788307300000, "entityScreening": "CLEAR",
"persons": [{"index": 0, "personId": 91, "role": "UBO", "accountHolder": false, "screening": "CLEAR"}, {"index": 1, "personId": 92, "role": "SHAREHOLDER_ENTITY", "accountHolder": false, "screening": "CLEAR"}, {"index": 4, "personId": 95, "role": "REPRESENTATIVE", "accountHolder": true, "screening": "CLEAR"}],
"documents": [{"index": 0, "docId": 40, "kind": "CERTIFICATE_OF_INCORPORATION", "reference": "acme/coi/2015", "documentId": null}]
}
}
}
screening and entityScreening are CLEAR, PENDING_REVIEW (a hit awaits a disposition, or the provider could not run) or NOT_RUN (no screening provider installed); never a hit's content. index maps a row to its entry in your package (null on a later read for a row you did not list).
PUT /partner/v1/clients/{clientId}/kycScope TRADE, reliance APPROVED, Idempotency-Key recommended. Body: {kyc, attestation, identityCheck?} in the shapes of section 9. At least one MiCA section must be present in kyc; only the sections present are re-written (approved again under the partner); the attestation is required with every revision; identityCheck, when present, records a further verified identity session. A new package revision is stored.
When the personal block changes the client's identity (first, middle or last name, date of birth, or the set of citizenships), OUR screening runs again. A hit moves the client to RESTRICTED pending review, marks the package SPOT_CHECK, opens a compliance case and fires client.review_required.
{
"code": 0,
"data": {
"clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001",
"kycPackageId": 61, "revision": 2, "status": "ACCEPTED",
"verificationLevel": "MICA", "lifecycleState": "ACTIVE",
"identityChanged": false, "screening": "NOT_RUN"
}
}
screening is NOT_RUN (the identity did not change), CLEAR or PENDING_REVIEW. A closed account answers 409 code 4092.
When the personal block changes the client's name or date of birth (revision 3), the client's display name on our side follows the verified name and our RTS 22 client identifier record (the transaction-reporting identity built from the personal, identification and tax residency data) is rebuilt as a new revision; a revision that changes only the identification or tax residency data rebuilds that record too. Nothing of it changes the answer.
Re-onboarding after a rejection (revision 3). When our compliance rejected reliance for the client (kyc.rejected: level NONE, lifecycle KYC_PENDING, the latest package REJECTED), the same route is the re-onboarding: the body must carry ALL six MiCA sections in kyc, the identityCheck record (the rejected identity session was failed) and the attestation; a partial body answers 400 with the field map (identityCheck reads "The identity check record is required to re-onboard the client after a rejected package."). The call stores the next package revision, re-approves every block, records the new identity session, runs OUR screening again and re-runs the MiCA grant exactly like POST /clients (section 9.6 steps 4 to 7). MiFID blocks the client held were rejected with the package and stay under the compliance lock after the re-onboarding (revision 14, section 11): this call brings level MICA back, the MiFID level returns only after our compliance released those blocks. The answer adds the onboarding fields to the shape above:
{
"code": 0,
"data": {
"clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001",
"kycPackageId": 74, "revision": 2, "status": "ACCEPTED",
"verificationLevel": "MICA", "lifecycleState": "ACTIVE",
"identityChanged": true, "screening": "CLEAR",
"reonboarded": true, "grant": "ACTIVE", "review": null,
"requirements": {"TRADE:PERP": ["ACCOUNT_PURPOSE", "..."], "DEPOSIT:CRYPTO": []}
}
}
grant is ACTIVE (level MICA, the webhook client.onboarded fires again) or PENDING_REVIEW (a screening hit or another refusal: the client stays at level NONE, the package becomes SPOT_CHECK, a compliance case is opened, review: {reason, caseId}, the webhook client.review_required fires, screening reads PENDING_REVIEW). POST /partner/v1/clients with the same partnerClientRef answers 409 code 4092 pointing here (section 9.1) instead of creating a second account.
A legal entity (revision 8, section 9.7). The body of a LEGAL client is {kyc?, identityCheck?, kyb?, attestation}: at least one of the account holder's kyc sections or the kyb object must be present ("At least one of the kyc sections or the kyb object is required."); the attestation with entityCdd is required with every revision. Inside kyb every part is optional and REPLACES what it names: entity the whole entity (a new version; a new LEI resets its status to FORMAT_OK, is checked again and rebuilds our RTS 22 identifier record), entityClassification the classification, persons the FULL active list (an entry with personId updates that active person under the rules of 9.7, an entry without one is added, an active person absent from the list is removed from the file with the reason "removed in package revision N"; a new beneficial owner or representative needs its identityCheck; a new account holder needs the top-level identityCheck ("The top-level identityCheck is required when the account holder changes.") and the matching kyc.personalInfo; the previous holder's link is cleared), documents the full list (a reference already on file keeps its row, a documentId beside a reference upgrades the held row to your upload, a documentId alone adds your upload, an entry absent from the list is removed with the revision's reason; the three required kinds must remain: "A document needs a reference (held by you) or a documentId (your upload)."), contact the contact. A new or renamed person and a renamed or re-identified entity are re-screened synchronously: any hit takes the PENDING_REVIEW path above (the account RESTRICTED, the package SPOT_CHECK, the case, client.review_required), identityChanged reads true when anything screened again. The answer adds clientType and kyb as in 9.7. After a REJECT the re-onboarding of a legal entity needs the full body: every kyc section, the identityCheck, the whole kyb object ("The kyb object with the entity, the persons and the documents is required to re-onboard the legal entity after a rejected package.") and the attestation; the file is submitted again and the grant of 9.7 runs.
GET /partner/v1/clients/{clientId}/kycThe latest package with its payload and attestation exactly as received:
{
"code": 0,
"data": {
"kycPackageId": 61, "partnerId": 7, "clientId": 412, "clientUid": "BARR-00000412", "clientType": "NATURAL", "revision": 2,
"status": "ACCEPTED", "receivedAt": 1788311000000,
"payload": {"personalInfo": {"first_name": "Jan", "last_name": "Kowalski", "...": "..."}},
"attestation": {"cddPerformedAt": 1788220800000, "cddStandard": "MICA_CASP_CDD", "...": "..."},
"reviewedBy": null, "reviewedAt": null, "reviewNote": null
}
}
Package statuses: ACCEPTED, SPOT_CHECK (under review by our compliance), DOCUMENTS_REQUESTED (we need the underlying documents; reviewNote says which), REJECTED (reliance refused for this client; reviewNote carries the reason; the client is set to KYC_PENDING at level NONE and must be re-onboarded with fresh data or documents through PUT .../kyc, section 10.1). reviewNote is answered only in the last two statuses. 404 code 4004 when the client has no package. reviewedBy is the reviewer's ROLE label (COMPLIANCE, ADMIN, SYSTEM for the automatic spot check, PARTNER), never a person's name (revision 6). A legal entity's payload (revision 8) is the pair {"kyc": {...}, "kyb": {...}} as received (the account holder's sections under kyc, the entity file under kyb, each key present only when the revision carried it); a natural person's payload stays the sections at the top level. clientType (NATURAL or LEGAL) says which of the two shapes the package carries.
A documents request (the status DOCUMENTS_REQUESTED, the webhook kyc.documents_requested with data {kycPackageId, revision, note}) is the decision of a compliance officer of ours in an individual case, under the documents-on-request undertaking of your attestation (9.4); nothing requests documents automatically. The officer's note names the documents wanted and is the only carrier of the request: read it as note on the webhook, or as reviewNote here and in the kycPackage summary of 11.5. Our officer can place the request on any package of the client that is not REJECTED, while these two reads show the LATEST package only: a request placed on an earlier revision is visible on the webhook alone, so keep the webhook's note with its kycPackageId. A request carries no deadline and gates nothing in our code: no route is refused because a package reads DOCUMENTS_REQUESTED, and no timer escalates it (a further measure would be a separate decision of our compliance and would reach you as its own event, 16.4). Answer it by uploading through 10.3 and, when the note asks for it, with a PUT .../kyc revision (10.1); our compliance then decides the package.
POST /partner/v1/clients/{clientId}/documentsScope TRADE. Uploads one document for the documents-on-request undertaking: {name, contentType, dataBase64, purpose} with purpose one of identity, address, w9, other, entity. The content must be a JPEG, PNG or PDF (sniffed, contentType is informational), at most 8 MB, at most 20 live partner documents per client (409 code 4092 beyond). The stored document type follows the purpose: identity takes the identification block's type (passport, ...), address becomes utility_bill, w9 becomes w9_form, other stays other. Partner documents are never removable by the client. purpose: "entity" (revision 8, a LEGAL client only: "purpose entity applies to legal entities.") takes kind (a document kind of 9.7) and an optional reference: the upload is stored under the corporate document type of its kind and linked into the entity file, where it replaces the row you hold under that reference (else it is added as a new document of that kind); the answer adds kind, reference, kybDocId (the file's document id) and linked (true when a held row was replaced). kind and reference on another purpose answer 400 ("kind and reference apply to purpose entity only."). A request for the underlying document of a legal entity (kyc.documents_requested, the note names the kind) is answered this way without a further package revision.
{
"code": 0,
"data": {"documentId": 905, "type": "utility_bill", "purpose": "address", "filename": "bill.pdf", "mimeType": "application/pdf", "sizeBytes": 48213, "uploadedAt": 1788311100000, "source": "PARTNER"}
}
Errors: 400 code 4000 (purpose, empty or invalid base64, an unsupported format "The file must be a JPEG, PNG or PDF.", an empty file), 413 code 4000 ("The file is too large. The maximum size is 8 MB."). The whole request (the base64 text plus the JSON around it) must stay under the edge's 11.5 MiB body cap (section 4, code 4130), which a full 8 MB file does: 8 MiB of file is 11,184,812 bytes of base64.
Supporting evidence for the source of wealth. The SOURCE_OF_WEALTH block (11.4) is a declaration: three lists of tokens with their free texts, no amounts and no document reference. There is no upload purpose for source-of-wealth evidence and no block status that means "more information needed". Our compliance may ask for supporting evidence in an individual case, and what is built for that is the documents request of 10.2: the request arrives as kyc.documents_requested, the wanted documents are named in its note, and you upload each one here with purpose: "other" (stored as type other; JPEG, PNG or PDF, at most 8 MB, within the 20 live documents). Such an upload cannot be linked to a MiFID block: kind and reference apply to purpose: "entity" only, and the MiFID blocks belong to no package, so the request rides on the client's KYC package with the note alone naming what it is about. For a legal entity purpose: "entity" with a document kind of 9.7 remains the route for a corporate document. Reaching the enhanced due diligence threshold of 15.1 requests nothing by itself. This reference states no rule for when supporting evidence is required at that threshold: our compliance function decides that in the individual case, and a request reaches you as 10.2 describes.
GET /partner/v1/clients/{clientId}/documentsMetadata of every document on the client's file, ours and yours (never the content):
{"code": 0, "data": {"documents": [{"documentId": 905, "type": "utility_bill", "side": null, "filename": "bill.pdf", "mimeType": "application/pdf", "sizeBytes": 48213, "uploadedAt": 1788311100000, "deletedAt": null, "source": "PARTNER"}]}}
source is CLIENT, ONDATO (archived from our own identity provider), PARTNER, or EMPLOYEE (revision 10: a corporate document our staff added to a legal entity's company file from the CRM; it is listed to you and takes none of your 20 upload slots).
Every client of ours accepts our client-facing documents by version: the Terms and Conditions (TERMS), the privacy notice (PRIVACY), the order execution policy, the risk disclosure, the conflicts policy, the complaints procedure and the categorisation notice, each registered by our compliance function with a version tag, a title, a link and the archived text. A client you onboard accepts the versions in force at the onboarding IMPLICITLY: the acceptance instant is the moment our router writes the declaration blocks of POST /clients (the DECLARATIONS_MICA block stands for TERMS, PRIVACY, the conflicts policy, the risk disclosure and the complaints procedure; the DECLARATIONS_MIFID block of PUT .../mifid for the order execution policy, the risk disclosure, the categorisation notice and the conflicts policy). When we publish a NEWER version of a document the client's standing on it is older than the version in force (reconsentRequired) until an acceptance of the new version is recorded.
GET /partner/v1/clients/{clientId}/consents (READ) is the standing:
{"code": 0, "data": {"reconsentRequired": true, "gated": false, "documents": [
{"doc": "TERMS", "title": "Terms and Conditions", "url": "https://barriers.com/legal/terms", "currentVersion": "2026-10", "effectiveAt": 1791158400000,
"acceptedVersion": "2026-08", "acceptedAt": 1788311100000, "acceptedVia": "IMPLIED", "reconsentRequired": true, "gateFrom": 1793750400000},
{"doc": "PRIVACY", "title": "Privacy Notice", "url": "https://barriers.com/legal/privacy", "currentVersion": "1.0", "effectiveAt": 1780000000000,
"acceptedVersion": "1.0", "acceptedAt": 1788311100000, "acceptedVia": "IMPLIED", "reconsentRequired": false, "gateFrom": null}]}}
One entry per document that has a version in force; acceptedVia is PARTNER (recorded over this API), CRM (our operations), IMPLIED (the onboarding blocks) or PORTAL (our own portal, never for your client); gateFrom (Unix ms, null while the document is not gated or our switch is off) is the instant from which a pending re-consent on that document refuses new positions; gated says the gate would refuse a new position for the client right now.
POST /partner/v1/clients/{clientId}/consents (TRADE, Idempotency-Key) records the client's acceptance of the version in force on the client's behalf, after the client accepted the text in your app:
{"doc": "TERMS", "version": "2026-10", "acceptedAt": 1791200000000, "reference": "consent-8842"}
version must be the currentVersion of the read (409 code 4092 with data.currentVersion otherwise; 404 code 4004 when no version of the document is in force); acceptedAt (optional, Unix ms, in the past) is the instant the client accepted in your app and reference (optional, at most 200 printable ASCII characters) your record of it, both kept on our record's note; the record itself is stamped at our clock with channel PARTNER and actor partner:CODE, and the answer is the standing above (reconsentRequired and gated false for the document). A malformed body answers 400 code 4000 with data.errors (doc, version, acceptedAt, reference).
ANY REGISTERED DOCUMENT is acknowledged this way (BROKER-95): the policy documents above, ELECTRONIC_DELIVERY (the client's election to receive the documents electronically) and the KEY INFORMATION DOCUMENTS of the barrier markets, KID_<SYMBOL> for the normal contract and KID_<SYMBOL>_STRICT for the strict take profit contract (12.1 names the current version and the public PDF of each on GET /market-info, kid and kidStrict). A key information document rides the standing read like every registered document once a version of it is published: its row carries currentVersion and acceptedVersion, with scope HOLDERS a client who never acknowledged it reads reconsentRequired false and a holder of an earlier version reads true once a newer version is in force, and the row never gates (gateFrom null, gated untouched). The KID gate of 12.2 reads the record, not that flag: a RETAIL client's opening request needs the record for the CURRENT version, so compare the row's acceptedVersion with its currentVersion (the tag GET /market-info serves as kid.version and kidStrict.version) before the request. Your duty is to show the PDF from kid.url in your interface before the client's first request on the market and to record the acknowledgement here, which is the delivery evidence we keep (PRIIPs Article 14; the duty stays with PM MTF Ltd as the person selling under the reliance model of section 1). A RETAIL client's request without that record answers 403 code 4035 with data.error "KID_REQUIRED" (12.2); the same record with RISK_DISCLOSURE clears the strict take profit gate (RISK_DISCLOSURE_CONSENT_REQUIRED). The record is attributed on the client's and your audit streams.
THE GATE (the TERMS re-consent gate; a switch on our side, Consents:Gate, OFF on the development environment and off in production until our compliance function turns it on): while it is on, a client whose standing on TERMS is older than the version in force is refused every admission of NEW risk from that version's gateFrom (the later of its effective instant and fourteen days after its publication, plus a thirty day grace): POST .../orders for an opening order (a reduce-only order or one within the remaining reduce capacity of the opposite position passes), POST .../rfq, POST .../rfq/{rfqId}/accept on an OPENING request (the automatic accept skips its pass with the sentence as autoAcceptNote and the request stays open) and POST .../staking/{offerId}/accept, all with 403 code 4035 and data {doc, currentVersion, gateFrom}; POST .../rfq/preview answers the refused preview with the sentence. Never gated: reducing orders, the early close request and its accept, collateral reports, deposits and withdrawals. The sentence (msg) for a client of yours: "Our updated Terms and Conditions need your acceptance before you open new positions. You can still close positions, deposit and withdraw. Review them in your provider's app." (since 2026-09-12; it names your app because a client of yours holds no sign-in with us, section 1, while a direct client of ours reads a last clause naming our own portal). Show your own consent screen to the client and record the acceptance through the POST above; the refusal clears at once. Continued use never counts as an acceptance on our side.
THE NOTICE: when we publish a new version you receive the webhook client.consent_required (16.4) for every client of yours in scope, twice at most per version and client: stage ANNOUNCED at the publication of a version effective later, stage IN_FORCE when the version came into force (within a few minutes); a client who already accepted the version is not notified. A direct client of ours receives an e-mail at the same two instants; for a client you onboarded the CONSENT notices are the one notice the webhook substitutes: no consent mail from us, relay the webhook in your app. The notices our own regulatory duties require still reach your clients by e-mail at the contact address you gave us and never name you: the categorisation notice (11.6), the LEI notices and renewal reminders (11.4), the DORA Article 19(3) service incident notice (16.4, which you mirror in your app), the statement notices where enabled (they point at your app, the integration guide 3.9) and the address notice on a forgot-password or registration attempt (section 1 and the integration guide 3.9). Two more kinds of mail from our direct-client flow reach your clients today, because that flow has no partner variant: the identity document reminders and the expiry notice (10.6), and the verification decision notice our back office sends when a compliance officer of ours approves or refuses a verification level (a grant made through your own calls sends none, and neither does a rejected package). Prepare your support desk for them. All of these decision and document mails are written for a direct client of ours and speak of things your client cannot do with us (section 1): the two identity document mails ask the client to upload a valid document in our portal, the refusal notice asks the client to sign in to its account with us to see which sections need attention (and to open a support request from that account if it disagrees), and the approval notice speaks of the client's account with us (the notice of the MICA level also of depositing crypto). In those cases tell your client yourself, in your app, what to do (10.6 for the document, blocks[].reason of 11.5 for a refused section).
Nothing you sent expires by time. No block of a client you onboarded is moved out of APPROVED by a clock, a sweep or a new questionnaire version: only a decision of our compliance does that (a verification decision or a rejected package, 16.4), and the block status EXPIRED cannot occur for a client of yours (11.5). The 12 months of identityCheck.verifiedAt are checked at submission only (9.3) and an appropriateness result has no validity period (section 11). Two things that run after onboarding are described here. Beside them our own sanctions and PEP screening of every client continues after onboarding (section 1), a legal entity's LEI is re-checked (11.4) and a professional categorisation is reviewed (11.6); a measure that follows from any of them is a decision of our compliance or a restriction and reaches you as its own event (16.4).
The identity document's expiry date. On an hourly pass we track the expiry date of the identity document of every client who holds a level, yours included. For a client you onboarded the tracked date is kyc.identification.expiration_date, the date of the IDENTIFICATION block you sent; identityCheck.documentExpiry is validated when you send it and kept with the identity check record, but it is NOT read by this pass, so keep expiration_date current.
expiration_date, within about an hour) the client is sent an e-mail saying the document has expired, a CLOSE_ONLY restriction is engaged on the account on EVERY family, crypto spot included (a switch on our side, on by default), and an internal review of the account is opened for our compliance function.autoAcceptNote, and the preview answers accepted: false); reducing and closing always go through. Collateral reports, deposits and withdrawals are NOT blocked by this restriction.IDENTIFICATION and IDENTITY_CHECK blocks stay APPROVED, the level stays, the requirements map is unchanged and the client row keeps its lifecycleState (the pause is a restriction, not a lifecycle state, like the LEI pause of 11.4).margin.restriction_set and of its end as margin.restriction_lifted (16.4, data {kind, ref, ts}, at most one delivery each). Neither names the ground and no read of this API shows a client's restriction today (11.5), so compare your own copy of expiration_date to tell the client why. Do not rely on the delivery alone: the pass stamps the item with the time the pass started, so under the 30 second rule of 16.4 an item written late in a long pass is not forwarded. Read GET .../notifications for a client whose document date has passed, as for every feed event (16.4).PUT .../kyc (10.1), the identification section with the new expiration_date plus the attestation; a date that is not after today is refused, as at onboarding. The PUT itself does not lift the pause: the next hourly pass records the new date and lifts the restriction it engaged, so allow up to an hour. A restriction our compliance set on its own stays.The reminder and the expiry e-mail are the texts a direct client of ours receives: they ask the client to upload a valid identity document in our portal, which a client of yours cannot use (section 1). Tell your client yourself, ahead of the date, that the renewal goes through your app. A further measure our compliance officer takes inside that review in the individual case (a money restriction, a documents request) would reach you as its own event (16.4).
Our periodic review. We review every client who holds a level on a cycle set by OUR OWN risk rating of the client (not the riskRating you attest, and not the categorisation review of 11.6): every 12 months for a client we rate HIGH, every 24 for MEDIUM and every 36 for LOW, counted from the last completed review, else from the latest move of the client's level to MICA or MICA_MIFID (the MiCA grant, or the MiFID grant when it followed). The review is an internal task of our compliance function. Opening or completing one changes nothing on the wire by itself: no block, level, lifecycle state or restriction moves, no mail is sent and no webhook fires. It can lead to a documents request (kyc.documents_requested, 10.2), a restriction (margin.restriction_set) or a rejection (kyc.rejected), each the decision of an officer and delivered as 16.4 describes. We do not re-identify clients automatically by risk class, and this API has no route or event by which we ask you for a fresh identity verification: such a request would arrive as a documents request. Whether you push refreshed KYC data to us on your own refresh cycle (a PUT .../kyc when your own periodic review updates the client's data) is a matter for the reliance agreement between us; this API states and enforces no cadence.
The MiFID families (PERP, BARRIER, STAKING) need level MICA_MIFID: the four MiFID information blocks approved and an appropriateness result we hold as complete (PASS, or WARN acknowledged). The order of the two halves does not matter; the level moves the moment both are in place (for a result completed at the first or second attempt, below), and the webhook client.level_changed fires. A FAIL leaves the level at MICA; a retake is possible after the cooling period (24 hours from the attempt, Verification:AppropriatenessRetakeHours). Every attempt starts a cooling period whatever its outcome, so answers cannot be iterated.
An attempt is a SCORED submission (revision 15). An attempt is the call to POST .../appropriateness that answers 200: it carries attempt, it records the result, it fires appropriateness.result and it starts the cooling period. A refusal of that route (400, 404, every 409, 429) is not an attempt: it records no result, numbers no attempt and starts no cooling period, and nothing of it reaches you as an event. In particular the 409 of a stale version (11.2) costs the client nothing: re-read the questionnaire, re-render, and submit again under a new Idempotency-Key (section 5). The one refusal that leaves a record on our side is the compliance lock of this section, whose refused call is noted on our log by design; it numbers no attempt either. The answer stored under your Idempotency-Key is the one thing any refusal leaves behind (section 5).
An appropriateness result has NO EXPIRY. retakeAt is the earliest instant of a next attempt (the end of the cooling period), not a validity date. A new questionnaire version invalidates no recorded result: a result keeps the version it was taken against, and only answers rendered from a stale version are refused (11.2). Once the level is granted nothing re-reads the result, and the level is lost only by a decision of our compliance (16.4), never by time.
The automatic grant covers the first two attempts (revision 14). A result completed at attempt 1 or 2 (attempt on the result) grants the level through your calls. A result completed after MORE THAN TWO attempts is never granted automatically, the rule of our own onboarding: the moment both halves are in place the five MiFID blocks go SUBMITTED, the level stays MICA, and our compliance function decides. The call that completed the second half still answers 200, with granted false and grantRefusal "The appropriateness assessment was completed after more than 2 attempts: the MiFID level of this client is decided by our compliance function, and the MiFID blocks are under its review."; client.level_changed does not fire. While the blocks are SUBMITTED, PUT .../mifid answers 409 code 4092 ("The MiFID profile of this client is under review by our compliance function and cannot be changed until it is decided.", data {error: "MIFID_UNDER_REVIEW", lockedBlocks}) and a further assessment attempt answers the 409 of a section under review (11.2). The decision reaches you as client.verification_decision (16.4): an approval moves the level to MICA_MIFID; a refusal sets the blocks it names REJECTED, and the lock below applies.
The compliance lock (revision 14). A MiFID block our compliance function set REJECTED (the refusal of the MiFID level, 16.4, or a rejected package, 10.2; its decidedBy on 11.5 reads the role of our employee) is LOCKED: you cannot attest it again, and you cannot re-run the grant around it. While any of the five MiFID blocks is locked, PUT .../mifid, POST .../appropriateness and POST .../appropriateness/acknowledge answer 409 code 4092 with a sentence that names the blocks ("Our compliance function rejected EMPLOYMENT_FINANCIAL of this client. The MiFID blocks and the appropriateness assessment cannot be sent again until our compliance function releases the rejected blocks; contact our compliance function.") and data {error: "MIFID_BLOCKS_REJECTED", lockedBlocks: ["EMPLOYMENT_FINANCIAL"]}; nothing is written, no grant is attempted, and the refused call is recorded on our side. The lock is lifted by an officer of our compliance function and by nothing else: when our compliance invites a new submission it RELEASES the blocks, which then read DRAFT on 11.5 with decidedBy null and the client-facing sentence still in reason (what to correct); data blocks our compliance still held SUBMITTED are handed back as DRAFT with them. No event announces a release: it follows your exchange with our compliance function in the individual case, so re-read GET .../verification. After the release send PUT .../mifid again (under a NEW Idempotency-Key, section 5), and a new assessment when the APPROPRIATENESS block was the one rejected; the ordinary rules then apply (the grant, or the review of a result completed after more than two attempts). A REJECTED APPROPRIATENESS block whose decidedBy is null is the client's own FAILED attempt, never the lock: the retake after the cooling period stays open. Before revision 14 a repeat PUT .../mifid rewrote and approved a block our compliance had rejected and the grant ran again, which undid the refusal; that was a defect, corrected.
GET /partner/v1/appropriateness-testThe current questionnaire in client view (questions and options, never the points or thresholds), versioned. Render it in your interface as it is; the warning and acknowledgement sentences are part of the contract.
Versions are announced ahead (revision 17). Until revision 17 a new version was in force the moment our compliance function published it, with no advance notice, no event and no field on any read that announced one (revision 15 described that state). Until the code of this revision is deployed on an environment you use, we will not publish a new version on it without telling you directly beforehand: an operating commitment in force since 2026-09-22, when our compliance and operations people were instructed; a direct notice given under it is filed on our side against your partner record (section 21). The commitment ends on each environment with that deployment, because from then on the announced versions of this section replace it. Since revision 17 a version is PUBLISHED AHEAD of an effective instant: this read carries the schedule (status, publishedAt, effectiveAt, asOf, previous, next), the partner-level webhook appropriateness_test.version_changed (16.4) announces it at every stage, and GET /me shows the schedule in one glance (section 8). The questionnaire's own notice rule, as this read states it: a version that changes what you render (next.changeClass STRUCTURE or TEXT) is announced at least 14 calendar days ahead, in practice 30 (section 21); an URGENT publication (next.urgent true: a regulator's instruction, a defect in a question or in the scoring) may be shorter and may shorten or remove any acceptance of the previous version; at most one version is pending at a time; a SCORING_ONLY version changes nothing you render and needs no notice. previous and next.previousAcceptedUntil say whether and until when the previous version is accepted; they may be null, and we may end an acceptance early; a submission of the previous version is accepted strictly before acceptedUntil and refused at it (11.2 says what is scored and recorded then). Every published version has a higher number than the one before; numbers increase and need not be consecutive, so compare for equality and never hard code one. previous on this read is the truth about what is accepted now.
{
"code": 0,
"data": {
"version": 1, "status": "IN_FORCE", "publishedAt": 1786865712000, "effectiveAt": 1786865712000, "asOf": 1790845260000,
"previous": null,
"next": {"version": 2, "publishedAt": 1790845200000, "effectiveAt": 1793577600000, "previousAcceptedUntil": 1793581200000,
"changeClass": "STRUCTURE", "renderingChanged": true, "sentencesChanged": true, "changedQuestionIds": ["strictTakeProfitKnowledge"],
"urgent": false, "rehearsal": false, "changeNote": "One knowledge question added and the warning sentence extended.",
"textHash": "sha256:<the hash of the announced version's English text>", "availableLocales": ["en"], "missingLocales": []},
"locale": "en", "requestedLocale": null, "localeFallback": false, "availableLocales": ["en"], "textRevision": 1,
"textHash": "sha256:40e4befc4a2b55ba1eb2c2b33c302af867243e212fe6f38e151ca290a80cfc32", "languageElection": null,
"questions": [
{"id": "tradingExperience", "text": "Have you traded financial instruments (stocks, bonds, derivatives) before?", "section": null,
"options": [{"id": "never", "text": "Never"}, {"id": "less_than_1_year", "text": "Less than 1 year"}, {"id": "1_to_3_years", "text": "1 to 3 years"}, {"id": "more_than_3_years", "text": "More than 3 years"}]},
{"id": "riskUnderstanding", "text": "Do you understand that trading barrier products and perpetual contracts involves the risk of losing your entire investment?", "section": null,
"options": [{"id": "yes", "text": "Yes, I understand"}, {"id": "no", "text": "No, I do not understand"}]}
],
"sections": [{"id": "tradingExperience", "title": "Trading Experience", "questions": ["tradingExperience", "transactionCount", "averageTransactionSize"]}],
"warningText": "Based on your responses, complex products such as barrier contracts and perpetual contracts may not be appropriate for you because your level of experience and knowledge does not seem adequate in order to understand the risks involved. These products carry a high risk of losing your entire investment.",
"acknowledgeText": "I understand the risks and wish to proceed",
"failText": null
}
}
(The example shows two of the questions and an illustrative announcement; render every question the answer carries. The textHash of version 1 is the one every environment that serves the version 1 text answers.)
| Member | Meaning (revision 17) |
|---|---|
version, status | the version SERVED and its state at asOf: IN_FORCE, ANNOUNCED (ahead of its instant, read with ?version=) or SUPERSEDED (a former version, read with ?version=) |
publishedAt, effectiveAt, asOf | when the served version was published, when it is or was in force (equal to publishedAt for a version published in force), and our clock at the read (Unix ms) |
previous | {version, acceptedUntil, scoredUnder} while a submission of the previous version is still accepted, else null; acceptedUntil is EXCLUSIVE; scoredUnder is the version whose rules score such a submission (the previous version after a STRUCTURE or TEXT change, the version in force after a SCORING_ONLY change; 11.2) |
next | the announced version while one is pending, else null: version, publishedAt, effectiveAt, previousAcceptedUntil (the instant until which the version now in force will still be accepted once next is in force, exclusive, equal to effectiveAt when nothing is accepted), changeClass (STRUCTURE: the set or order of question, option or section ids changes; TEXT: only served words change; SCORING_ONLY: nothing you render changes; REHEARSAL: a copy of the version in force, section 18), renderingChanged, sentencesChanged (the warning, acknowledgement or fail sentence), changedQuestionIds (the questions added, removed, moved or reworded; [] when nothing you render changes), urgent, rehearsal, changeNote (one English sentence for your people; never points or thresholds), textHash (its English text, the recipe below), availableLocales and missingLocales (the locales of the version in force that the announced one does not serve yet; a read of the announced version in such a locale falls back to English). previous and next describe the SCHEDULE, so they are the same whatever ?version= asks for |
locale, requestedLocale, localeFallback, availableLocales | the language SERVED (the primary tag, en on a fallback), the tag you sent (null when none), whether a language you asked for is not served for this version (the English is answered as a whole then), and the languages this version is served in (en first; section 7) |
textRevision, textHash | the revision of the served text under this version and language, and the hash of its words (the recipe below); send textHash back on the submission (11.2) |
languageElection | reserved, null today; do not build on it; a later revision defines it |
questions[], sections[], warningText, acknowledgeText | the client view as before: questions[].{id, text, section, options[].{id, text}}, sections[].{id, title, questions} (ids and order from the version, the words from the served language), the two sentences that are part of the contract |
failText | reserved, null today; do not build on it; a later revision defines it |
?version=. Without it the read serves the version in force. With ?version=N it serves the announced version AHEAD of its instant (render and test it before it applies), the version in force, or a former version (what an attempt recorded under it was rendered from). A value that is not a positive integer answers 400 code 4000 "version must be a positive integer."; a number we never published, a withdrawn one and an announced one that was overtaken answer 404 code 4004 "Unknown questionnaire version.".
?locale=. A language tag xx or xx-YY (_ accepted for -), resolved to its primary language (section 7): the version is served in that language when availableLocales lists it, else in English as a whole with localeFallback true and requestedLocale echoing what you sent, never a mix; a value that is no language tag answers 400 code 4000 with data.error LOCALE_MALFORMED. Question and option ids, the order and sections[].questions come from the version whatever the language; the words come from the served text. Our own clients' routes carry no language parameter.
THE RENDERING RULES. Render from this read at the time the user opens the assessment; never ship the questionnaire text with an app release. Cache the read at most 5 minutes per version and locale, treat next.effectiveAt as a hard expiry of the cache, and drop the cache on any appropriateness_test.* event (16.4) and on a stale 409 (11.2); an answer with localeFallback true is never cached beyond minutes (the language may arrive). Never switch on your own clock: switch when this read answers the new version with status IN_FORCE, or on the IN_FORCE stage of the event; flipping late is always safe (the previous version is accepted inside its acceptance, and a stale 409 costs nothing), flipping early never is (the announced version answers 409 before its instant). Always submit the version, locale and textHash of the read you rendered. Numbers increase and need not be consecutive: compare for equality, never hard code one. The acceptance of the previous version is frozen at the publication of its successor, except that we may end it early for a regulatory defect and that an urgent publication ends a running acceptance; a refusal that follows costs the client nothing (section 11); previous on this read is the truth. Rehearse the stale path on the development environment by posting version: 0, and the whole announcement path on request (section 18).
textHash names the words, version names the rules (revision 17). version (one integer across languages) names the structure, the scoring and the English master; any change to those, English wording included, is a new version. locale, textRevision and textHash name the exact words served: a corrected translation is a new textRevision under the SAME version, announced by appropriateness_test.text_changed (16.4). The hash is sha256: plus the lower-case hex of SHA-256 over the UTF-8 bytes (no byte order mark) of the CANONICAL TEXT of the served words: lines, each ended by LF (U+000A, the last included), each key=value, the value the string exactly as served with backslash, LF, CR and TAB escaped as \\, \n, \r and \t, no Unicode normalisation (served text is precomposed); in this order, family=APPROPRIATENESS_TEST, locale=<xx>, per question in the served order q.<questionId>=<text> then, only when the question carries a section label, q.<questionId>.section=<label>, then per option in order o.<questionId>.<optionId>=<text>; then, only when the version has sections, per section s.<sectionId>=<title> and s.<sectionId>.questions=<id>,<id>,... (comma joined, the version's list); then warningText=<text>, acknowledgeText=<text> and, only when present, failText=<text>. A line is emitted only for a member that is present, so a member added later never moves the hash of a text that does not have it, and the version number is not part of it: equal text has an equal textHash on every environment; compare the production hash with the one you tested. A worked example: one question q1 "Alpha?" with the options a "Yes" and b "No", no sections, the warning "Warn." and the acknowledgement "Ack.", served in English, has the canonical text
family=APPROPRIATENESS_TEST
locale=en
q.q1=Alpha?
o.q1.a=Yes
o.q1.b=No
warningText=Warn.
acknowledgeText=Ack.
and the textHash sha256:304e3a67e466077d15370f9369389dec92a466d3219d91115f9f9914183e92c4; recompute it with any SHA-256 tool over those seven lines, each ended by LF, to check your reading of the recipe.
The lines of a sentences family (revision 18). The same recipe serves the declaration sentences of 11.7: family=DECLARATIONS_MIFID, locale=<xx>, then per sentence in the order the read serves them t.<key>=<text> (a line only for a key present with a non empty text; the master version is no line). A worked example over two test sentences that are never served, a "Sample sentence, not a regulated text." and b "Second sample.", in English, has the canonical text
family=DECLARATIONS_MIFID
locale=en
t.a=Sample sentence, not a regulated text.
t.b=Second sample.
and the textHash sha256:c5bc2fc0cd220a241d7929a9110cb9512b8f3de4fdde4a9f9d69d26210e89b34; the three sentences of 11.4 as served at this publication hash to sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe (five lines, 1766 bytes), which GET /texts?family=DECLARATIONS_MIFID answers on every environment until our compliance function approves a later revision.
POST /partner/v1/clients/{clientId}/appropriatenessScope TRADE. Body {version, locale, textHash, clientLanguage, languageElection, answers, representativePersonId}: version the version you rendered (11.1), locale and textHash the language and the text hash of the read you rendered (below), clientLanguage optionally the language your app showed the user (a language tag; recorded; a well formed value is never refused today, a value that is no language tag answers LOCALE_MALFORMED below), languageElection reserved (below), answers an object of question id to option id, every question answered (400 code 4000 names the first missing or unknown answer), representativePersonId for a legal entity (below). The result is recorded, the webhook appropriateness.result fires, and the MiFID grant is attempted at once.
version: REQUIRED as a JSON integer from the cut over (revision 17). The version check comes FIRST among the checks of the submission itself: before the cooling period, the section lock, the text checks and the answers are looked at; only the representative check of a legal entity and the compliance lock of this section's introduction come before it, and from the version check on a refusal writes nothing apart from the answer stored under your Idempotency-Key. Announced in revision 15 under section 21 and in force with the code of this revision: on the production environment from its first day, on the development environment from 2026-11-01 00:00 UTC, a body where version is absent or not a JSON integer (a string such as "1", a fraction, null) answers 400 code 4000 "version (the questionnaire version you rendered, a JSON integer) is required."; before that instant such a body is scored against the version in force, as revision 15 said. What the number you send decides:
version you send | What happens | Recorded |
|---|---|---|
| the version in force | scored under it | that version |
the previous version inside its acceptance after a STRUCTURE or TEXT change (previous.scoredUnder = the previous version) | scored under the previous version's questions AND its scoring: what your user saw is what counts; the 200 carries version = the previous version and supersededBy = the version in force | the previous version |
the previous version inside its acceptance after a SCORING_ONLY change, or a rehearsal copy (previous.scoredUnder = the version in force; section 18) | the rendering is identical, so the answers are scored under the rules IN FORCE; the 200 carries version = the version in force and submittedVersion = the number you sent | the version in force, with submittedVersion beside it |
| the announced version before its instant | 409 code 4092 "This questionnaire version is announced and not yet in force. Submit again once it is in force." with data {error: "APPROPRIATENESS_VERSION_NOT_IN_FORCE", submittedVersion, currentVersion, effectiveAt, serverTime}; the ONE refusal whose Idempotency-Key claim is given back (section 5): send the same request again at effectiveAt | nothing |
anything else: an older number, 0, a number we never published, an announced number that was overtaken | 409 code 4092 "The assessment has been updated. Please reload the questions and answer again." with data {error: "APPROPRIATENESS_VERSION_STALE", submittedVersion, currentVersion, acceptedVersions, renderingChanged} | nothing |
data.currentVersion is on BOTH version refusals, so a client that knows neither label still re-reads. acceptedVersions lists the version in force and, while one is accepted, the previous version. renderingChanged false on the stale 409 means the number you sent names a version whose served English text equals the version in force's (a scoring-only change ended its acceptance): re-post the same answers under currentVersion without re-rendering; true (a text or structure change, or an unknown number) means re-read, re-render and keep the answers whose question and option ids survive.
locale and textHash: the words your user read (revision 17). Send the locale and the textHash of the read you rendered. Both are REQUIRED for every questionnaire version that is or ever was served in more than one language, and whenever the locale you send is not en. Send both always. Nothing on this route ever falls back to English silently. The checks, in this order, each before anything is written and each recording nothing:
| Check | Answer |
|---|---|
locale sent and no language tag (also clientLanguage) | 400 code 4000 data.error "LOCALE_MALFORMED" ("locale must be a language tag such as xx or xx-YY."; data.member clientLanguage for that one) |
locale other than en and no textHash | 400 code 4000 "APPROPRIATENESS_TEXT_HASH_REQUIRED" ("textHash (the textHash member of the questionnaire read you rendered) is required.") |
locale absent while the version is or ever was served in more than one language, whatever else the body carries | 400 code 4000 "APPROPRIATENESS_LOCALE_REQUIRED" ("locale (the locale member of the questionnaire read you rendered) is required: this questionnaire version is served in more than one language."); then, textHash absent, the refusal above |
textHash sent and not sha256: plus 64 lower-case hex digits | 400 code 4000 "APPROPRIATENESS_TEXT_HASH_UNKNOWN" (data {error, version, locale}) |
textHash names a text we served under the version the attempt is recorded under (the version in force inside a scoring-only acceptance; the previous version inside a grace) in that language, and the text is still accepted | recorded ATTESTED with that text's textRevision and textHash |
textHash names a text we served but no longer accept (replaced by a material correction, withdrawn) | 409 code 4092 "APPROPRIATENESS_TEXT_WITHDRAWN" ("The text you rendered is no longer accepted. Please reload the questions and answer again.", data {error, reason: "SUPERSEDED" or "WITHDRAWN", defective, version, locale, currentVersion, currentTextRevision, currentTextHash, localeFallback}, the current members being what a read with your locale serves now); handled exactly like a stale version: nothing consumed, new key |
textHash names no text we served, and the version is not served in that language | 400 code 4000 "APPROPRIATENESS_LOCALE_NOT_AVAILABLE" ("This questionnaire version is not served in the locale xx. Read the questionnaire with ?locale= and render what the answer serves.", data {error, locale, availableLocales}) |
textHash names no text we served, and the version is served in that language | 400 code 4000 "APPROPRIATENESS_TEXT_HASH_UNKNOWN" ("textHash does not name a text we served for version N in xx. Send the textHash of the read you rendered.", data {error, version, locale}) |
neither locale nor textHash sent, the requirement above not met (a version only ever served in English) | recorded as English, textAttested false |
locale en and no textHash, the requirement not met | recorded as English, textAttested false |
Of a replaced text: a replaced text may stay acceptable until the instant the appropriateness_test.text_changed event states in previousAcceptedUntil (16.4); never rely on it. languageElection on this route is reserved, null today; do not build on it; a later revision defines it (LANGUAGE_ELECTION_UNKNOWN answers a value that names no sentence we served, section 4).
What a refusal of this route consumes: nothing (revision 15, widened in revision 17). The attempts rule of the introduction of this section, stated for this route: the 409s of a stale, announced or withdrawn rendering, the 400 of a missing version, locale or textHash or of an unanswerable body, the 429 of the cooling period and the 409 of a section under review, approved or under the compliance lock record no result, number no attempt, start no cooling period, fire no appropriateness.result and move neither retakeAt nor attempt; the next scored submission is numbered as if the refusal had not happened. After any of them the next submission carries a NEW Idempotency-Key (section 5; the "still in progress" answer about the key itself and the APPROPRIATENESS_VERSION_NOT_IN_FORCE refusal, whose key is given back, excepted).
{"version": 1, "locale": "en", "textHash": "sha256:40e4befc4a2b55ba1eb2c2b33c302af867243e212fe6f38e151ca290a80cfc32", "answers": {"tradingExperience": "more_than_3_years", "transactionCount": "more_than_50", "averageTransactionSize": "more_than_50000", "barrierProductKnowledge": "expires_worthless", "knockoutKnowledge": "closed_at_loss", "riskUnderstanding": "yes", "investmentObjectives": "speculation", "highRiskAllocation": "10_to_25"}}
{
"code": 0,
"data": {"version": 1, "outcome": "PASS", "score": 24, "requiresAcknowledgement": false, "retakeAt": 1788397200000, "attempt": 1,
"submittedVersion": null, "supersededBy": null, "locale": "en", "textRevision": 1, "textHash": "sha256:40e4befc4a2b55ba1eb2c2b33c302af867243e212fe6f38e151ca290a80cfc32", "textAttested": true,
"warning": null, "failure": null, "verificationLevel": "MICA_MIFID", "granted": true, "grantRefusal": null}
}
The 200 (revision 17): submittedVersion (the number you sent inside a scoring-only acceptance, else null), supersededBy (the version in force when the answers were scored under the previous version inside a grace, else null), locale, textRevision, textHash and textAttested (the words recorded for this attempt and whether you attested them with the hash), warning ({text, acknowledgeText, version} on a WARN: the sentences of the version your user SAW, in the recorded language; show them and acknowledge with 11.3; null otherwise) and failure (reserved, null today; do not build on it; a later revision defines it).
granted is true when this call moved the level to MICA_MIFID (the four blocks were already in place). grantRefusal (revision 8) is the sentence of a grant that was attempted and refused (a legal entity whose LEI is not in good standing, 11.4) or withheld for our compliance (revision 14: a result completed after more than two attempts, the introduction of this section), null when granted or when nothing was attempted yet. Outcomes: PASS, WARN (requiresAcknowledgement true until section 11.3), FAIL. A retake before retakeAt answers 429 code 4290 with data.retakeAt. Once the APPROPRIATENESS block is approved (the level was granted) a further attempt answers 409 code 4092; so does an attempt while the block is SUBMITTED for our compliance, and (revision 14) an attempt while the compliance lock stands (data.error MIFID_BLOCKS_REJECTED, the introduction of this section).
A legal entity (revision 8). The answers are given by a named representative on the entity's behalf: the body carries representativePersonId, the personId of an active REPRESENTATIVE of the file (from the kyb.persons of the onboarding answer, 9.7), required for a LEGAL client ("representativePersonId must name an active representative of the entity.") and refused on a NATURAL client ("representativePersonId applies to legal entities."). On this route and on 11.3 the refusal is a plain 400 code 4000 with the sentence as msg, like the route's other refusals; on PUT .../mifid (11.4) it is the field representativePersonId of the validation map. The assessment engine is unchanged; the person who answered is recorded on the entity file (APPROPRIATENESS_ANSWERED) and in our audit trail.
POST /partner/v1/clients/{clientId}/appropriateness/acknowledgeScope TRADE. For a WARN result: the partner attests that the warning text was shown to the client and accepted. Body {version, acknowledgedAt, locale, textHash, representativePersonId}: version the version of the result you acknowledge (below), acknowledgedAt Unix ms, required, not in the future, locale and textHash the words your user read (below). The row carries our server time as the acknowledgement time and yours as the attested time.
{"version": 1, "acknowledgedAt": 1788311280000, "locale": "en", "textHash": "sha256:40e4befc4a2b55ba1eb2c2b33c302af867243e212fe6f38e151ca290a80cfc32"}
{"code": 0, "data": {"version": 1, "acknowledgedAt": 1788311300000, "attestedAt": 1788311280000, "outcome": "WARN", "verificationLevel": "MICA_MIFID", "granted": true, "grantRefusal": null}}
400 code 4000 when there is no WARN result to acknowledge or acknowledgedAt is missing; 409 code 4092 while the compliance lock stands (revision 14, the introduction of this section: a repeated acknowledgement never re-runs the grant around a block our compliance rejected). A legal entity acknowledges through its named representative (representativePersonId, the rule of 11.2; recorded as APPROPRIATENESS_ACKNOWLEDGED on the file); grantRefusal as in 11.2.
version on the acknowledgement: REQUIRED from the cut over (revision 17). Announced in revision 15 and in force with the code of this revision, with the same cut over as 11.2 (production from its first day, the development environment from 2026-11-01 00:00 UTC): a body where version is absent or not a JSON integer answers 400 code 4000 "version (the version of the result you acknowledge, a JSON integer) is required."; before that instant an omission is accepted as before. It is ACCEPTED when it equals the version of the result you acknowledge OR that result's submittedVersion (the number you rendered inside a scoring-only acceptance, 11.2), so a client that sends the version it rendered is never refused by a switch it did not see. Anything else answers 409 code 4092 "The acknowledgement names another questionnaire version than the result it acknowledges." with data {error: "APPROPRIATENESS_ACK_VERSION_MISMATCH", resultVersion, submittedVersion} and NEVER currentVersion (a 409 that carries currentVersion means "re-read and re-render"; this one means "acknowledge the result you have"). The answer carries version, the result's. locale and textHash are taken under the SAME rules as on 11.2 against the RESULT's version, after the version comparison, with one addition: the textHash the 200 of 11.2 answered (the words your user was shown with the result) is always accepted. A refusal of this route consumes nothing (the attempts rule of the introduction of this section) and the next call carries a new Idempotency-Key.
PUT /partner/v1/clients/{clientId}/mifidScope TRADE, reliance APPROVED. The four MiFID blocks, all required, validated like the wizard and approved under the partner's attestation; errors are keyed mifid.<section>.<field>.
accountPurpose (ACCOUNT_PURPOSE): primary_purpose (one of long_term_investment, short_term_speculation, hedging, income_generation, portfolio_diversification, professional_trading, other), primary_purpose_other (required with other, at most 200).
employmentFinancial (EMPLOYMENT_FINANCIAL): employment_status (one of employed_full_time, employed_part_time, self_employed, unemployed, retired, student, homemaker, other), employment_status_other (with other, at most 100); for the three employed statuses also industry (one of agriculture_forestry, automotive, banking_financial, construction_real_estate, consulting, defense_aerospace, education, energy_utilities, entertainment_media, government_public, healthcare_pharma, hospitality_tourism, information_technology, insurance, legal_services, manufacturing, mining_metals, non_profit, retail_consumer, shipping_logistics, telecommunications, transportation, other), industry_other, position (one of entry_level, mid_level, senior, manager, director, vice_president, c_suite, board_member, owner_founder, other), position_other, employer_name (at most 100); always annual_income (one of lt_10k, 10k_25k, 25k_50k, 50k_100k, 100k_250k, 250k_500k, 500k_1m, gt_1m), net_worth (one of lt_50k, 50k_100k, 100k_250k, 250k_500k, 500k_1m, 1m_5m, gt_5m), expected_deposit_12m (one of lt_5k, 5k_10k, 10k_25k, 25k_50k, 50k_100k, 100k_500k, gt_500k).
sourceOfWealth (SOURCE_OF_WEALTH): three arrays of at least one distinct token each, with a free text (at most 200) required when other is chosen: income_sources / income_source_other (tokens employment_salary, business_income, investments, trust_income, rental_income, pension_retirement, government_benefits, family_support, other); funds_sources / funds_source_other (the income tokens plus inheritance_gift, savings, property_sale, business_sale, insurance_payout, legal_settlement); wealth_sources / wealth_source_other (employment_income, business_profits, investment_returns, property_ownership, inheritance, family_wealth, sale_of_assets, legal_settlement, other).
declarationsMifid (DECLARATIONS_MIFID): three booleans, every one true: order_execution_policy_consent, complex_products_risk_acknowledged, client_categorisation_notice_acknowledged. Before you send them, show the client the CURRENT versions of our Order Execution Policy, our Risk Disclosure (including its complex products section) and our Client Categorisation Notice at the links we publish to you (the versions our consent register records; we announce a new version before it takes effect); the client's acknowledgement in your interface is your evidence under your attestation.
The sentences the three keys stand for (revision 15, served since revision 18). Each key is the client's acknowledgement of one sentence of the regulated text family DECLARATIONS_MIFID, served by GET /partner/v1/texts?family=DECLARATIONS_MIFID (11.7) with the language members of 11.1, a textRevision and a textHash. THE READ IS THE TRUTH: render what GET /texts serves, never this table; a sentence may change at any instant by an approval of our compliance function, in force at the instant (no notice period, the content rule of section 21), the event appropriateness_test.text_changed with family DECLARATIONS_MIFID (16.4) is the signal to re-read and to drop your cache, and the display duty of section 8 (render as served, never rephrase, translate or shorten) applies to the served revision. The three sentences are printed here AS SERVED AT THIS PUBLICATION: revision 1 of master version 1, byte for byte the wording our compliance function approved on 2026-09-09 and our own onboarding shows, with the hash sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe; the text ids of revision 15 stay as the label of revision 1 (text id declarations_mifid.<key>.2026-09-09 = master version 1, revision 1). The sentence behind complex_products_risk_acknowledged, held in revisions 15 to 17 pending a re-confirmation, is printed from this revision: it is revision 1 as served, and our compliance function edits it, like the other two, in our own tooling.
| Key | Text id | Sentence (revision 1) |
|---|---|---|
order_execution_policy_consent | declarations_mifid.order_execution_policy_consent.2026-09-09 | I have read the Order Execution Policy of PM MTF Ltd and I consent to it. I understand that PM MTF Ltd executes my orders on the Kronos X MTF, a trading venue operated by PM MTF Ltd itself, that this arrangement is a conflict of interest disclosed in the policy, and that PM MTF Ltd has documented why executing on that venue delivers the best possible result for my orders. |
complex_products_risk_acknowledged | declarations_mifid.complex_products_risk_acknowledged.2026-09-09 | I have read the Risk Disclosure of PM MTF Ltd, including its section on complex products. I acknowledge that the complex products offered by PM MTF Ltd (today perpetual contracts, barrier contracts and staking swaps) carry a high risk of losing money rapidly, that a leveraged position can be closed out at a loss without my instruction when my margin falls below the required level, that a barrier contract is closed at the barrier, typically at a loss, that in a staking swap the counterparty's obligation to return my assets and pay the reward is unsecured, that a strict take profit contract loses the whole premium at expiration unless the take profit is touched, and that past performance is no guide to future results. |
client_categorisation_notice_acknowledged | declarations_mifid.client_categorisation_notice_acknowledged.2026-09-09 | I have received and read the Client Categorisation Notice of PM MTF Ltd. I understand that PM MTF Ltd treats me as a retail client unless it has notified me in writing of a different categorisation, that the retail categorisation gives me the highest level of regulatory protection (including the leverage limits, the margin close-out rule and negative balance protection), and that I may request a different categorisation in writing, subject to the conditions and the loss of protections described in the notice. |
declarationsMifidText (revision 18). An OPTIONAL top level object beside the four blocks and representativePersonId, {locale, textHash}: the locale and textHash of the GET /texts?family=DECLARATIONS_MIFID read you rendered to the user before the user acknowledged the three keys. It sits beside declarationsMifid, not inside it, because declarationsMifid is the block's data, stored and echoed as the three booleans. The rules are those of 11.2's locale and textHash, with the family's own labels; every 400 is a field of this route's validation map (mifid.declarationsMifidText.locale or .textHash, the label the first word of the field's sentence) and data.error carries the label of the first text refusal beside errors (additive), so key on data.error:
| Check | Refusal |
|---|---|
declarationsMifidText present and not an object (a string, a number, an array) | 400 on the field mifid.declarationsMifidText itself, its sentence naming the two members; no data.error (a malformed body member, like any other field of the map) |
locale sent and no language tag | 400 LOCALE_MALFORMED (the field .locale) |
locale not en and textHash absent, whatever the date | 400 DECLARATIONS_TEXT_HASH_REQUIRED (.textHash) |
the family is or ever was served in a second language and locale absent (a hash alone included) | 400 DECLARATIONS_LOCALE_REQUIRED (.locale); then a missing textHash the row above |
textHash not sha256: plus 64 lower case hex | 400 DECLARATIONS_TEXT_HASH_UNKNOWN (.textHash) |
the hash names a text we served under the language that is still accepted (current, or replaced or withdrawn inside the instant text_changed stated) | recorded ATTESTED with that text's revision and hash |
| the hash names a text we served that is no longer accepted | 409 code 4092 DECLARATIONS_TEXT_WITHDRAWN with data {error, reason SUPERSEDED or WITHDRAWN, defective, masterVersion, locale, currentTextRevision, currentTextHash, localeFallback}; nothing written; re-read, re-render, a new Idempotency-Key |
| the hash is unknown and the family is not served in that language | 400 DECLARATIONS_LOCALE_NOT_AVAILABLE (.locale) with data {error, locale, availableLocales}; never a silent fallback |
| the hash is unknown otherwise | 400 DECLARATIONS_TEXT_HASH_UNKNOWN (.textHash) with data {error, masterVersion, locale} |
| nothing sent while the family is served in English only | recorded en with the current English's revision and hash, localeAssumed true, not attested |
locale en without a hash while the family is served in English only | recorded en with the current English's revision and hash, not assumed, not attested |
textHash without a locale while the family is served in English only | looked up under en; recorded ATTESTED with that text's revision and hash, localeAssumed false (the hash names the English text, so nothing is assumed); the hash outcomes above apply |
The checks run after the block validation and before any write; nothing is written by a refusal, and the Idempotency-Key claim is kept like every text refusal of 11.2. Every acknowledgement is RECORDED with the revision and the hash of the words (one record per call, in the same transaction as the blocks); a record stands as a declaration to those words, for ever, and nothing is invalidated by a later revision. The 200 carries declarationsText {family, masterVersion, locale, localeAssumed, textRevision, textHash, textAttested, recordedAt} (additive), and GET .../verification (11.5) the latest record. Send declarationsMifidText always: as long as the family is served in English only it is optional, and once a second language is served it is required by content exactly like 11.2's members (section 21, item (5)).
{
"accountPurpose": {"primary_purpose": "long_term_investment"},
"employmentFinancial": {"employment_status": "employed_full_time", "industry": "information_technology", "position": "senior", "employer_name": "Acme GmbH", "annual_income": "50k_100k", "net_worth": "100k_250k", "expected_deposit_12m": "5k_10k"},
"sourceOfWealth": {"income_sources": ["employment_salary"], "funds_sources": ["savings"], "wealth_sources": ["employment_income"]},
"declarationsMifid": {"order_execution_policy_consent": true, "complex_products_risk_acknowledged": true, "client_categorisation_notice_acknowledged": true},
"declarationsMifidText": {"locale": "en", "textHash": "sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe"}
}
{
"code": 0,
"data": {
"clientId": 412, "clientUid": "BARR-00000412", "verificationLevel": "MICA", "lifecycleState": "ACTIVE", "granted": false, "grantRefusal": null,
"appropriateness": {"version": null, "outcome": null, "score": null, "takenAt": null, "acknowledgedAt": null, "retakeAt": null, "requiresAcknowledgement": false, "attempt": 0},
"requirements": {"TRADE:PERP": ["APPROPRIATENESS"], "TRADE:SPOT": [], "TRADE:BARRIER": ["APPROPRIATENESS"], "TRADE:STAKING": ["APPROPRIATENESS"], "DEPOSIT:CRYPTO": [], "WITHDRAW:CRYPTO": [], "DEPOSIT:FIAT": ["BANK_ACCOUNT"], "WITHDRAW:FIAT": ["BANK_ACCOUNT"]},
"declarationsText": {"family": "DECLARATIONS_MIFID", "masterVersion": 1, "locale": "en", "localeAssumed": false, "textRevision": 1, "textHash": "sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe", "textAttested": true, "recordedAt": 1788307300000}
}
}
All four blocks, every time. No partner route submits one MiFID block alone: PUT .../mifid takes all four on every call, and a missing section is a field error of the validation map. The four blocks are written and approved in one step BEFORE the grant is attempted and whatever the appropriateness outcome, so a client whose result is a FAIL or still missing holds the four blocks APPROVED while its level stays MICA. A REPEAT call rewrites all four blocks as a new version and approves them again under your attestation, EXCEPT while our compliance function holds the file (revision 14, the introduction of this section): a block it set REJECTED locks the route until it releases the block, and blocks SUBMITTED for its decision lock the route until it decides; both answer 409 code 4092 and write nothing. The grant is attempted only while the client's level is MICA (a client already at MICA_MIFID keeps its level), and a result completed after more than two attempts hands the file to our compliance instead of granting. This is also the one way to put the SOURCE_OF_WEALTH block on file for a client you kept at level MICA who met the enhanced due diligence ask of 15.1: send the full four-block body.
A legal entity (revision 8). The body carries representativePersonId (the rule of 11.2: the representative who attests the blocks on the entity's behalf, recorded as MIFID_ATTESTED on the file). The four blocks keep their shapes and are read with entity semantics: accountPurpose is the entity's purpose; employmentFinancial describes the entity: employment_status other with employment_status_other "legal entity", industry the entity's token, position the representative's position, employer_name the legal name, annual_income the entity's net annual revenue band, net_worth the entity's net assets band, expected_deposit_12m as for a natural person; sourceOfWealth the entity's sources (business_income, business_profits, investment_returns, ...); declarationsMifid acknowledged by the representative; a missing or invalid representativePersonId (or one sent for a natural person) is the field representativePersonId of the same validation map as the block errors. The grant needs the LEI in good standing (MiFIR Article 26(6)): a leiStatus of ISSUED, PENDING_TRANSFER or PENDING_ARCHIVAL (the GLEIF registration status), FORMAT_OK (the format check alone, when no registry lookup is configured) or UNKNOWN (the lookup could not run, failed, or answered a status we do not know: the format check stands until the next check re-reads the registry); a LAPSED, RETIRED, ANNULLED, DUPLICATE, MERGED or invalid LEI leaves the level at MICA and the answer's grantRefusal says so ("KYB: a legal entity needs an LEI in good standing before it may trade MiFID instruments (MiFIR Article 26(6))"); renew the LEI, send the new one through PUT .../kyc (10.1) and the next assessment or acknowledgement attempts the grant again. Level MICA_MIFID unlocks the same families as for a natural person.
FORMAT_OK grants only when no registry lookup is configured (revision 10). Where our registry lookup runs, the grant looks the LEI up first: an LEI whose status is FORMAT_OK or has never been checked against the Global LEI System is checked ONCE at the grant, on every grant path (the assessment, the acknowledgement and PUT .../mifid alike), the status recorded on the file, and the answer of the registry decides: ISSUED, PENDING_TRANSFER, PENDING_ARCHIVAL and UNKNOWN (the registry unreachable) grant, LAPSED, RETIRED, ANNULLED, DUPLICATE, MERGED and an invalid LEI refuse with the sentence above in grantRefusal. A status already looked up is never re-fetched at the grant. The check runs at onboarding and at every new LEI, so a partner normally never sees the grant wait for it.
AFTER THE GRANT (revision 10, grown in place; the lapse sweep): where our registry lookup runs, our re-check of an approved entity's LEI runs at least every 30 days. An LEI whose registration is found LAPSED puts the client in close-only mode on perpetuals, barrier contracts and staking swaps 30 days after the lapse unless it is renewed in the meantime (the cure window); an LEI whose registration is RETIRED, ANNULLED, MERGED, DUPLICATE or no longer found puts the client in close-only mode at once. The pause is a restriction, not a lifecycle state: the client row keeps its lifecycleState (an ACTIVE client stays ACTIVE, and state=RESTRICTED on the clients list never finds a paused entity). In close-only mode an opening order, a barrier request, the accept of an opening request and a swap take answer the close-only sentence of section 4 as 400 code 4000 ("Your account is in close-only mode on this market: you can reduce or close positions but not open new ones. Please contact support."), reducing and closing always go through, and crypto spot is unaffected. You learn of the pause and of its end as margin.restriction_set and margin.restriction_lifted (16.4); there is no other wire change, and leiStatus on the client row and on kyb reads the stored registration status throughout. The client is mailed the notice and, where the registry publishes the renewal date, renewal reminders. Send the renewed LEI through PUT .../kyc (10.1): it is checked at once, and a registration found ISSUED or pending again lifts the pause on the next pass; a registry that cannot answer neither starts nor lifts a pause. Off on the development environment (section 18).
GET /partner/v1/clients/{clientId}/verificationThe client-shaped overview: level, lifecycle, every block with its status and the ROLE of its decider (PARTNER for your own attestations, SYSTEM for our automation, else the role of our employee: COMPLIANCE, ADMIN, ...; never a person's name, revision 6), the requirements map, the latest appropriateness result and the package summary.
{
"code": 0,
"data": {
"clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "level": "MICA_MIFID", "lifecycleState": "ACTIVE",
"blocks": [
{"code": "PERSONAL_INFO", "level": "MICA", "status": "APPROVED", "decidedBy": "PARTNER", "decidedAt": 1788307300000, "reason": null, "updatedAt": 1788307300000},
{"code": "APPROPRIATENESS", "level": "MICA_MIFID", "status": "APPROVED", "decidedBy": "PARTNER", "decidedAt": 1788311200000, "reason": null, "updatedAt": 1788311200000}
],
"requirements": {"TRADE:PERP": [], "TRADE:SPOT": [], "TRADE:BARRIER": [], "TRADE:STAKING": [], "DEPOSIT:CRYPTO": [], "WITHDRAW:CRYPTO": [], "DEPOSIT:FIAT": ["BANK_ACCOUNT"], "WITHDRAW:FIAT": ["BANK_ACCOUNT"]},
"appropriateness": {"version": 1, "outcome": "PASS", "score": 24, "takenAt": 1788311200000, "acknowledgedAt": null, "retakeAt": 1788397200000, "requiresAcknowledgement": false, "attempt": 1, "submittedVersion": null, "supersededBy": null, "locale": "en"},
"kycPackage": {"kycPackageId": 61, "revision": 2, "status": "ACCEPTED", "reviewNote": null},
"declarationsText": {"family": "DECLARATIONS_MIFID", "masterVersion": 1, "locale": "en", "localeAssumed": false, "textRevision": 1, "textHash": "sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe", "textAttested": true, "recordedAt": 1788307300000}
}
}
Since revision 18 the answer carries declarationsText, the LATEST record of the declarations the client acknowledged (11.4: the family, the master version, the locale and whether it was assumed, the revision and the hash of the words, whether you attested the hash, the instant; null before the client's first MiFID write). Since revision 17 the appropriateness object carries submittedVersion, supersededBy and locale as the 200 of 11.2 answered them (the number rendered inside a scoring-only acceptance, the version in force when the attempt was scored under the previous one, the language of the words the client read; the same three members on PUT .../mifid's answer).
Block statuses: NOT_STARTED, DRAFT, SUBMITTED, APPROVED, REJECTED, EXPIRED. The twelve blocks listed are the seven MiCA blocks (PERSONAL_INFO, IDENTIFICATION, TAX_RESIDENCY, FATCA, PEP, DECLARATIONS_MICA, IDENTITY_CHECK) and the five MiFID blocks (ACCOUNT_PURPOSE, EMPLOYMENT_FINANCIAL, SOURCE_OF_WEALTH, APPROPRIATENESS, DECLARATIONS_MIFID). A legal entity (revision 8) lists four more with level: "KYB": ENTITY_INFO, ENTITY_DOCUMENTS, UBO_DECLARATION, REPRESENTATIVES (the entity file, APPROVED with the MiCA grant); a natural person's answer is unchanged.
EXPIRED belongs to the block model this read shares with our own onboarding. It is set only on IDENTITY_CHECK, and only for a client of our own hosted identity flow whose identity session expired or was abandoned before it was completed. It cannot occur for a client you onboarded: your identityCheck record is stored as a session that is already concluded (9.3). No block of your client expires by time: a block leaves APPROVED only by a decision of our compliance, which sets it REJECTED and says why in reason (16.4). What runs after onboarding is described in 10.6.
On the five MiFID blocks two statuses mean that our compliance function holds the file (revision 14, the introduction of section 11): REJECTED with the role of our employee in decidedBy is the compliance lock (your MiFID writes answer 409 until our compliance releases the block, which then reads DRAFT with decidedBy null and the sentence kept in reason), and SUBMITTED is a file waiting for its decision (a result completed after more than two attempts). A REJECTED APPROPRIATENESS block with decidedBy null is a failed attempt of the client and locks nothing.
requirements is also the one advance signal of the enhanced due diligence source-of-wealth ask (15.1): once it applies to the client, requirements["DEPOSIT:CRYPTO"] and requirements["WITHDRAW:CRYPTO"] list SOURCE_OF_WEALTH. The read carries no enhanced due diligence standing (no threshold, no deposit sum, no flag), so you cannot read how close a client is to the threshold.
The read has no restrictions member. A client restriction (NO_NEW_ORDERS, CLOSE_ONLY, NO_WITHDRAWALS, FROZEN; an information-only kind without any trading or money effect exists too, so an event need not be followed by a refusal) cannot be read on this API today: not its kind, not its family, not its ground. You learn that one was engaged or lifted from margin.restriction_set and margin.restriction_lifted (16.4), which carry the restriction's id only, and you meet its effect as the sentence of the refused request (11.4, 15.1). GET /trading-status (12.1) answers the venue-wide trading halts, never a client's restrictions.
Lifecycle states a partner sees: KYC_PENDING (held for review), ACTIVE, RESTRICTED (the account is held pending a decision of our compliance, for example after a KYC revision that changed the client's identity and whose re-screening needs a decision, 10.1; opening new risk is refused), KYC_REJECTED, CLOSED. A client restriction is NOT a lifecycle state: a close-only pause (the LEI pause of 11.4, the identity document pause of 10.6) or any other restriction leaves lifecycleState as it was, so an ACTIVE client under a restriction still reads ACTIVE.
Every client is onboarded as RETAIL (9.1). A client who asks to be treated as a PROFESSIONAL client (MiFID II Annex II) asks in writing, and you record that request on the client's behalf; OUR compliance function assesses the file and decides in our back office. No partner route decides, applies or revokes a categorisation. Since revision 10 the decision also needs the written request to be GENERAL (criteria.scope GENERAL; a request scoped to particular products is still recorded and read back as sent, but it is never approved: we hold one categorisation per client), criteria.warningVersion to name the published warning version the client read (CAT-WARN-2026-09 v1 today; our officer tells you the version in force at onboarding and on every change) and statementSignedAt to lie at or after warningShownAt. The outcome reaches you as the webhook client.categorization_changed (16.4) and changes categorization on every read; the leverage caps (12.3: the schedule for the categorisation in force) and the retail protections (the negative balance protection among them) follow the categorisation in force. An opt-down back to RETAIL is a request of the same shape and is applied at once on approval (a client may always ask for more protection). Eligible counterparties have no path.
Two bases exist:
basis | Who | The test our compliance function applies |
|---|---|---|
ELECTIVE (default) | a natural person or a legal entity | Annex II section II: at least two of three quantitative criteria (an average of 10 significant transactions per quarter over the previous four quarters, a financial instrument portfolio exceeding EUR 500,000, at least one year in a professional position in the financial sector), the qualitative assessment of expertise, experience and knowledge, which is OURS to record, and the three procedural steps: the client's written request, the clear written warning of the protections lost, the client's separate written statement |
PER_SE | a legal entity only | Annex II section I(2), a large undertaking: two of the three size requirements on a company basis (balance sheet total EUR 20,000,000, net turnover EUR 40,000,000, own funds EUR 2,000,000), from the figures you send in perSe (a figure at a threshold meets it; section I(2) only: a regulated entity, a public body or an institutional investor of section I(1), I(3) or I(4) opts up under ELECTIVE); the three procedural steps are recorded on this basis too (recording more protects the client and the file); the qualitative test of section II is not required |
POST /partner/v1/clients/{clientId}/categorization
Scope TRADE, reliance APPROVED (403 code 4036 otherwise, like the onboarding, KYC and MiFID writes: the written request, the warning and the statement are records you hold), Idempotency-Key recommended. Records the client's request: requestedBy CLIENT, the requester your partner code. Answer 201 with the request.
{
"target": "PROFESSIONAL",
"basis": "ELECTIVE",
"criteria": {"tradesPerQuarter": [14, 12, 9, 11], "portfolioEur": "620000", "financialSectorMonths": 30, "financialSectorRole": "derivatives trader at a bank",
"qualitativeAssessment": "the client has run a discretionary derivatives book for two years", "scope": "GENERAL", "assessedPerson": null, "notes": null,
"warningVersion": "CAT-WARN-2026-09 v1", "statementRef": "sig-88a1"},
"evidence": [{"kind": "TRADE_HISTORY", "description": "quarterly trade counts from the partner's records", "ref": "acme/trades/2025Q3-2026Q2"},
{"kind": "PORTFOLIO_PROOF", "description": "custody statement of 2026-08-31", "ref": "doc-412-7"}],
"requestReceivedAt": 1788300000000, "warningShownAt": 1788300100000, "statementSignedAt": 1788300200000,
"perSe": null
}
| Field | Required | Rules |
|---|---|---|
target | yes | PROFESSIONAL or RETAIL ("target must be PROFESSIONAL or RETAIL"); the target must differ from the categorisation in force (409 code 4092 "the client is already PROFESSIONAL" or "... RETAIL"). |
basis | no | ELECTIVE (default) or PER_SE; PER_SE on a natural person: "PER_SE applies to legal entities; a natural person opts up under ELECTIVE." |
criteria | no | The Annex II inputs: tradesPerQuarter (up to four quarterly counts, oldest first), portfolioEur (decimal string, kept as sent), financialSectorMonths (integer), financialSectorRole, qualitativeAssessment (your own text; our officer may replace it and you read the replacement), scope (must be GENERAL: we hold one categorisation per client; a request scoped to particular services or products is not approved; record GENERAL only when the client's written request is general, otherwise do not file it), assessedPerson, notes (free text for our compliance officer: kept on the file, never returned on this API), warningVersion (the identifier of OUR published warning text you delivered, currently CAT-WARN-2026-09 v1, 11.6.1; a request carrying another version is not approved until the published one is recorded), statementRef; unknown fields are refused. qualitativePass is NOT accepted: "qualitativePass is recorded by our compliance function; send qualitativeAssessment and evidence." requestChannel is set by us to partner:CODE; basis and perSe are top-level fields of the request, not criteria fields. |
evidence | no | Up to 50 items {kind, description, ref} (kind at most 40 characters, TRADE_HISTORY, PORTFOLIO_PROOF, EMPLOYMENT_PROOF, STATEMENT, WARNING, DOCUMENT, NOTE, ...; description at most 4000; ref at most 200, a document id of ours or your own reference); each item is stamped with the actor label PARTNER and the time. Our officer may add evidence in our back office. |
requestReceivedAt | yes | Unix milliseconds, not in the future (5 minutes of clock skew allowed): when the client's written request reached you. |
warningShownAt, statementSignedAt | for PROFESSIONAL | The clear written warning of the protections lost, and the client's separate written statement that it is aware of the consequences; both required for a professional request on either basis ("required for a professional request (Annex II section II.2)"), optional for an opt-down. statementSignedAt lies at or after warningShownAt (the separate statement is signed after the warning, 11.6.1); a request with the statement before the warning is not approved. |
perSe | with PER_SE | {balanceSheetEur, netTurnoverEur, ownFundsEur}, non-negative decimal strings (or numbers), kept as sent with every decimal place (a figure just under a threshold never meets it), required with basis PER_SE ("perSe {balanceSheetEur, netTurnoverEur, ownFundsEur} is required with basis PER_SE."), refused with ELECTIVE ("perSe applies to basis PER_SE."). |
representativePersonId | no, legal entities | For a legal entity the person assessed is the person authorised to carry out transactions on its behalf (Annex II section II.1): name an ACTIVE REPRESENTATIVE of the entity file (9.7, kyb.persons) and we record criteria.assessedPerson as the person's name and position. Refused on a natural person ("representativePersonId applies to legal entities."). |
Validation failures answer 400 code 4000 with data.errors (a map of field paths to one sentence each, like 9.1: target, basis, criteria, criteria.qualitativePass, perSe, evidence, requestReceivedAt, ...); nothing is recorded. One request is open per client at a time: a second POST answers 409 code 4092 "a categorisation request is already open for this client" with data.requestId.
The request on the wire (every read of this section):
{
"code": 0,
"data": {
"requestId": 77, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001",
"requestedBy": "CLIENT", "requester": "PARTNER", "basis": "ELECTIVE", "from": "RETAIL", "target": "PROFESSIONAL",
"criteria": {"tradesPerQuarter": [14, 12, 9, 11], "portfolioEur": "620000", "financialSectorMonths": 30, "financialSectorRole": "derivatives trader at a bank",
"qualitativeAssessment": "the client has run a discretionary derivatives book for two years", "scope": "GENERAL", "requestChannel": "partner:NEOBROKER1", "warningVersion": "CAT-WARN-2026-09 v1", "statementRef": "sig-88a1"},
"perSe": null,
"evidence": [{"kind": "TRADE_HISTORY", "description": "quarterly trade counts from the partner's records", "ref": "acme/trades/2025Q3-2026Q2", "addedBy": "PARTNER", "addedAt": 1788300300000},
{"kind": "PORTFOLIO_PROOF", "description": "custody statement of 2026-08-31", "ref": "doc-412-7", "addedBy": "PARTNER", "addedAt": 1788300300000}],
"requestReceivedAt": 1788300000000, "warningShownAt": 1788300100000, "statementSignedAt": 1788300200000,
"status": "PENDING", "decidedBy": null, "decidedAt": null, "effectiveAt": null, "appliedAt": null, "reviewAt": null, "noticeSentAt": null,
"revokedBy": null, "revokedAt": null, "createdAt": 1788300300000, "updatedAt": 1788300300000,
"assessment": {"basis": "ELECTIVE", "tradesMet": true, "quartersMeeting": 3, "tradesTotal": 46, "tradesAverage": 11.5, "portfolioMet": true, "experienceMet": true, "quantitativeMet": 3, "quantitativePass": true,
"qualitativePass": false, "requestReceived": true, "warningShown": true, "statementSigned": true, "complete": false,
"missing": ["qualitative assessment: expertise, experience and knowledge not assessed as adequate (qualitativePass)"],
"perSe": null}
}
}
status is PENDING (with us), APPROVED (approved, the 24 hour cooling period runs until effectiveAt; the client may still withdraw), APPLIED (in force since appliedAt; reviewAt the date of our periodic review), REJECTED, WITHDRAWN or REVOKED (revokedAt). requester, decidedBy and revokedBy are role labels (PARTNER, SYSTEM, or the role of our employee: COMPLIANCE, ADMIN), never a person's name; our officer's decision reason does not cross this API. assessment is our router's evaluation of the file as recorded: quantitativePass (section II: two of three; on PER_SE two of the three size requirements, with perSe: {balanceSheetMet, netTurnoverMet, ownFundsMet, thresholdsMet} and the section II figures reading false), qualitativePass (ours; not required on PER_SE), the three procedural stamps, complete and missing (the sentences naming what our officer still needs; on PER_SE for example "per se test: 1 of 3 size requirements met, 2 required (balance sheet EUR 20,000,000, net turnover EUR 40,000,000, own funds EUR 2,000,000)").
GET /partner/v1/clients/{clientId}/categorization
The client's file: {clientId, clientUid, partnerClientRef, categorization, openRequest, appliedRequestId, reviewAt, requests: [...], annexII}, the requests newest first in the shape above, openRequest the PENDING or APPROVED one (else null), appliedRequestId and reviewAt of the professional status in force (both null while the client is RETAIL), annexII the thresholds (minTradesPerQuarter 10, minQuarters 4, minPortfolioEur 500000, minFinancialSectorMonths 12, minCriteriaMet 2, perSe: {minBalanceSheetEur, minNetTurnoverEur, minOwnFundsEur, minThresholdsMet}). An APPROVED request whose cooling period has passed is applied by our scheduler within a minute of effectiveAt (the webhook, the leverage caps and the retail protections follow the period whether or not anyone reads); this read applies it too, so a poll after the period never sees it unapplied.
POST /partner/v1/clients/{clientId}/categorization/{requestId}/withdraw
Body {reason} (required, at most 4000 characters). The client withdraws its request while it is PENDING or APPROVED and not yet applied (the cooling period): the request becomes WITHDRAWN and is answered; 409 code 4092 when it can no longer be withdrawn, 404 code 4004 for a request of another client. No webhook fires (you made the change).
What happens on our side. Our compliance officer reads the file in our back office (the queue shows the request "via NEOBROKER1"), may add evidence, records the qualitative assessment on an elective request, and decides. Approval of a professional request needs a complete file (the officer is told what is missing); the approval starts the cooling period (24 hours), within a minute of whose end the categorisation is applied by our scheduler (any read of the file applies it too), the client is mailed the notice of its new categorisation and the protections it loses or regains (to the contact address you gave us), and a review date is set (12 months). At the review date our officer confirms the status (a new reviewAt on the file, no webhook) or revokes it (the REVOKED webhook). A rejection, a revocation by our periodic review, or the client's written request through you as an opt-down request fires the same webhook. After the webhook read GET .../clients/{clientId} (the categorization in force) or the file.
| Step | Effect for you |
|---|---|
| request recorded | PENDING; pendingCategorizationRequest on the client rows (section 8), pendingCategorizationRequests on the summary |
| our approval of a professional request | APPROVED with effectiveAt; within a minute of the end of the cooling period APPLIED, the webhook client.categorization_changed {status: "APPLIED"}, categorization PROFESSIONAL on every read, the professional leverage caps and the loss of the retail protections |
| our approval of an opt-down | applied at once, the same webhook ({status: "APPLIED", to: "RETAIL"}), categorization RETAIL; the earlier applied professional request is closed on the file as REVOKED in the same step (no second webhook: the opt-down's APPLIED is the one event), so appliedRequestId and reviewAt read null |
| our rejection | REJECTED, the webhook {status: "REJECTED"}, the categorisation unchanged |
| the client's withdrawal during the cooling period | WITHDRAWN (the route above, or our back office), no webhook |
| our periodic review confirms the status | reviewAt moves forward on the file, no webhook |
| our revocation | RETAIL at once, the applied request REVOKED, the webhook {status: "REVOKED"} |
The clear written warning of the protections lost (Annex II section II.2) is OUR published text, identified by its version. You show it to the client verbatim, in a durable medium, before the client signs; warningShownAt is the time you delivered it and criteria.warningVersion its identifier. A new version is announced to you before it takes effect; a request naming another version than the one in force stays PENDING until our officer records the published one or rejects the request. The version in force is CAT-WARN-2026-09 v1:
CAT-WARN-2026-09 v1. Warning to a client who asks to be treated as a professional client. PM MTF Ltd (barriers) treats you as a retail client today. If we agree to treat you as a professional client under MiFID II, you lose the protections that apply to retail clients: (1) the retail leverage limits: the retail leverage limit of each asset class no longer applies to you; on every perpetual contract we may offer you the higher leverage of our professional leverage schedule in force at the time, published on the leverage read of your account, so a small price move can cost you a larger part of your account; (2) the guaranteed margin close-out at 50 percent: as a retail client your open positions are closed by regulation when the margin on your account falls to 50 percent of the required margin; as a professional client that guarantee no longer applies and only our own risk rules protect you; (3) the negative balance protection: as a retail client your loss can never exceed the funds on your account; as a professional client you may owe us more than you deposited; (4) the standardised risk warning and the loss-percentage disclosure that retail clients receive; (5) the information and appropriateness treatment given to retail clients: we may give you less detailed information on the products, their risks and their costs, and we may assume that you have the experience and knowledge to understand the risks of the products and services you ask for, so the appropriateness assessment applied to retail clients no longer protects you. Investor compensation and complaints: as a professional client you may lose the cover of the Investor Compensation Fund for clients of Cyprus investment firms and access to the Financial Ombudsman of the Republic of Cyprus for complaints against us. You may ask at any time, in writing, to be treated as a retail client again. Read this warning in full before you sign the separate statement.
The client's separate written statement is collected as a SEPARATE document AFTER the warning (statementSignedAt at or after warningShownAt; your signature reference in criteria.statementRef), with this wording:
I have read warning CAT-WARN-2026-09 v1. I ask PM MTF Ltd to treat me as a professional client for all services and products. I understand that I lose the protections and any investor compensation rights listed in that warning and I am aware of the consequences. I will inform PM MTF Ltd of any change that could affect my categorisation.
The statement asks for the professional treatment for ALL services and products, which is why criteria.scope must read GENERAL: a client whose written request covers particular services or products only has not asked for what the statement says, and such a request is not approved.
GET /partner/v1/texts?family=&locale=Since revision 18. The sentences of a regulated text family in the language served: today the MiFID declaration sentences, family DECLARATIONS_MIFID, the three sentences the keys of declarationsMifid (11.4) stand for. Authenticated like GET /appropriateness-test (the key, no client, no scope beyond it). family is REQUIRED and must name a sentences family we serve (an absent, unknown or other family answers 400 code 4000 with data.error TEXT_FAMILY_UNKNOWN and a sentence naming the families served); locale as on 11.1 (xx or xx-YY, _ for -, resolved to the primary language; a value that is no language tag 400 LOCALE_MALFORMED; Accept-Language is never read). The answer is served from the family's CURRENT English and ONE bundle, never a mix: the primary language requested when availableLocales lists it, else the English as a whole with localeFallback true and requestedLocale echoing what you sent. sentences[] is an ARRAY in the key order the client is shown, never a map. masterVersion is the version of the family's key set, fixed by code (1); a rewording is a new textRevision under it, announced by appropriateness_test.text_changed with family DECLARATIONS_MIFID (16.4). textHash is the hash of the words under the recipe of 11.1 (the lines of a sentences family); revision 1 hashes to sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe on every environment until our compliance function approves a later revision. Send the locale and textHash of the read you rendered as declarationsMifidText on PUT .../mifid (11.4). The caching rule of 11.1 applies PER FAMILY AND LOCALE: at most 5 minutes, drop the cache on any appropriateness_test.text_changed whose family is the family, an answer with localeFallback true never beyond minutes; render from this read when the user opens the declarations, never from a copy shipped with a release. The pseudo locale qps of section 18 is served here too on the development environment.
{
"code": 0,
"data": {
"family": "DECLARATIONS_MIFID", "masterVersion": 1, "asOf": 1790845260000,
"locale": "en", "requestedLocale": null, "localeFallback": false, "availableLocales": ["en"],
"textRevision": 1, "textHash": "sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe",
"sentences": [
{"key": "order_execution_policy_consent", "text": "I have read the Order Execution Policy of PM MTF Ltd and I consent to it. ..."},
{"key": "complex_products_risk_acknowledged", "text": "I have read the Risk Disclosure of PM MTF Ltd, including its section on complex products. ..."},
{"key": "client_categorisation_notice_acknowledged", "text": "I have received and read the Client Categorisation Notice of PM MTF Ltd. ..."}
]
}
}
The members locale, requestedLocale, localeFallback, availableLocales, textRevision and textHash mean what 11.1 says; asOf is our clock. The three sentences of revision 1 are printed in full in 11.4.
Every trading and account route delegates to the same code path as our own client API with the resolved client id, so the verification gate (403 code 4030 with missingBlocks), the TERMS re-consent gate beside it (403 code 4035 with {doc, currentVersion, gateFrom} on an opening order, a barrier request, the accept of an opening request and a swap take while our switch is on, section 10.5), the client restrictions, the market controls and every economic rule apply unchanged. Successful trading writes are attributed on the client's and the partner's audit streams. A venue transport failure answers 502 code 5020; the trading row usually stays in flight and our poll resolves it, so read the state back before retrying. A failure on our side after the venue answered answers the same way (an order state SENT, the other writes 502 code 5020 with the row id) and keeps the Idempotency-Key (section 5, revision 19). Since revision 23 a write whose outcome the venue could not confirm (its code 5000, or a 5xx without a venue code from a proxy between us and the venue: the write may still take effect) answers the same way, never as a refusal: an order state SENT, a barrier request or close request, an accept and a staking take 502 code 5020 with the row id; a cancel of an order or a request 502 code 5020 "The trading venue could not confirm whether the cancel took effect. Please check whether it is still open and cancel it again if it is.", the object unchanged until the venue's confirmation or our reconciliation moves it. The trading routes are the same under both custody models; under PARTNER custody the cash the risk engine margins is the collateral the partner reported (section 15), nothing else changes.
GET /instruments (bare: {instruments: [{symbol, family, displayName, assetClass}]}), GET /ticker?symbol ({code, msg, data: {symbol, lastPrice, markPrice, indexPrice, bestBid, bestAsk, volume24h, openInterest, fundingRate, fundingRatePct, predictedFundingRate, nextFundingTs}, asOfSeq, ts}; volume24h null since revision 27, the 24 h volume below), GET /tickers (bare: {asOf, tickers: [{symbol, family, displayName, assetClass, lastPrice, markPrice, indexPrice, bestBid, bestAsk, change24h, change24hPct, high24h, low24h, volume24h, quoteVolume24h, openInterest, fundingRate, fundingRatePct, predictedFundingRate, nextFundingTs}]}, one row per offered market built member by member, so a row carries exactly these members and none of the other members the venue's ticker holds; a market whose venue ticker failed is listed with null prices, a BARRIER or SPOT row carries null funding members and nextFundingTs 0, a SPOT row a null openInterest, quoteVolume24h is null while no 1 hour candle of the last 24 hours could be read for the market, and volume24h is null on every row since revision 27), GET /klines?symbol&interval&from&to&limit ({code: 0, data: {symbol, interval, intervalMs, klines: [{t, open, high, low, close, volume, trades, partial}], hasMore, oldestAvailable, asOf}}; intervals 1m, 5m, 15m, 30m, 1h, 4h, 1d; limit 1 to 1000), GET /orderbook?symbol&depth (bare: {symbol, bids: [[price, qty]], asks: [[price, qty]], checksum, asOfSeq, ts}; depth 1, 5, 25 or 100), GET /market-trades?symbol&limit (bare: {symbol, trades: [{tradeId, price, qty, side, flags, ts}], asOfSeq, ts}), GET /index-price?symbol (bare: {symbol, indexPrice, ts}), GET /funding?symbol (bare: {symbol, fundingRate, fundingRatePct, predictedFundingRate, predictedFundingRatePct, fundingRateAnnualizedPct, fundingIntervalHours, settleCcy, nextFundingTs, funding: {schedule, ...}}), GET /market-info?symbol (bare: the market specification: enabled (the offered-instrument row) and, since revision 10 (BROKER-84), offered (our router currently offers the market; false while our operator disabled it, in which case a new opening order, a barrier request, the accept of an open barrier request and a swap take on it answer 400 code 4000 with "This market is not currently offered." while existing exposure stays closable), grids, limits, funding schedule, risk tiers, leverage schedule, unitLabel (since revision 11 read from the venue's published tick: the quantity unit when the venue's listing makes it other than one base asset unit, 1,000 JPY on the two JPY markets once the venue lists them at tickSize 0.0001, null while the venue lists 1 JPY at 0.000001, on every other market and while the venue is unreachable; GET /instruments carries it per row), the barrier parameters knockOutRebateBps / strictTakeProfitPremiumBps / strictTakeProfitOffered / barrierPremiumBps and, on a BARRIER market, barrierExpiry {maxExplicitMs, defaultLifeMonths, venueMaxLifeHours} (the 8 h cap on an explicit barrier.expiryTs, the 12-month open-end default when it is omitted, the venue's published maximum contract life in hours that clamps the default less 60 s, null on an older venue; the block is null on every other family), the commission (commissionBasis, commissionBps, commissionMin, commissionCurrency: since revision 10, grown in place by the operator decision OQ-36 D16 and BROKER-8, commissionBasis is PREMIUM on a BARRIER market, where commissionBps is basis points OF THE PREMIUM (the maximum loss the ticket and the preview show) and commissionMin the per-CONTRACT minimum, and NOTIONAL on PERP and SPOT (basis points of qty x price, commissionMin per order); commissionBps is a decimal number, 100 on the barrier markets and 0.1 on the perpetuals) and the venue fees, and since revision 10 (BROKER-95) on a barrier market the key information documents kid and kidStrict (the normal and the strict take profit contract): each {doc, version, title, url, sha256, effectiveAt, acknowledged} or null while no version with a file is in force, doc the consent document code KID_<SYMBOL> / KID_<SYMBOL>_STRICT, url the absolute public URL of the PDF on our router (no key needed, cached five minutes, throttled per address), sha256 the file's hash, acknowledged ALWAYS null on this read (no client rides along: read the client's acknowledgement rows on GET .../clients/{clientId}/consents); both null on every other family; fetch the PDF from url, show it in your interface and record the client's acknowledgement with POST .../clients/{clientId}/consents {doc, version} before the first request on the market, 10.5), GET /trading-status ({code: 0, data: {asOf, halted, halts: [{scope, since, until, message}]}}; a halt has no end time, so until is null; a halt our desk engaged with its sweep cancelled the clients' resting opening orders in scope, the neutral message says so and the order.canceled webhooks follow from the venue's confirmations, stop-loss and reducing orders stay), GET /currencies (bare: {cash: "USDC", currencies: [{code, kind, venueAsset, displayDecimals, scale}]}), GET /staking/markets and GET /staking/offers?asset&symbol (section 12.4).
Market families: PERP (perpetual swaps, BTC-USD-PERP, SP500-USD-PERP, ...), SPOT (prefunded cash markets, BTC-USD), BARRIER (knock-out contracts by RFQ, BTC-USD-KO), STAKING (writer-first swap markets, USD-TBILL-SWAP). A symbol that is not offered answers 400 code 4000 ("A market symbol is required.") or 404 code 4004.
THE 24 H VOLUME (revision 27). The venue's ticker figure that volume24h carried is not a 24 hour volume: it is the traded base quantity of every trade since the venue's gateway process last started, and it never drops a trade older than 24 hours, so it restarts with each venue restart and can differ between venue nodes. Since this revision we serve volume24h as null on GET /ticker (every other member of data as the venue sent it) and on every row of GET /tickers, until the venue's rolling 24 hour window is live on the environment you use; that return is a later revision of this reference. quoteVolume24h on GET /tickers is unchanged: the traded value of the last 24 hours summed from the venue's 1 hour candles (candle volume x candle close), an approximation of the USD turnover, and null while no candle of that window could be read for the market. Read the traded value from quoteVolume24h, and show no base 24 hour volume while volume24h is null.
A barrier contract is bought by request for quote: the client names the market, side, size, knock-out level, optional take profit and expiry; market makers quote an entry price; the client (or the router on the client's behalf, autoAccept, default true) accepts a quote and the contract opens. The premium (the maximum loss) is a share of the entry-to-barrier distance (premiumBps); the contract ends AT the barrier (KNOCK_OUT), AT the take profit (TAKE_PROFIT), at expiry, or early by a close request.
Every step of that life reaches your webhook as well (section 16.4): rfq.quote once per live quote, rfq.accepted when the request traded, rfq.expired, rfq.canceled or rfq.rejected when it ended without trading, and barrier.closed when the contract ended; the reads of this section stay the state to converge on.
POST /clients/{clientId}/rfq (TRADE, Idempotency-Key), body RfqRequest:
| Field | Required | Rules |
|---|---|---|
symbol | yes | a barrier market, e.g. BTC-USD-KO |
side | yes | BUY (long, knock-out below the entry) or SELL (short, knock-out above); LONG / SHORT accepted |
qty | yes | decimal string on the market's lot grid (lotSize / qtyStep on GET /market-info, read from the venue and never a constant of ours: fractional on the knock-out markets since the venue lists them on the perpetual's grid, 0.0001 on BTC-USD-KO, the venue's ADR-25 of 2026-09-16, Member API 3.71, on the development environment from the date its operations announce; "1" stays valid on every grid), at least the market's minQty ("The quantity must be at least 0.0001.") and, since revision 12, worth at least the market's minNotional at the current mark (100 USD once the venue re-lists: "The request value is below the minimum for this market (100.00 USD).", refused before anything is written, no data, no rfqId, no webhook; exactly at the minimum passes; the venue's own check stays the backstop, valuing every quote at ITS entry, so size with headroom: the lot ceiling of minNotional / (mark x (1 - barrierGuard.entryBandBps / 10000)), 0.0014 BTC at 76,000 with the 50 bps band) |
premium | no | the premium the client typed when the ticket was premium-sized; the request's budget every quote is measured against |
barrier.level | yes | the knock-out price, on the tick grid, at least 0.1 percent from the current price |
barrier.expiryTs | no | Unix ms, at most 8 hours after our clock at receipt (exactly 8 h passes; beyond it 400 code 4000 "The expiry can be at most 8 hours ahead, or leave it out for an open-end contract."); omitted = the open-end contract: 12 calendar months, never rolled over (expiryDefaulted true) |
barrier.takeProfit | no | a price on the profit side of the entry; omit for a knock-out-only contract |
barrier.strictTakeProfit | no | default false; pays only on a touch of the take profit, needs takeProfit, an explicit expiryTs at most 8 h ahead (the binding cap; "Strict take profit needs an expiration at most 8 hours ahead." since revision 10, BROKER-96), a market that offers it, and, for every client whatever its categorisation, the acknowledged current Risk Disclosure (403 code 4035 RISK_DISCLOSURE_CONSENT_REQUIRED otherwise, on the preview too; BROKER-96) and, for a RETAIL client only, the strict key information document KID_<SYMBOL>_STRICT (403 code 4035 KID_REQUIRED, section 4 and 10.5; PROFESSIONAL and ECP clients are outside the KID gate) |
expiresInMs | no | the quoting window, default 60000, 1 s to 24 h |
autoAccept | no | default true: the router accepts the first acceptable live quote itself |
Two consent refusals of the request since revision 10 (BROKER-95, BROKER-96; section 4; both 403 code 4035 with a distinct data.error, both over FIX as 297=5 9110=4035 with the sentence in Text(58)): the KEY INFORMATION DOCUMENT gate, on the opening request of a RETAIL client only (the preview, the accept, the cancel and the early close are never gated; a PROFESSIONAL client passes): without an acknowledgement of the market's current document (kid of 12.1, kidStrict on a strict request) the answer is {code: 4035, msg: "Please read and acknowledge the key information document for this market (KID_BTC_USD_KO, version 2026-09) before your first request.", data: {error: "KID_REQUIRED", doc: "KID_BTC_USD_KO", currentVersion: "2026-09", symbol: "BTC-USD-KO", strict: false}}, and while no document is published at all msg: "The key information document for this market is not available yet, so this contract cannot be requested by retail clients." with currentVersion null (a strict request without the strict document reads this with doc the _STRICT code); record the acknowledgement with POST .../consents {doc, version} (10.5) after showing the PDF from url. The STRICT CONSENT gate, on a strict take profit request and its preview: while the client's standing on RISK_DISCLOSURE is older than the version in force the answer is {code: 4035, msg: "Accept the current Risk Disclosure before requesting a strict take profit contract.", data: {error: "RISK_DISCLOSURE_CONSENT_REQUIRED", doc: "RISK_DISCLOSURE", currentVersion: "2026-09"}}; a normal request is never gated; inert while no version of the document is registered; record the acceptance with the same POST (our CRM can too). Since revision 10 (section 15.7) an opening request and the accept of an opening request are also refused with 403 code 4041 PARTNER_CLOSE_ONLY (data: {error: "PARTNER_CLOSE_ONLY"}, the sentence in msg) while your clients are close-only under the settlement tier; a close request and its accept never.
Answer 200 {code: 0, data: <rfq>}:
{
"code": 0,
"data": {
"rfqId": 311, "clientId": 412, "clientUid": "BARR-00000412", "venueRfqId": 5207, "clOrdId": "BARR-RFQ-311",
"symbol": "BTC-USD-KO", "side": "BUY", "direction": "LONG", "qty": "1.0", "barrierLevel": "63000.0", "takeProfit": "66500.0",
"expiryTs": 1819843200000, "expiryDefaulted": true, "expiresAt": 1788311460000, "expiresInMs": 59200,
"state": "OPEN", "createdAt": 1788311400000, "updatedAt": 1788311400000, "rejectReason": null,
"accepting": false, "autoAccept": true, "autoAcceptNote": null, "closeContractId": null, "contractEntry": null,
"acceptedQuoteId": null, "contractId": null, "entryPrice": null,
"commission": "0.000000", "maxLossAtRequest": "2222.130000", "premium": "2222.130000", "markAtRequest": "65469.0",
"impliedLeverage": "26.51", "potentialProfit": "1031.000000", "strictTakeProfit": false, "premiumBps": 9000,
"quotes": [], "currency": "USDC",
"channel": "REST", "channelKeyId": null, "channelRef": null
}
}
channel, channelKeyId and channelRef (additive, revision 9): the transport that made the request, REST for every request of these routes and FIX for a request entered on a FIX session (section 20, docs/14 section 7), with the key of that session (the keyId of GET /me) and the QuoteReqID(131) the session sent; REST with two nulls otherwise. An early close requested over FIX carries them the same way.
Request states: OPEN, ACCEPTED (a contract opened, contractId, entryPrice, acceptedQuoteId), CANCELLED, EXPIRED, REJECTED (rejectReason). Errors: 400 code 4000 (the barrier floor "The barrier must be at least 0.1 percent away from the current price (1000x maximum).", the lot grid, the minimum quantity and, since revision 12, the minimum request value "The request value is below the minimum for this market (100.00 USD)." (refused before anything is written: no data, no rfqId, no rfq.rejected webhook; the preview answers accepted: false with the sentence as reason), the window, the account state, the free-margin check), 403 code 4030, 404 code 4004 ("This market is not offered."), 502 code 5020 (data.rfqId names the row, it stays OPEN and the poll resolves it). A request the VENUE refused for its minimum (the mark moved between our check and the venue, or a quote priced below the mark, since the venue values the request at the mark but every quote at its entry) ends REJECTED with the same sentence as rejectReason, 400 code 4000 with data {rfqId, state: "REJECTED"} and the rfq.rejected webhook, as any venue refusal. A fractional request on the served grid is an ordinary request: {"symbol": "BTC-USD-KO", "side": "BUY", "qty": "0.05", "barrier": {"level": "63000"}} answers the <rfq> above with qty: "0.05" and the per-unit figures scaled by 0.05 (premium: "111.106500" at the example's mark), while the same body with qty: "0.001" (65.47 USD at that mark) answers {"code": 4000, "msg": "The request value is below the minimum for this market (100.00 USD).", "error": "The request value is below the minimum for this market (100.00 USD)."} and writes nothing.
POST /clients/{clientId}/rfq/preview: the same body, nothing written; {accepted, reason, mark, maxLoss, premium, estimatedCommission, estimatedCloseCommission, requiredFreeMargin, freeMarginBefore, freeMarginAfter, impliedLeverage, potentialProfit, maxLeverage, expiryTs, expiryDefaulted, strictTakeProfit, premiumBps, knockOutRebateBps, strictTakeProfitPremiumBps, strictTakeProfitOffered, currency}. estimatedCommission is the client's BARRIER scheme applied to the PREMIUM (basis points of the premium, ceiled to the micro, at least the per-contract minimum; revision 10, OQ-36 D16), estimatedCloseCommission the early-close commission estimated at the same reference and reserved with the contract while it is open (revision 10, BROKER-14), and requiredFreeMargin = premium + estimatedCommission + estimatedCloseCommission. A venue transport failure while the preview reads the market's live facts answers 502 code 5020 (nothing was written; retry); POST .../margin/preview (section 12.3) answers the same (revision 6).
GET /clients/{clientId}/rfq: the client's live requests with their quotes ({rfqs: [<rfq with quotes>]}). A quote: {quoteId, price, qty, validUntil, validForMs, impliedMaxLoss, impliedPremium, impliedLeverage, impliedPotentialProfit, impliedPnl, state, acceptable, notAcceptableReason}; quote states LIVE, EXPIRED, WITHDRAWN (the maker pulled it), ACCEPTED (the one that traded), SUPERSEDED (still valid when another quote of the request was accepted).
GET /clients/{clientId}/rfq/{rfqId}: one request.
POST /clients/{clientId}/rfq/{rfqId}/accept (TRADE, Idempotency-Key), body {quoteId}: the manual accept when autoAccept was false. The pre-trade check runs again at the quoted entry, the accept goes to the venue and the contract opens:
{
"code": 0,
"data": {"rfqId": 311, "venueRfqId": 5207, "quoteId": 902, "contractId": 4711, "tradeId": 771310, "symbol": "BTC-USD-KO", "side": "BUY", "direction": "LONG", "qty": "1.0", "entry": "65471.5", "barrierLevel": "63000.0", "takeProfit": "66500.0", "expiryTs": 1819843200000, "maxLoss": "2224.350000", "premium": "2224.350000", "leverage": "26.49", "potentialProfit": "1028.500000", "commission": "22.243500", "strictTakeProfit": false, "premiumBps": 9000, "ts": 1788311428000, "currency": "USDC"}
}
400 code 4000 when the request is not open, an accept is in flight or the quote is not acceptable, and, since revision 10 (BROKER-84), 400 code 4000 with "This market is not currently offered." on the accept of an OPENING request whose market our operator withdrew (manual and automatic; over FIX 35=j 380=0 with 9110=4000; a close request and its accept never); 403 code 4041 PARTNER_CLOSE_ONLY on the accept of an opening request while your clients are close-only under the settlement tier (15.7; the accept of a close request is never refused for it); 404 code 4004 for an unknown request or a gone quote; 502 code 5020 with data: {rfqId, quoteId, accepting: true} (the poll resolves it).
POST /clients/{clientId}/rfq/{rfqId}/cancel (TRADE): {rfqId, state: "CANCELLED"}.
GET /clients/{clientId}/barriers?state&limit&before: state open (default) or history; limit 1 to 500; {barriers: [...], hasMore, nextBefore, currency}. An open contract is valued live:
{
"contractId": 4711, "symbol": "BTC-USD-KO", "side": "LONG", "qty": "1.0", "entry": "65471.5", "barrierLevel": "63000.0", "takeProfit": "66500.0",
"expiryTs": 1819843200000, "expiresInMs": 31531772000, "state": "OPEN", "openedAt": 1788311428000, "closedAt": null, "settlePrice": null, "pnl": null,
"mark": "65612.0", "markFresh": true, "distanceAbs": "2612.0", "distancePct": "3.98", "distanceToTpAbs": "888.0", "distanceToTpPct": "1.35",
"unrealizedPnl": "140.500000", "requirement": "2083.850000", "maxLoss": "2224.350000", "premium": "2224.350000", "potentialProfit": "1028.500000", "leverage": "26.49",
"commission": "22.243500", "closeCommission": null, "estimatedCloseCommission": "22.243500", "notional": "65612.000000", "venueRfqId": 5207, "rfqId": 311, "closeReason": null, "closeRfqId": null,
"strictTakeProfit": false, "premiumBps": 9000, "currency": "USDC"
}
Terminal states: KNOCKED_OUT (at the barrier), TAKE_PROFIT (at the take profit), EXPIRED, EARLY_CLOSE (closed by an accepted close request at the quoted price), TORN_UP (a venue liquidation tear-up), CLOSED (any other termination), with settlePrice, pnl and closeReason (knockOut, takeProfit, expiry, earlyClose, liquidation, finalSettlement). Since revision 10 (BROKER-14) every row carries closeCommission (our early-close commission, set on an EARLY_CLOSE row, 0.000000 on every other terminal row, null while the contract is open) and an OPEN row estimatedCloseCommission (the early-close commission reserved beside the requirement, estimated at the entry; null on a terminal row); commission is the opening commission, basis points of the premium at the client's BARRIER scheme with the per-contract minimum (OQ-36 D16).
POST /clients/{clientId}/barriers/{contractId}/close (TRADE, Idempotency-Key), body {autoAccept?, expiresInMs?} (both optional): a close request to the contract's writer; quotes are acceptable strictly inside the contract's terms; the accept settles the contract EARLY_CLOSE at the quoted price. Since revision 10 (BROKER-14, the operator's decision of 2026-09-09 on OQ-43) the accept of a close request charges our EARLY-CLOSE COMMISSION: the client's BARRIER scheme applied to the premium at the accepted close price (the same rate and per-contract minimum as the entry; the close request's commission, the accept answer's closeCommission, the contract's closeCommission, one COMMISSION settlement item with refs {contractId, symbol, reason: "earlyClose", closeTradeId} dated at the close), reserved while the contract is open as estimatedCloseCommission; a knock-out, take profit, expiry or venue close carries no commission. Answer: the close request as an <rfq> with closeContractId and contractEntry set. 400 code 4000 when the contract is not open or a close request is already open (data.rfqId names it), 404 code 4004 for a foreign contract. Since revision 12 (the venue's ADR-25): the venue values a close request and the writer's close quote at the contract's WHOLE quantity against the market's minNotional, so a contract worth less than the minimum at the current mark cannot be closed early and ends at its barrier, its take profit or its expiry; such a close request ends REJECTED with "This contract is worth less than the market minimum (100.00 USD) at the current mark; it ends at its barrier, its take profit or its expiry." as rejectReason (400 code 4000 with data {rfqId, state: "REJECTED"}, the rfq.rejected webhook); we run no check of our own on a close, so the sentence disappears by itself should the venue exempt closes from the floor; termination and settlement are never floored.
The margin picture, the pre-trade preview and the per-market leverage choice are the same bodies as our client API's GET /v1/margin, POST /v1/margin/preview and GET | PUT /v1/leverage, so a partner reads exactly what its client would see in our portal. Money figures are USDC strings with 6 places.
GET /clients/{clientId}/margin: (bare) the live margin picture:
{
"clientId": 412, "equity": "10009.800000", "cash": "10000.000000", "unrealizedPnl": "9.800000", "reserved": "0.000000",
"initialMargin": "120.000000", "maintenanceMargin": "60.000000", "marginCallLevel": "120.000000", "positionMargin": "120.000000", "orderMargin": "0.000000",
"freeMargin": "9889.800000", "marginLevel": "8341.50", "riskState": "NORMAL", "categorization": "RETAIL", "marksFresh": true,
"closeOutPct": "50", "marginCallPct": "100",
"positions": [
{"symbol": "BTC-USD-PERP", "netQty": "0.01", "avgEntryPrice": "60000", "mark": "60980", "markFresh": true, "unrealizedPnl": "9.800000",
"initialMargin": "120.000000", "roiPct": "8.17", "leverage": 5, "realizedPnl": "0.000000", "estLiquidationPrice": null}
],
"openOrders": 0,
"barrierMargin": "0.000000", "barrierUnrealizedPnl": "0.000000", "barrierMarksFresh": true, "pendingAcceptMargin": "0.000000", "closeOutEquity": "10009.800000",
"barriers": []
}
riskState is NORMAL, MARGIN_CALL or CLOSE_OUT; marginLevel is the equity as a percentage of the total initial margin (barrier requirements included; null without margin), marginCallPct and closeOutPct the percentages of the PERPETUAL margin (positionMargin + orderMargin) at which the account enters margin call and close-out (100 and 50 by default: the close-out at half the margin required is the regulatory margin close-out), each the value that applies to this client, never below the brokerage value (a partner may agree a tighter level for its clients with us; it is set by our back office, never on this API), maintenanceMargin the close-out level in money and marginCallLevel (additive, revision 10) the margin call level in money, (positionMargin + orderMargin) x marginCallPct / 100, the same figure the margin.call event carries, so a partner never derives it; the two are compared against closeOutEquity (docs/13 section 2.5). A position's initialMargin (and so positionMargin) is |netQty| x max(mark, avgEntryPrice) / leverage on a leveraged position since 11 September 2026 (revision 10 grown in place, values only): never below the margin required to open it, rising with the mark as before, so a losing long is held at its opening margin and a winning short no longer frees margin as the mark falls; roiPct and estLiquidationPrice follow (docs/13 2.2 and 2.6). A fully funded position (leverage 1) keeps |netQty| x mark. closeOutEquity is the conservative equity our close-out test compares (open barrier profits excluded); barriers lists the open barrier contracts in the shape of section 12.2 (each with estimatedCloseCommission, revision 10) and barrierMargin their requirement plus the reserved early-close commissions (inside initialMargin). Under PARTNER custody cash is the collateral the partner reported, less what the client has lost and paid since, exactly as our ledger holds it. 503 code 5030 when the margin service is not attached. The transitions of riskState reach your webhook as margin.call, margin.call_cleared, margin.close_out_started and margin.close_out_done, each with the figures of this read at the transition (section 16.4); this read stays the source of the live numbers between events and the fallback when a delivery failed for good.
The written specification of the risk engine behind these figures (the arithmetic, the parameters, the state machine, the cadence and latency, the close-out algorithm, stale marks, negative balance protection and what the partner sees at each step) is docs/13-risk-engine-for-partners.md.
POST /clients/{clientId}/margin/preview: (bare) the pre-trade check of an order, nothing written. Body {symbol, side, type, tif?, qty, price?, trigger?, reduceOnly?, leverage?} (the order shape of the orders route below; leverage previews at that leverage instead of the stored choice and is not persisted):
{
"accepted": true, "reason": null, "reservation": null, "orderValue": "600.000000", "marginRequired": "120.000000", "leverage": 5,
"freeMarginBefore": "10000.000000", "freeMarginAfter": "9880.000000", "marginLevelAfter": null, "estLiquidationPrice": null,
"equityBefore": "10000.000000", "initialMarginBefore": "0.000000", "initialMarginAfter": "120.000000",
"estimatedCommission": "0.300000", "slippageAllowance": "0.000000", "estimatedCosts": "0.300000"
}
accepted: false carries the refusal sentence in reason, the same sentence the orders route would answer (since revision 26 also the conditional limit band below, with the after figures null). A malformed shape answers 400 code 4000 with accepted: false; a market that is not offered 404 code 4004. On a spot market reservation names what the order would reserve.
GET /clients/{clientId}/leverage?symbol: (bare) the client's leverage on one perpetual market, {symbol, leverage, cap, categorization, chosen}: cap is the schedule's maximum for the client's categorisation on the environment you are connected to; read it from this route (on the development environment today RETAIL 10x on the crypto perpetuals, 20x on the index, metal and energy perpetuals, 30x on the FX perpetuals, PROFESSIONAL 50x; production publishes its figures at go-live), chosen the client's stored choice (null until one is set) and leverage the effective figure (the choice, else the cap). 400 code 4000 without symbol, 404 code 4004 for a market that is not offered.
PUT /clients/{clientId}/leverage (TRADE, Idempotency-Key; POST is accepted with the same body), body {symbol, leverage, force?}: stores the choice and answers the same view. 400 code 4000 above the cap or not a whole number; 404 code 4004 for a market that is not offered; 409 code 4090 when the change would need more initial margin than the account holds (data: {wouldBreach: true, initialMarginAfter, maintenanceMarginAfter, freeMarginAfter, marginLevelAfter, riskStateAfter}; repeat with force: true to apply it anyway). The stored leverage is what the orders route margins at; an order carrying a leverage hint that no longer matches the stored choice answers 409 code 4091. While your server-side order entry is CLOSED (22.10) this route, POST .../orders and DELETE .../orders/{orderId} answer 403 code 4043 SERVER_ORDER_ENTRY_CLOSED (revision 21), and the same changes come from your app's order commands (section 22).
POST /clients/{clientId}/orders (TRADE, Idempotency-Key), body PlaceOrderRequest:
| Field | Required | Rules |
|---|---|---|
symbol | yes | a PERP or SPOT market |
side | yes | BUY or SELL |
type | yes | LIMIT, MARKET, STOP_MARKET, STOP_LIMIT, TAKE_PROFIT_MARKET, TAKE_PROFIT_LIMIT (no trailing stops) |
qty | yes | decimal string on the lot grid |
price | LIMIT | on the tick grid |
tif | no | GTC (default), IOC, FOK, POST_ONLY (LIMIT only) |
reduceOnly | no | the order must reduce the position; not on spot |
trigger | conditional types | {source: LAST | MARK | INDEX, price, direction: AT_OR_ABOVE | AT_OR_BELOW, limitPrice}; since revision 26 limitPrice (the *_LIMIT types) must lie within the market's price band around the price at which the order becomes a limit order (below the table) |
attach | no | LIMIT and MARKET parents: {takeProfit: {price, limitPrice?}, stopLoss: {price, limitPrice?}}; since revision 26 a leg's limitPrice must lie within the market's price band around that leg's price |
ocoGroup | no | conditional reduce-only orders, 1 to 40 characters [A-Za-z0-9:_-] |
leverage | no | the leverage hint the order was prepared at; a mismatch answers 409 code 4091 |
Answer (bare) {orderId, clOrdId, state} on acceptance ({"orderId": 4182, "clOrdId": "BARR-4182", "state": "ACKED"}; state SENT when the venue's acknowledgement was lost in transport or could not be recorded on our side, or, since revision 23, the venue answered that it could not confirm the order: the order may be live, read it back, and the key replays this answer, section 5); 400 {code: 4000, msg, error, orderId, clOrdId, state} on a refusal (orderId null when nothing was written); 403 code 4030 on the verification gate; 403 code 4041 PARTNER_CLOSE_ONLY on an opening order while your clients are close-only under the settlement tier (15.7; the same bare fields with orderId null and data: {error: "PARTNER_CLOSE_ONLY"}; a reduce-only order and an order within the reduce capacity pass); 409 code 4091 {leverageSent, leverageStored}.
THE CONDITIONAL LIMIT BAND (revision 26). The venue checks a plain LIMIT price against the market's price band but checks neither the trigger nor the limit of a conditional order, so a triggered limit order could rest or trade far from the market, or sweep the book past the venue's market-order protection. We therefore refuse, before anything is written and before the venue, a STOP_LIMIT or TAKE_PROFIT_LIMIT whose trigger.limitPrice lies outside the market's band (priceBandBps on GET /market-info, 10 percent on the crypto perpetuals today; a market that publishes none has no band) around the price at which the order becomes a limit order: its trigger.price, or the current mark when the mark has already reached the trigger (AT_OR_ABOVE: the mark at or above it; AT_OR_BELOW: at or below it). The band is floor(reference x priceBandBps / 10000) in whole price steps, both bounds inside. 400 code 4000 with orderId null and the sentence, for example "The limit price 1 is outside the price band of this market: this order becomes a limit order at its trigger price 64000, so its limit price must lie within 10 percent of the trigger price, from 57600 to 70400." (with the mark: "The limit price 96000 is outside the price band of this market: the mark price 60000 has already reached this order's trigger price, so its limit price must lie within 10 percent of the mark, from 54000 to 66000."). The trigger price itself is not bounded: a stop loss or take profit far from the market with its limit near its trigger is accepted. Reduce-only orders are checked too, and on a market that publishes a band a conditional limit order, reduce-only or not, needs a fresh mark ("There is no fresh mark price for BTC-USD-PERP right now. Please try again shortly."; a STOP_MARKET or TAKE_PROFIT_MARKET reduce-only order does not, so a position can always be protected); while the venue's instrument list cannot be read it is refused with "This market's specification is unavailable right now. Please try again shortly.". An attach leg with a limitPrice is checked against the band around its own price when the parent order is placed ("The stop loss limit price 1 is outside the price band of this market: it must lie within 10 percent of its trigger price 55000, from 49500 to 60500."); the legs we place once the parent filled are never refused by it. Derive a conditional limit from its trigger and the market's priceBandBps at run time, never as a fixed offset from today's price. The preview answers the same sentence as accepted: false.
Spot markets are prefunded: a BUY reserves the worst-case quote cash plus commission, a SELL the asset; they refuse reduceOnly, attach, ocoGroup and market-type conditional buys. The order's life reaches your webhook as order.accepted, order.partially_filled, order.filled, order.canceled and order.rejected, each carrying the order in the shape below plus the fill (section 16.4).
DELETE /clients/{clientId}/orders/{orderId} (TRADE): (bare) {orderId, clOrdId}; 404 code 4004 for a foreign order, 400 code 4000 when the order is terminal or the venue refused; 502 code 5020 when the venue could not be reached and, since revision 23, when the venue could not confirm the cancel (the sentence of section 12, the order unchanged until the venue's confirmation arrives; a cancel may be sent again at once).
GET /clients/{clientId}/orders/open: {code: 0, data: [<order>]}. GET /clients/{clientId}/orders?limit&before: (bare) {orders, hasMore, nextBefore}, limit 1 to 500. An order:
{
"orderId": 4185, "clOrdId": "BARR-4185", "symbol": "BTC-USD-PERP", "family": "PERP", "side": "BUY", "type": "LIMIT", "qty": "0.5", "price": "64250.0",
"state": "FILLED", "reject": null, "filledQty": "0.5", "createdAt": 1788311500000, "reduceOnly": false, "tif": "GTC", "trigger": null, "updatedAt": 1788311600120,
"attach": {"takeProfit": {"price": "66000.0"}, "stopLoss": {"price": "62800.0"}, "legs": [4186, 4187]},
"ocoGroup": null, "parentOrderId": null, "parentClOrdId": null, "origin": "CLIENT", "note": null, "replacedBy": null, "replaces": null, "reservation": null,
"channel": "REST", "channelKeyId": null, "channelRef": null
}
channel, channelKeyId and channelRef (additive, revision 9): the transport that placed the order, REST for every order of these routes and FIX for an order entered on a FIX session (section 20), with the key of that session (the keyId of GET /me) and the ClOrdID(11) the session sent; since revision 21 APP for an order your app placed directly with an app order command (section 22: the place, and the new order of a replace), with channelKeyId null and channelRef the jti of the token that carried the command, our record of it (22.11; it is never a reference of yours, and over FIX such an order carries no ClOrdID(11)); the take profit and stop loss legs of an attached order read APP with channelRef null; REST with two nulls otherwise. An app order's origin is CLIENT, like every order the client instructs. A flatten of our risk engine reads origin: "CLOSE_OUT" with channel: "REST"; so does the forced sale of a client's spot holdings after a close-out left its cash negative (docs/13 section 8): a SELL, MARKET, IOC order on the spot market in the client's name with origin: "CLOSE_OUT" and family: "SPOT", reduceOnly false, prefunded in the asset like any spot sale and settled by its fill like one; it runs for a client only once that client has accepted the Terms version carrying the right-of-sale clause, our configuration naming that version and the sale never running while it names none (revision 10).
GET /clients/{clientId}/positions: (bare) {positions: [{symbol, netQty, avgEntryPrice, netCost, realizedPnl, updatedAt}]}. GET /clients/{clientId}/trades?limit&before: (bare) {trades: [{id, venueTradeId, orderId, clOrdId, symbol, family, side, price, qty, commission, ts, realizedPnl, notional, asset}], hasMore, nextBefore}. GET /clients/{clientId}/position-history?limit&before: (bare) {items: [{id, orderId, clOrdId, symbol, side, qty, price, realizedPnl, ts}], hasMore, nextBefore} (the fills that realised P&L).
The sequence our tests pin for a PARTNER-custody client at level MICA_MIFID with 10000 USDC of reported collateral (section 15.1) and the mark of BTC-USD-PERP at 60000:
GET .../leverage?symbol=BTC-USD-PERP answers {"symbol": "BTC-USD-PERP", "leverage": 10, "cap": 10, "categorization": "RETAIL", "chosen": null}.PUT .../leverage {"symbol": "BTC-USD-PERP", "leverage": 5} answers {"symbol": "BTC-USD-PERP", "leverage": 5, "cap": 10, "categorization": "RETAIL", "chosen": 5}; {"leverage": 50} answers 400 code 4000 (above the cap).POST .../margin/preview {"symbol": "BTC-USD-PERP", "side": "BUY", "type": "LIMIT", "qty": "0.01", "price": "60000"} answers accepted: true, leverage: 5, marginRequired: "120.000000" (600 of notional at 5x), freeMarginBefore: "10000.000000".POST .../orders with the same body answers {"orderId": 4185, "clOrdId": "BARR-4185", "state": "ACKED"}; GET .../orders/open lists it; DELETE .../orders/4185 answers {"orderId": 4185, "clOrdId": "BARR-4185"} and the order leaves the open list once the venue acknowledges the cancel (state PENDING_CANCEL until then).POST .../orders {"symbol": "BTC-USD-PERP", "side": "BUY", "type": "MARKET", "qty": "0.01"} fills at 60000: GET .../positions shows {"symbol": "BTC-USD-PERP", "netQty": "0.01", "avgEntryPrice": "60000", ...} and GET .../margin carries the position with its initialMargin of 120.POST .../orders {"symbol": "BTC-USD-PERP", "side": "SELL", "type": "MARKET", "qty": "0.01", "reduceOnly": true} fills at 61000: the position is flat, GET .../trades lists both fills with realizedPnl "10.000000" on the closing one, and, under PARTNER custody, exactly one settlement item of type PNL for +10.000000 plus one COMMISSION item per fill appear on GET /partner/v1/settlement/items (section 15.3).Every write of the sequence is attributed on the client's audit stream ("order placed for client BARR-00000412 by partner NEOBROKER1 key pbk_1f9c2d..."). Before MICA_MIFID, step 4 answers 403 code 4030 with missingBlocks naming the MiFID blocks and nothing is written; the barrier lifecycle of section 12.2 (preview, request, quote, accept, knock-out) runs through the same gate and the same collateral.
Writer-first swap markets: a writer posts an offer (rate, tenor, size); the client takes a size, the principal leaves the account at the take and comes back with the reward at maturity, paid by the writer. The writer's obligation is UNSECURED (the disclosure on every answer must be shown to the client); a writer who fails to pay leaves a claim (swap.defaulted).
GET /staking/markets: {markets: [{symbol, name, asset, venueAsset, enabled, minTenorDays, maxTenorDays, maxRateBps, maxRatePct, minQty, qtyStep, maxQty, takerFeeRate, displayDecimals, commissionRewardBps, family, usualTenors}], disclosure, commissionRewardBps}. name is the product name of the market (USD, EUR, BTC, ETH, SOL and USD1 Fixed Yield Contract since revision 10; the same name rides on the offer rows, the swap records and the take answer). takerFeeRate is the broker's effective venue taker rate on the market (the venue's listed rate, or the member's measured tier or programme rate): the rate a take reserves for and the fee leg is posted at, so a preview computed from it matches the charge. usualTenors (revision 10) is the list of terms writers usually offer on the market, in whole days ascending, always present and [] when the market lists none; it is a presentation hint (a writer may offer any term inside minTenorDays to maxTenorDays).
GET /staking/offers?asset&symbol: {offers: [{offerId, symbol, name, asset, venueAsset, rateBps, ratePct, tenorDays, qty, remainingQty, minQty, expiryTs, createdTs, rewardPerUnit, commissionRewardBps, offered}], asOf, stale, disclosure, commissionRewardBps}. The board is polled from the venue every 2 s and cached 3 s. offered (revision 10, BROKER-84) is false while our operator disabled the offer's market: the offer stays listed, a take of it answers 400 code 4000 with "This market is not currently offered.", and GET /staking/markets omits the market meanwhile.
POST /clients/{clientId}/staking/{offerId}/accept (TRADE, Idempotency-Key), body {qty} (in the market's asset, on the lot grid): the gates, the reservation in the asset, the venue take, the principal legs. Answer {code: 0, data: <swap>}:
{
"code": 0,
"data": {
"swapId": 88, "contractId": 412, "tradeId": 771412, "offerId": 6103, "symbol": "USD-TBILL-SWAP", "name": "USD Fixed Yield Contract", "asset": "USDC", "venueAsset": "USD", "displayDecimals": 2,
"qty": "10000", "notional": "10000.00", "rateBps": 450, "ratePct": "4.50", "tenorDays": 91, "reward": "112.19", "fee": "2.00", "expectedPayout": "10112.19",
"state": "OPEN", "openedTs": 1788311700000, "maturityTs": 1796174100000, "closedTs": null,
"paid": "0.00", "outstanding": "0.00", "claim": "0.00", "writtenOff": "0.00", "recovered": "0.00",
"commission": "0.00", "commissionRewardBps": "1000", "estimatedCommission": "11.22", "settleReason": null, "rejectReason": null, "ts": 1788311700000, "createdAt": 1788311700000
}
}
The broker commission is a share of the reward (commissionRewardBps), frozen at the take and charged at the close. Errors: 400 code 4000 (size rules, a disabled market, the balance short of notional plus fee), 403 code 4030, 403 code 4041 PARTNER_CLOSE_ONLY (a take while your clients are close-only under the settlement tier, 15.7), 404 code 4004 (the offer left the board), 502 code 5020 (the row stays ACCEPTING, the poll resolves it).
GET /clients/{clientId}/staking/swaps?state&limit&before (state live default, history, all): {swaps, hasMore, nextBefore}. GET /clients/{clientId}/staking/swaps/{swapId}: one swap by the venue contract id (the router's swap id is accepted when no contract of the client carries that id). Swap states: ACCEPTING (the take is at the venue), OPEN, MATURED (paid in full at maturity), DEFAULTED (the writer was short at maturity; claim is what the client can still expect, later payments cure it), CURED (a defaulted swap paid in full later), FINAL_SETTLEMENT (the market was delisted and the venue settled the contract), REJECTED; settleReason matured, default, cured, finalSettlement.
The reads of this section name the client by the route alone and do not repeat its identifiers. Of the answers that do name the client, the margin read of 12.3 (GET /clients/{clientId}/margin) and the statement rows of section 17 carry clientId only, without clientUid or partnerClientRef (revision 25, section 7): key them by clientId.
GET /clients/{clientId}/balances: (bare) {balances: [<row>], portfolioValue, portfolioCurrency, portfolio}, one row per currency, the USDC cash row first with the margin figures:
{
"balances": [
{"currency": "USDC", "kind": "CASH", "cash": "9800.000000", "reserved": "125.000000", "total": "9925.000000", "free": "9800.000000", "displayDecimals": 2, "venueAsset": "USD",
"equity": "10250.410000", "unrealizedPnl": "450.410000", "freeMargin": "9100.250000", "mark": null, "markFresh": null, "value": "9925.000000", "valueCurrency": "USDC",
"staked": "1000.000000", "swapClaims": "0.000000", "stakedValue": "1000.000000", "claimsValue": "0.000000", "valuationBasis": "CASH"},
{"currency": "BTC", "kind": "ASSET", "cash": "0.05000000", "reserved": "0.00000000", "total": "0.05000000", "free": "0.05000000", "displayDecimals": 8, "venueAsset": "BTC",
"equity": null, "unrealizedPnl": null, "freeMargin": null, "mark": "65000", "markFresh": true, "value": "3250.000000", "valueCurrency": "USDC",
"staked": "0.00000000", "swapClaims": "0.00000000", "stakedValue": null, "claimsValue": null, "valuationBasis": "MARK"}
],
"portfolioValue": "14500.410000", "portfolioCurrency": "USDC",
"portfolio": {"cashTotal": "9925.000000", "reserved": "125.000000", "withdrawalHolds": "0.000000", "holdingsValue": "3250.000000", "stakedValue": "1000.000000", "unrealizedPnl": "450.410000", "claimsValue": "0.000000", "holdingsValued": true, "currency": "USDC", "valuationBasis": "MARK"}
}
Every row carries valuationBasis (revision 10): CASH on the cash row, MARK on an asset valued at the spot or perpetual mark, PAR on a currency our registry values at 1.000000 USDC per unit without a market price (mark null, markFresh true, value, stakedValue and claimsValue at par; such a value may not be realisable), NONE on an asset without a mark (counted zero); the portfolio block and the overview root carry the aggregate over the non-cash rows (MARK, PAR or MIXED). The staked principal is at full value and unsecured (out with writers, at risk): the principal STILL out with writers, so a partial return already in the client's cash is not counted twice (since 2026-09-12, the code audit of 2026-09-11, B-472; a value rule, the staked and stakedValue members unchanged in shape); the claims are apart (owed, not paid); no currency is PAR yet. portfolioValue is the ONE figure of GET .../account/overview and the equity history: the margin equity (USDC cash and reserved plus the unrealised P&L) plus the valued holdings plus the staked principal (revision 11: computed from the same margin snapshot on both routes; before, this route summed the USDC total, so a pending USDC withdrawal request stayed inside it while the overview kept it out). A pending USDC withdrawal's reservation (a REQUESTED request, the amount held in reserved) is outside portfolioValue on both routes and outside equity; it returns to the figure when the request is rejected or released and leaves the account when it is approved. The portfolio block carries it additively as withdrawalHolds (revision 11), so that cashTotal - withdrawalHolds + holdingsValue + stakedValue + unrealizedPnl = portfolioValue and cashTotal keeps meaning the USDC row's total.
GET /clients/{clientId}/account/summary: {code: 0, data: {currency, realizedPnl, commissionPaid, fundingPaid, tradeCount, since, sinceTs, realizedTotal, realizedByFamily: {fills, spot, barrier, staking}, commissionByFamily: {fills, barrier, staking}, counts: {fills, barrierContracts, swapTakes}}} (lifetime figures).
GET /clients/{clientId}/account/overview: {code: 0, data: {...}}, the whole portfolio in one round trip: equity, cash, free margin, initial margin, unrealised P&L, holdings with FIFO cost, staked rows, realised and commission figures per family, counts, wins and losses, the allocation, the verification level (503 code 5030 without the margin engine).
GET /clients/{clientId}/ledger?limit&before: {code: 0, data: {postings: [{postingId, txId, type, actor, reason, ts, amount, currency, kind, refs}], hasMore, nextBefore}}, limit 1 to 500 (default 100), newest first. kind is the account (cash, reserved), type the transaction type (DEPOSIT, WITHDRAWAL_RESERVE, WITHDRAWAL, WITHDRAWAL_RELEASE, COMMISSION, PNL, FUNDING, BARRIER_SETTLE, SWAP_PRINCIPAL, SWAP_SETTLE, SWAP_FEE_ADJUST, PARTNER_COLLATERAL_IN, PARTNER_COLLATERAL_OUT, ...; every commission, a fill's, a barrier entry's or our share of a staking swap's reward, is a COMMISSION posting whose refs say which, revision 6; a FUNDING posting carries the refs of a funding item, 15.3, with fundingRate, markPrice and roundingRemainder since revision 24), actor partner:<CODE> on the partner's own postings (deposits, withdrawals and collateral reports).
GET /clients/{clientId}/notifications?since: the client's notification items newer than since (Unix ms, default the last 24 hours), oldest first, at most 200: {code: 0, data: {items: [{kind, ref, ts, event}], since, asOf}}. event is the partner webhook event name the kind maps to (section 16.4), null for kinds that are not forwarded. Poll this when you prefer pulling over webhooks.
Every partner is configured by our operations team with one of two custody models. The model decides who holds the clients' money, which money routes the partner uses, and how the economic results of trading are settled between the partner and us. GET /partner/v1/me carries custodyModel and collateralAsset; the model is set and changed by our operations team with a recorded reason, never by the partner.
BROKER custody (the default) | PARTNER custody | |
|---|---|---|
| Who holds the client money | We do. The partner receives the client's money on its rails and passes it to us; our finance desk confirms every deposit and approves every withdrawal against the settlement between the partner and us. | The partner does, under its own MiCA licence, as a USD stablecoin (collateralAsset, for example USDe). The margin stays at the partner; our ledger MIRRORS the collateral the partner holds per client. |
| Valuation for margin | The client currencies at their own scale; USDC is the cash currency. | The partner's stablecoin is valued 1:1 to USDC (and to USD). Every collateral report is booked in USDC; the stablecoin name travels as sourceAsset for display and reconciliation only. |
| Funding a client | POST .../deposits, a notice that stays PENDING until finance confirms (section 14.1). | POST .../collateral with direction: DEPOSIT, booked at once (section 15.1). The deposit routes answer 409 code 4092 "this partner holds client money itself: report collateral movements on /collateral". |
| Paying a client out | POST .../withdrawals, reserved at once, approved and paid by finance (section 14.2). | POST .../collateral with direction: WITHDRAWAL, booked at once when the client's collateral and free margin allow it (section 15.1). The withdrawal routes answer 409 code 4092 with the same sentence. |
| Trading, margin, risk | Unchanged. | Unchanged: the cash the risk engine margins is the mirrored collateral; the same pre-trade checks, restrictions, close-out and negative balance protection apply. |
| Economic results (realised P&L, commissions, funding, barrier settlements, swap flows, corrections) | Posted to the client's cash with us; the partner sees them on GET .../ledger and in the settlement report. | Posted to the client's mirrored cash with us AND turned into a settlement item the partner settles with its client in stablecoin and reports back (section 15.3). |
| Reconciliation with our finance | GET /partner/v1/settlement (section 14.3). | GET /partner/v1/settlement with the interfirm block, GET /partner/v1/settlement/summary and the interfirm records (sections 15.5 and 15.6). |
| Sandbox behaviour | A sandbox partner's notices and requests are confirmed and paid inside the POST (section 18). | Nothing waits for finance in any environment; the sandbox flag changes nothing. |
| Webhooks | deposit.confirmed, deposit.rejected, withdrawal.status. | collateral.booked, collateral.refused, settlement.item_created (section 16.4). |
What does NOT change with the model: the reliance onboarding, the MiFID assessment, the verification gates on money movements (level MICA and the enhanced due diligence source-of-wealth ask, which is the SOURCE_OF_WEALTH block of PUT .../mifid and whose deposit sum counts collateral deposits like any other deposit; the rule is in 15.1), the client restrictions, the trading routes, the statements (their content follows the model in one place: the quarterly CLIENT_ASSETS statement's balances and MiCA notes describe a PARTNER custody client's USDC balance as the mirror of the collateral you hold, section 17), and the fact that OUR ledger is the record of the client's balance with us at all times.
On our books a PARTNER-custody partner has one distinct house account, partner_custody:<partnerId>, whose balance is minus the collateral the partner holds for our clients; collateralHeld in the settlement summary is that figure. Every interfirm payment our finance records moves that account by the amount paid (positive = we paid the partner) against a second house account partner_interfirm:<partnerId>, the firm's own receivable or payable with the partner, so collateralHeld is the booked collateral reports plus the interfirm payments recorded, and the aggregate collateral the partner holds for our clients equals collateralHeld plus the outstanding interfirm balance over the whole history (revision 10). A BROKER-custody partner's client cash sits against our transit house account instead.
Changing the custody model (revision 4). The model is changed by our operations team only, and only on a partner with nothing standing under the old model: the change is refused while any of the partner's clients holds a nonzero USDC cash or reserved balance, while our house account partner_custody:<partnerId> is nonzero, while a settlement item is PENDING or FAILED, while a deposit notice is PENDING, while a withdrawal request is REQUESTED or APPROVED, or while your clients are close-only under the settlement escalation of 15.7 (the operator sees the standing items named; the close-only state lifts on our next hourly check after every item is settled and reported and the unsettled sum is under the cap, so that last condition holds for at most an hour once the others are met). Because a client with money at us blocks the change, the unwind happens with every client of the partner flat and paid out. Unwinding a PARTNER model: settle and report every settlement item, agree and pay the interfirm balance (recorded by our finance; the record moves partner_custody:<partnerId> by the payment, so once every client's collateral is reported withdrawn the account reads zero), report every client's collateral as withdrawn (direction: WITHDRAWAL down to a zero balance); then the model changes and, under BROKER, the clients are funded again through deposit notices. Unwinding a BROKER model: let finance decide every pending notice, approve and pay or reject every open withdrawal request, pay every client out (withdrawal requests down to a zero balance); then the model changes and, under PARTNER, the partner reports the collateral it holds. The trading routes are not touched by the change; the settlement items and collateral reports already on file stay readable.
Partner clients fund their accounts THROUGH THE PARTNER: the partner receives the client's money (fiat or crypto) on its own rails, notifies us of the deposit, and our finance team confirms it against the settlement between the partner and us; withdrawals are requested by the partner, paid out by the partner to the client, and confirmed as paid by our finance team. Our ledger is the record of the client's balance at all times; the partner and our finance reconcile through the settlement report. A PARTNER-custody partner uses section 15 instead of 14.1 and 14.2 (the two routes answer 409 code 4092 for it) and reads the settlement report of 14.3 with its interfirm block.
Both money writes pass the client's verification gate for a crypto movement (DEPOSIT:CRYPTO / WITHDRAW:CRYPTO: level MICA, and the enhanced due diligence source-of-wealth ask once cumulative deposits reach the threshold; 403 code 4030 with missingBlocks), the account state (a closed account 409 code 4092) and the client's money restrictions (409 code 4092). The ask is the SOURCE_OF_WEALTH block of PUT .../mifid; its rule is stated once, in 15.1, and its threshold, its sum and its cure are the same under both custody models: here the deposits counted are the deposit notices our finance confirmed (a notice in a currency other than USDC counts at the USDC value recorded for it at the confirmation, and at zero when none was recorded), and the ask is met when the next notice or withdrawal request is filed. The retry note at the end of 15.1 does not apply here: a partnerRef you already filed replays before the gate (section 5), so a retry answers the original notice or request.
POST /clients/{clientId}/deposits (TRADE, Idempotency-Key):
| Field | Required | Rules |
|---|---|---|
partnerRef | yes | your reference for the deposit, 1 to 64 characters, unique per partner; a reuse for the same client answers the original notice with 200 (section 5), for another client 409 code 4092 |
currency | yes | a client currency (USDC, BTC, ETH, SOL, BNB, XRP, EUR, USD1) |
amount | yes | positive decimal string within the currency's precision |
method | no | FIAT (default) or CRYPTO |
evidenceRef | no | your payment evidence reference, at most 200 characters |
Answer 201 with the notice:
{
"code": 0,
"data": {"noticeId": 77, "partnerId": 7, "clientId": 412, "clientUid": "BARR-00000412", "partnerRef": "dep-2026-0001", "currency": "USDC", "amount": "1500.000000", "method": "FIAT", "evidenceRef": "SEPA-9F3A", "status": "PENDING", "createdAt": 1788312000000, "decidedBy": null, "decidedAt": null, "decisionNote": "", "ledgerTxId": null}
}
Deposit state machine:
PENDING --confirm (finance)--> CONFIRMED ledger DEPOSIT: client cash + amount, house transit; webhook deposit.confirmed
PENDING --reject (finance)---> REJECTED no posting; webhook deposit.rejected
A sandbox partner's notice is confirmed inside the POST (the 201 already says CONFIRMED with the ledgerTxId). The confirmation is idempotent on our side (ledger key partnerdep:{noticeId}), so a notice is never credited twice. decidedBy on a notice (and on a withdrawal request, section 14.2) is the decider's ROLE label (FINANCE, ADMIN; PARTNER on a sandbox partner's instant decision), never a person's name (revision 6).
GET /clients/{clientId}/deposits?status&limit&before and GET /partner/v1/deposits?status&limit&before (partner-wide): {deposits, hasMore, nextBefore}, status one of PENDING, CONFIRMED, REJECTED, limit 1 to 200 (default 50).
POST /clients/{clientId}/withdrawals (TRADE, Idempotency-Key):
| Field | Required | Rules |
|---|---|---|
partnerRef | yes | 1 to 64 characters, unique per partner; a reuse for the same client answers the original request with 200 (section 5), for another client 409 code 4092 |
currency | yes | a client currency |
amount | yes | positive decimal string |
destination | no | a JSON object of your own shape (your payout reference), stored verbatim, at most 4096 characters |
The free balance is checked under the client's lock inside the request's own transaction (revision 4), so nothing that moves the client's money or margin can slip in between the check and the reserve: the cash (400 code 4000 "Insufficient free balance for this withdrawal."), and for USDC the lesser of the cash and the free margin, never in a margin call or close-out, whether recorded on the account or implied by the live marks (400 code 4000 "This withdrawal would leave the account short of margin for its open positions."). The amount is reserved at once (cash to reserved, WITHDRAWAL_RESERVE); the reserve is on its way out, so it neither shields open positions nor counts as margin (the client's equity and free margin read as if it had already left). Answer 201 with the request:
{
"code": 0,
"data": {"requestId": 41, "partnerId": 7, "clientId": 412, "clientUid": "BARR-00000412", "partnerRef": "wd-2026-0001", "currency": "USDC", "amount": "500.000000", "destination": {"iban": "DE89370400440532013000", "reference": "wd-2026-0001"}, "status": "REQUESTED", "createdAt": 1788312100000, "decidedBy": null, "decidedAt": null, "paidAt": null, "decisionNote": "", "ledgerTxId": 9911}
}
Withdrawal state machine:
REQUESTED --approve (finance)--> APPROVED --pay (finance)--> PAID
| ledger WITHDRAWAL: reserved to house transit (paidAt set; the partner has paid the client)
|
+--reject (finance)-----------> REJECTED ledger WITHDRAWAL_RELEASE: reserved back to cash
+--cancel (partner)-----------> CANCELLED ledger WITHDRAWAL_RELEASE
Every transition fires withdrawal.status. A sandbox partner's request is approved and paid inside the POST (the 201 says PAID).
Finance's approval re-runs the money-out gate under the client's lock (revision 4): a client restriction, an open reconciliation break that pauses money out in that currency, or in every currency for a ledger-proof break, a margin call or close-out (recorded or implied by the live marks), or free funds that no longer cover the amount refuse the approval and leave the request REQUESTED with its reserve; finance then approves later or rejects it (the release). Nothing changes on the wire for you: the request stays REQUESTED until a transition fires withdrawal.status.
POST /clients/{clientId}/withdrawals/{rid}/cancel (TRADE, Idempotency-Key): the partner's own cancel while REQUESTED; {request, alreadyDecided}; 409 code 4092 once approved.
GET /clients/{clientId}/withdrawals?status&limit&before and GET /partner/v1/withdrawals?status&limit&before: {withdrawals, hasMore, nextBefore}, status one of REQUESTED, APPROVED, PAID, REJECTED, CANCELLED.
GET /partner/v1/settlement?from&to (Unix ms; default the whole history up to now): per currency the confirmed deposits and paid withdrawals of the window (by decision and payout time), the commissions charged to the partner's clients and their realised results (the ledger postings of the window), and the closing client cash (cash plus reserved of every partner client) as of to. This is the reconciliation the partner and our finance agree on.
{
"code": 0,
"data": {
"from": 1785715200000, "to": 1788307200000, "asOf": 1788312200000, "custodyModel": "BROKER", "collateralAsset": "",
"currencies": [
{"currency": "USDC", "depositsConfirmed": "250000.000000", "withdrawalsPaid": "61200.000000", "commissions": "1840.250000", "realizedPnl": "-3120.400000", "closingClientCash": "186710.500000"}
],
"interfirm": null,
"definitions": {"commissions": ["COMMISSION"], "realizedPnl": ["PNL", "BARRIER_SETTLE", "SWAP_SETTLE", "FUNDING"], "closingClientCash": "client cash plus reserved of the partner's clients as of `to`", "interfirm": "only a PARTNER-custody partner carries the interfirm block"}
}
}
commissions is positive for a charge; every commission (a fill's, a barrier entry's, our share of a staking swap's reward) is a COMMISSION posting (revision 6 dropped SWAP_COMMISSION and BARRIER_COMMISSION from the list: they were documented and never posted). realizedPnl is signed from the client's point of view.
For a PARTNER-custody partner custodyModel is PARTNER, depositsConfirmed and withdrawalsPaid stay zero (collateral reports are not finance-desk deposits), and interfirm is the block of section 15.5 for the same window, {currencies: [{currency, owedToPartner, owedByPartner, net, settlementsRecorded, outstanding}], definitions}, with definitions.interfirm "the settlement items of the window from our view, less the interfirm settlements recorded whose period overlaps it".
Under PARTNER custody the partner is the custodian. Three things replace the deposit notices and withdrawal requests:
SETTLED or FAILED.POST /clients/{clientId}/collateralScope TRADE, Idempotency-Key recommended. Reports one collateral movement:
| Field | Required | Rules |
|---|---|---|
partnerRef | yes | your reference for the movement, 1 to 64 characters, unique per partner; a reuse for the same client answers the original report (a BOOKED one with 200, a REFUSED one with its original 409 code 4092, section 5), for another client 409 code 4092; a REFUSED report keeps its reference, so a new attempt needs a new one |
direction | yes | DEPOSIT (the client's collateral with you increased) or WITHDRAWAL (it decreased) |
amount | yes | positive decimal string, at most 6 places |
currency | no | USDC, the only accepted value (the stablecoin is valued 1:1) |
sourceAsset | no | the stablecoin name for display, at most 32 characters; defaults to the partner's collateralAsset |
txRef | no | your on-chain or internal transaction reference, at most 128 characters |
occurredAt | no | Unix ms when the movement happened on your side, not in the future (5 minutes of skew tolerated); defaults to now |
A DEPOSIT is booked as the ledger transaction PARTNER_COLLATERAL_IN: the client's USDC cash plus amount against our house account partner_custody:<partnerId>, actor partner:<CODE>, ledger key pcol:<reportId>, refs {partnerId, reportId, partnerRef, sourceAsset, txRef, direction, occurredAt}. A WITHDRAWAL is PARTNER_COLLATERAL_OUT the other way. Neither produces a settlement item: the report is the partner's own movement, not an economic event. The enhanced due diligence deposit sum counts collateral deposits like any other deposit (the rule is stated below, after the refusal rules).
Answer 201:
{
"code": 0,
"data": {
"reportId": 501, "status": "BOOKED", "ledgerTxId": 10231,
"balances": {"cash": "10000.000000", "reserved": "0.000000", "free": "10000.000000", "freeMargin": "10000.000000"},
"report": {
"reportId": 501, "partnerId": 7, "clientId": 412, "clientUid": "BARR-00000412", "partnerRef": "col-2026-0001", "direction": "DEPOSIT",
"currency": "USDC", "amount": "10000.000000", "sourceAsset": "USDe", "txRef": "0x9f3a6c1b", "occurredAt": 1788393600000,
"status": "BOOKED", "refusal": "", "ledgerTxId": 10231, "createdAt": 1788393605000
}
}
}
balances is the client's USDC line after the booking: cash and reserved from the ledger, freeMargin from the risk engine (equal to cash while the client has no positions), free the lesser of the two (what a withdrawal could take now).
Refusal rules, in the order they are checked:
| Refusal | Answer | Report row |
|---|---|---|
The partner is under BROKER custody | 409 code 4092 "This partner does not hold client money itself: use the deposit notices and withdrawal requests." | none |
A field is invalid (the reference, the direction, the currency, the amount, sourceAsset, txRef, an occurredAt in the future) | 400 code 4000 with the sentence, for example "The currency must be USDC (the partner's stablecoin is valued 1:1 to USDC)." | none |
| The account is closed | 409 code 4092 "This account is closed." | none |
The verification gate (DEPOSIT:CRYPTO / WITHDRAW:CRYPTO: level MICA, the EDD source-of-wealth ask, which is the SOURCE_OF_WEALTH block of PUT .../mifid, stated below) | 403 code 4030 with data.missingBlocks | none |
partnerRef already filed for another client of yours | 409 code 4092 "This partnerRef was already used for another collateral report." | none |
A money restriction stands (NO_WITHDRAWALS refuses a withdrawal, FROZEN refuses both directions; a deposit is welcome under NO_WITHDRAWALS) | 409 code 4092 with the restriction's sentence | REFUSED |
| A withdrawal exceeds the client's collateral on our books (the USDC cash) | 409 code 4092 "The withdrawal exceeds the client's collateral on our books." | REFUSED |
| The account is in margin call or close-out, recorded on the account or implied by the live marks | 409 code 4092 "The account is in margin call or close-out: no collateral may be withdrawn." | REFUSED |
| A withdrawal exceeds the lesser of the client's cash and free margin | 409 code 4092 "The withdrawal exceeds the client's free margin." | REFUSED |
The enhanced due diligence (EDD) source-of-wealth ask. The ask is the SOURCE_OF_WEALTH block of PUT .../mifid (11.4) and nothing else: there is no second block, record, case or trigger name on the wire. It is an additional requirement on the money actions (DEPOSIT:CRYPTO and WITHDRAW:CRYPTO), whatever the client's level, once the client's cumulative deposits reach our threshold:
BOOKED collateral DEPOSIT report (under BROKER custody every confirmed deposit notice), plus any manual credit our finance desk books to the client's cash (a manual correction in the client's favour counts as one). There is no time window and a withdrawal NEVER reduces the sum; realised profits, funding, commissions, barrier settlements, swap flows and REFUSED reports never count.WITHDRAWAL included, is refused with 403 code 4030, data {action, level, requiredLevel: "MICA", missingBlocks: ["SOURCE_OF_WEALTH"]} and the msg "Because your total deposits have reached 500,000 EUR, we need to ask about the source of your wealth before the next deposit or withdrawal." (the threshold in force is inserted). No report row is written and no webhook fires on that refusal. Trading is not affected.SUBMITTED or APPROVED. PUT .../mifid writes the four MiFID blocks APPROVED in one step, whatever the appropriateness outcome, so a client you sent through PUT .../mifid never sees the ask, however much it deposits. It comes back only if our compliance later rejects that block (a verification decision naming it, or a rejected package, 16.4). A block our compliance rejected is under the compliance lock of section 11 (revision 14): the cure, the full four-block PUT .../mifid, answers 409 code 4092 until our compliance releases the block, so the collateral reports stay refused until then.MICA, without a PUT .../mifid so far, CAN meet the ask. No partner route submits the SOURCE_OF_WEALTH block alone: the cure is the full four-block PUT .../mifid (11.4), after which the next report passes.GET .../verification (11.5): once the ask applies, requirements["DEPOSIT:CRYPTO"] and requirements["WITHDRAW:CRYPTO"] list SOURCE_OF_WEALTH.partnerRef replay (the order of the table above; the deposit notices and withdrawal requests of section 14 replay first). A retry of a report that was already BOOKED, sent without its original Idempotency-Key for a client who meets the ask by then, answers 403 code 4030 and not the original report (a retry under the original key replays the stored answer, section 5): read the report lists (15.2) before you treat that deposit as refused.Reaching the threshold requests no documents by itself; supporting evidence is a matter of 10.3.
A partnerRef already filed for the SAME client is not a refusal: the original report replays (section 5). The four economic rows of a withdrawal are read under the client's lock inside the booking transaction (revision 4), so a fill or a mark move cannot slip in between the check and the booking. An economic refusal (the four rows that record a REFUSED report) answers the reason as msg and data: {reportId, status: "REFUSED", refusal, report}, fires collateral.refused, and moves nothing; the row stays in the lists as the partner's record of the attempt. Every booking fires collateral.booked. Both outcomes are noted on the client's and the partner's audit streams on our side.
Worked example: a withdrawal refused, then booked. Client 412 holds 10000 USDC of reported collateral and a 0.01 BTC-USD-PERP position bought at 60000 at 5x (initial margin 120, free margin 9880 at a mark of 60000):
{"partnerRef": "col-2026-0002", "direction": "WITHDRAWAL", "amount": "9900"} answers 409 code 4092, msg "The withdrawal exceeds the client's free margin.", data.reportId 502, data.status REFUSED; the client's cash is still 10000 and the webhook collateral.refused carries the report.{"partnerRef": "col-2026-0003", "direction": "WITHDRAWAL", "amount": "9000"} answers 201 BOOKED with balances {"cash": "1000.000000", "reserved": "0.000000", "free": "880.000000", "freeMargin": "880.000000"} and the ledger transaction PARTNER_COLLATERAL_OUT; our house account partner_custody:7 now shows minus 1000, the collateral the partner still holds for the client per our books.{"partnerRef": "col-2026-0002", "direction": "WITHDRAWAL", "amount": "1"} answers the original refusal again: 409 code 4092, msg "The withdrawal exceeds the client's free margin.", data.reportId 502 (the reference names that report for good; a new attempt needs a new reference), and nothing is written. A retry of step 2 under col-2026-0003 answers 200 with the same reportId and ledgerTxId, nothing booked twice.GET /clients/{clientId}/collateral?status&limit&before and GET /partner/v1/collateral?status&limit&before (partner-wide): {reports: [<report>], hasMore, nextBefore}, newest first (keyset on the report id), status BOOKED or REFUSED (400 code 4000 otherwise), limit 1 to 200 (default 50).
Every ledger transaction that changes a PARTNER-custody client's USDC money with us (cash plus reserved), other than the collateral reports themselves and the withdrawal reserve mechanics, produces exactly ONE settlement item, written in the same database transaction as the posting: an item exists if and only if the posting does, and never twice (a replayed posting writes nothing more). A hold moved from cash to reserved and back (a spot reservation, a withdrawal reserve) is not a money change and produces no item; money leaving the reserved side (a swap principal, a spot delivery) is one. One economic fact can lead to more than one transaction over time, and so to more than one item: for example, a funding charge our reconciliation restates after the settlement is a further FUNDING transaction and a further item (the funding items, below).
amount is signed FROM THE CLIENT'S VIEW: positive = the partner must credit the client's stablecoin account by that amount, negative = the partner must debit it. type is the ledger transaction type and refs the transaction's own references:
type | When | refs |
|---|---|---|
PNL | a fill realised profit or loss on a perpetual | {TradeId, ClientOrderId, Instrument} |
COMMISSION | our commission on a fill, the entry commission of a barrier contract, its early-close commission (revision 10, BROKER-14: dated at the close), or our share of a staking swap's reward at its close (every commission is a COMMISSION item; the refs say which, revision 6) | {TradeId, ClientOrderId, Instrument} on a fill; {contractId, symbol} on a barrier entry; {contractId, symbol, reason: "earlyClose", closeTradeId} on a barrier early close; {contractId, swapId, symbol, asset, rewardReceived, commissionRewardBps, reason} on a swap |
FUNDING | a funding settlement passed through to the client; later, possibly days after the settlement, each RESTATEMENT of that charge (revision 22, the funding items below) | {symbol, settledAt, qty, fundingPerUnit, side, fundingRate, markPrice, roundingRemainder} on the settlement's charge; {symbol, settledAt, qty, previousQty, previouslyPosted, side, fundingRate, markPrice, roundingRemainder} on a restatement (no fundingPerUnit; side can be FLAT); the items of a settlement charged before revision 24 carry both shapes without fundingRate, markPrice and roundingRemainder |
BARRIER_SETTLE | a barrier contract ended (knock-out, take profit, expiry, early close, tear-up) | {contractId, symbol, reason, settlePrice} |
SWAP_PRINCIPAL, SWAP_SETTLE, SWAP_FEE_ADJUST | the staking swap flows: the principal out at the take, a payout, a venue fee correction (the commission at the close is a COMMISSION item) | {contractId, ...} |
NBP_WRITEOFF | negative balance protection wrote a shortfall off (a positive amount: the partner credits the client back to zero) | the close-out episode |
MANUAL | a manual ledger correction by our finance | the correction's references |
Item lifecycle:
PENDING --report SETTLED (partnerTxRef)--> SETTLED
PENDING --report FAILED (note)-----------> FAILED --report SETTLED (the retry)--> SETTLED
SETTLED --report FAILED-------------------> refused, 409 code 4092
any status, the same status again --------> 200, changed: false (nothing rewritten)
The item is created PENDING with occurredAt = the posting time, and the webhook settlement.item_created fires from the same transaction. An item is owed between the firms the moment it exists (section 15.5); the partner's report only confirms that the client side was settled.
Worked example: a perpetual close. Client 412 buys 0.01 BTC-USD-PERP at 60000 and sells it reduce-only at 61000. The closing fill posts the realised P&L, and each fill posts its commission (0.30 in this example, whatever the commission scheme configured for the partner's clients charges); three items result:
{"itemId": 9101, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "COMMISSION", "currency": "USDC", "amount": "-0.300000",
"refs": {"TradeId": 88001, "ClientOrderId": 4186, "Instrument": "BTC-USD-PERP"}, "occurredAt": 1788393700000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10240, "createdAt": 1788393700000}
{"itemId": 9102, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "PNL", "currency": "USDC", "amount": "10.000000",
"refs": {"TradeId": 88002, "ClientOrderId": 4187, "Instrument": "BTC-USD-PERP"}, "occurredAt": 1788393760000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10242, "createdAt": 1788393760000}
{"itemId": 9103, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "COMMISSION", "currency": "USDC", "amount": "-0.300000",
"refs": {"TradeId": 88002, "ClientOrderId": 4187, "Instrument": "BTC-USD-PERP"}, "occurredAt": 1788393760000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10243, "createdAt": 1788393760000}
The partner credits its client 10.00 USDe for item 9102, debits 0.30 twice for 9101 and 9103, and reports the three items (section 15.4). Between the firms the three net to +9.40: we owe the partner 9.40 (section 15.5). The opening fill realised nothing, so it produced its commission item alone.
Worked example: a barrier knock-out. Client 412 holds a long SP500-USD-KO contract 4711 on 2 units, entry 5000, barrier 4900, premium 200. The venue knocks the contract out AT the barrier: the settlement of minus 200 is exactly one item (the entry commission, when the scheme charges one, was its own COMMISSION item with refs {contractId, symbol} at the opening):
{"itemId": 9104, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "BARRIER_SETTLE", "currency": "USDC", "amount": "-200.000000",
"refs": {"contractId": 4711, "symbol": "SP500-USD-KO", "reason": "knockOut", "settlePrice": "4900"}, "occurredAt": 1788397300000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10251, "createdAt": 1788397300000}
A contract that ends at its take profit produces the same item with a positive amount and reason takeProfit; an early close carries earlyClose and the accepted close price, an expiry expiry.
Worked example: a commission. A fill that opens or adds to a position realises nothing and produces its commission item alone, item 9101 above: type COMMISSION, a negative amount, the fill in refs. The partner debits the client and reports it like any other item.
The funding items (revision 22; the amount since revision 24). At every funding settlement of a perpetual market the venue charges us on our net position, and we pass the charge on to every client that held a position at the settlement, with the venue's own arithmetic (revision 24): minus the client's signed position times the settlement's markPrice times its fundingRate, rounded half-even to 6 places, plus the settlement's rounding remainder on ONE client of ours, often not one of yours (below); a long pays at a positive rate and a short receives; a charge that rounds to zero is not posted. That is one FUNDING item per client with a nonzero charge, normally created within minutes of the settlement, with refs {symbol, settledAt, qty, fundingPerUnit, side, fundingRate, markPrice, roundingRemainder}: symbol the market; settledAt the settlement's time in Unix ms (the venue's settlement instant that names it), not the item's own top-level settledAt, which is when the client side was settled, as your SETTLED report gives it (15.4; the report's own time is reportedAt); qty the client's position at the settlement as a SIGNED decimal string (negative for a short); fundingPerUnit the venue's figure in USD per unit, valued 1:1 in USDC, a DISPLAY figure: the venue rounds the charge of one lot to a millionth before it divides it by the lot, so qty times fundingPerUnit is NOT the amount (on a position of 10,000 at a mark of 76.37 and a rate of 0.0001 it gives 76.00 where the venue charged 76.37) and the amount is never computed from it; side LONG or SHORT; fundingRate and markPrice the venue's rate and mark of that settlement as the venue published them (decimal strings; the mark in USD per unit); roundingRemainder a JSON INTEGER in millionths of a USDC (not a decimal string), signed like amount, non-zero on at most ONE item of the settlement across ALL our clients, so usually 0 on every item you receive.
THE ROUNDING REMAINDER (revision 24). Each client's charge is rounded to the millionth on its own while the venue charges our net position once, so the venue's charge and the sum of our clients' rounded charges can differ, by less than one millionth per client of ours holding a position in that market. As the venue attributes it, that difference is carried by the ONE client of ours holding the largest position in that market at the settlement (the lowest of our clients on a tie), chosen across ALL our clients: our direct clients and the clients of every partner, not yours alone. So for most settlements every item you receive carries roundingRemainder 0; only the sum over ALL our clients is exactly what the venue charged us, never the sum of your items of a settlement, so never reconcile your items against the venue's charge or expect exactly one non-zero remainder among them. A non-zero roundingRemainder on one of your items means that your client held our largest position in that market at that settlement. So every item reproduces as:
amount = -round_half_even(qty x markPrice x fundingRate, 6 places) + roundingRemainder / 1,000,000
Before revision 24 the amount was minus qty times fundingPerUnit, rounded half-even to 6 places, with no remainder: a settlement we charged before the deployment of revision 24 keeps that amount and is never restated to the new one; its items carry the first five members alone, and a restatement of it (up to seven days after the settlement, below) is computed the earlier way, its refs without fundingRate, markPrice and roundingRemainder. One exception: a settlement whose charging a fault on our side interrupted before the deployment, and which we completed after it, is charged the new way, so its items posted before the deployment (the first five members alone) are restated to the new amount by a restatement item carrying the three members, with qty equal to previousQty, of the size of the difference between the two ways (0.37 on a position of 10,000 at a mark of 76.37 and a rate of 0.0001).
Restatements. Every few minutes our reconciliation re-derives the funding settlements of the last seven days (counted from the settlement's time or from the time we processed it, whichever is later) from the client's position at that settlement as our records hold it at that run. When that position changed after the charge (a fill of the client's that reached our records late from the venue, the source of truth, with a venue time before the settlement; or a corrected instant at which the positions of that settlement are measured), we never edit the earlier item: we post the DIFFERENCE as a further FUNDING transaction, and under PARTNER custody it is a settlement item of its own, with its own itemId, its own settlement.item_created delivery and occurredAt = the time of the restatement, hours or days after the settlement. Since revision 24 a client's charge is also restated by the rounding remainder alone (less than one millionth per client of ours holding a position in that market) when the position at that settlement of ANOTHER client of ours, yours or not, was restated and the remainder changed its size or moved to or from this client (such an item has qty equal to previousQty). Its refs are {symbol, settledAt, qty, previousQty, previouslyPosted, side, fundingRate, markPrice, roundingRemainder}:
| Key | Meaning |
|---|---|
symbol, settledAt | the settlement restated, the same values as on its first item |
qty | the client's position at the settlement as restated, a signed decimal string |
previousQty | the position our charge record held before this restatement, signed; it can differ from the qty of the item before it (a restated position whose charge rounds to the same amount moves the record without an item, and when a charge restated to zero earlier comes back, previousQty repeats qty) |
previouslyPosted | the client's net charge for this settlement before this item, a JSON INTEGER in millionths of a USDC (not a decimal string), signed like amount (negative: the client paid): the sum of the earlier FUNDING items of this client and settlement, 0 after a charge restated to zero |
side | LONG, SHORT, or FLAT when the restated position is none at all (qty "0") |
fundingRate, markPrice, roundingRemainder | the settlement's rate and mark, the same values as on its first item, and the rounding remainder this client carries at the restated positions, a JSON INTEGER in millionths of a USDC (revision 24): the restated charge is -round_half_even(qty x markPrice x fundingRate, 6 places) + roundingRemainder / 1,000,000; all three absent on a restatement of a settlement charged before revision 24 (present on the one exception above) |
There is no fundingPerUnit: the settlement's figure is the one on its first item. amount is the restated charge minus what was posted before, signed from the client's view like every item, so the restated charge is previouslyPosted / 1,000,000 plus amount. A charge restated to zero (side FLAT, or a position so small that its charge rounds to zero) yields an item of exactly minus previouslyPosted, which offsets the earlier items of that settlement; a restated position whose charge rounds to the same amount yields no item. One settlement can be restated more than once for the same client, each time as a further item. A client the re-derivation finds with a position at a settlement where it had no charge is charged late with an item of the FIRST shape (with fundingPerUnit), occurredAt the time of that run.
How to book the funding items:
itemId (ledgerTxId is unique too), never by client, symbol and settlement: one client, market and settlement can carry several FUNDING items, the first and every restatement, and their sum is the client's net funding for that settlement.fundingPerUnit, nor any member that only one of the two shapes carries: a restatement is the FUNDING item whose refs carry previouslyPosted. Never require fundingRate, markPrice or roundingRemainder either: they are absent on the items of a settlement charged before revision 24.fundingRate, markPrice and roundingRemainder (the formula above), never from fundingPerUnit.roundingRemainder: the remainder sits with one client across ALL our clients (above).occurredAt, in the period in which it was created, never back-dated to refs.settledAt: the item list's since, the summary and the interfirm block all read occurredAt (15.5).Worked example: a restated funding charge. Client 412 is long 0.5 BTC-USD-PERP at the settlement of 2026-11-04 08:00 UTC (settledAt 1793779200000), charged under revision 24, at a rate of 0.0001 and a mark of 60000 (fundingPerUnit 6): the charge is minus 0.5 x 60000 x 0.0001 = 3.00 (roundingRemainder 0: client 412 does not carry the settlement's rounding remainder), item 9105. Two hours later our reconciliation receives from the venue a sell of 0.3 the client made before the settlement: the restated position is 0.2, the restated charge minus 1.20, and item 9106 credits the difference, plus 1.80. Two days later a second late sell of 0.2 from before the settlement makes the position at the settlement flat: item 9107 credits plus 1.20, minus its previouslyPosted, with side FLAT:
{"itemId": 9105, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "FUNDING", "currency": "USDC", "amount": "-3.000000",
"refs": {"symbol": "BTC-USD-PERP", "settledAt": 1793779200000, "qty": "0.5", "fundingPerUnit": "6", "side": "LONG", "fundingRate": "0.0001", "markPrice": "60000", "roundingRemainder": 0}, "occurredAt": 1793779260000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10260, "createdAt": 1793779260000}
{"itemId": 9106, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "FUNDING", "currency": "USDC", "amount": "1.800000",
"refs": {"symbol": "BTC-USD-PERP", "settledAt": 1793779200000, "qty": "0.2", "previousQty": "0.5", "previouslyPosted": -3000000, "side": "LONG", "fundingRate": "0.0001", "markPrice": "60000", "roundingRemainder": 0}, "occurredAt": 1793786700000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10275, "createdAt": 1793786700000}
{"itemId": 9107, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "FUNDING", "currency": "USDC", "amount": "1.200000",
"refs": {"symbol": "BTC-USD-PERP", "settledAt": 1793779200000, "qty": "0", "previousQty": "0.2", "previouslyPosted": -1200000, "side": "FLAT", "fundingRate": "0.0001", "markPrice": "60000", "roundingRemainder": 0}, "occurredAt": 1793952300000, "status": "PENDING",
"partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10298, "createdAt": 1793952300000}
The three items sum to zero: in the end the client owed no funding for that settlement. The partner debits the client 3.00 for item 9105 and credits 1.80 and 1.20 for the other two, each in the period of its own occurredAt, and reports each one (15.4).
GET /partner/v1/settlement/items?status&since&clientId&limit&before: {items: [<item>], hasMore, nextBefore}, newest first (keyset on the item id). status one of PENDING, SETTLED, FAILED (400 code 4000 otherwise); since Unix ms, the items with occurredAt at or after it; clientId our numeric id or the client uid exactly as issued (section 7; 404 code 4004 for an unknown or foreign client); limit 1 to 500 (default 100). Poll status=PENDING after every settlement.item_created delivery, or on a schedule when you prefer pulling over webhooks.
POST /partner/v1/settlement/items/{itemId}/report (TRADE, Idempotency-Key), body:
| Field | Required | Rules |
|---|---|---|
status | yes | SETTLED or FAILED |
partnerTxRef | for SETTLED | your transaction reference on the client's stablecoin account, 1 to 128 characters (optional on FAILED) |
settledAt | no | Unix ms when the client side was settled, not in the future; defaults to now on SETTLED, null on FAILED |
note | no | at most 500 characters (why it failed, what was done) |
Answer 200 {item: <item>, changed: true}:
{"code": 0, "data": {"item": {"itemId": 9102, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "PNL", "currency": "USDC", "amount": "10.000000", "refs": {"TradeId": 88002, "ClientOrderId": 4187, "Instrument": "BTC-USD-PERP"}, "occurredAt": 1788393760000, "status": "SETTLED", "partnerTxRef": "usde-0x77c1", "settledAt": 1788394000000, "reportedAt": 1788394002000, "note": "", "ledgerTxId": 10242, "createdAt": 1788393760000}, "changed": true}}
State rules (pinned by our tests):
changed: false and the item as it stands: the earlier partnerTxRef, settledAt and note are NOT rewritten.FAILED on a PENDING item records the note (settledAt null); a later SETTLED on it is the retry and is accepted.FAILED on a SETTLED item answers 409 code 4092 "This item was already reported SETTLED; it cannot be reported FAILED." with data.item.SETTLED ("partnerTxRef is required for a SETTLED report (1 to 128 characters)."), a settledAt in the future, a note over 500 characters.amount or the interfirm balance; it changes which bucket of the summary the item sits in.POST /partner/v1/settlement/report (TRADE, Idempotency-Key), the bulk form, body {items: [{itemId, status, partnerTxRef?, settledAt?, note?}]} with 1 to 500 entries (400 code 4000 "items must carry 1 to 500 entries." otherwise). Every entry is applied on its own under the rules above; a refused entry does not stop the others. Answer 200:
{
"code": 0,
"data": {
"results": [
{"itemId": 9101, "ok": true, "changed": true, "status": "SETTLED"},
{"itemId": 9102, "ok": true, "changed": false, "status": "SETTLED"},
{"itemId": 9103, "ok": false, "code": 4092, "msg": "This item was already reported SETTLED; it cannot be reported FAILED."},
{"itemId": 9999, "ok": false, "code": 4004, "msg": "Unknown settlement item."},
{"itemId": 9104, "ok": false, "code": 4000, "msg": "The status must be SETTLED or FAILED."}
],
"settled": 1, "failed": 0, "unchanged": 1, "refused": 3
}
}
settled and failed count the entries that CHANGED an item to that status, unchanged the idempotent repeats, refused the entries with ok: false (an entry without itemId is refused with code 4000 "itemId is required."). With an Idempotency-Key the whole answer is replayed on a retry; without one a retry is harmless anyway, the repeats come back unchanged.
GET /partner/v1/settlement/summary?from&toThe settlement position per currency for a window (from and to Unix ms by the items' occurredAt; the defaults are the whole history up to now; 400 code 4000 unless 0 <= from <= to):
{
"code": 0,
"data": {
"from": 0, "to": 1788480000000, "asOf": 1788480000000, "custodyModel": "PARTNER", "collateralAsset": "USDe",
"currencies": [
{
"currency": "USDC",
"pending": {"count": 3, "amount": "68.000000", "credits": "100.000000", "debits": "32.000000"},
"settled": {"count": 0, "amount": "0.000000", "credits": "0.000000", "debits": "0.000000"},
"failed": {"count": 0, "amount": "0.000000", "credits": "0.000000", "debits": "0.000000"},
"collateralHeld": "1000.000000",
"clientCash": "1068.000000",
"interfirm": {"currency": "USDC", "owedToPartner": "100.000000", "owedByPartner": "32.000000", "net": "68.000000", "settlementsRecorded": "0.000000", "outstanding": "68.000000"}
}
],
"definitions": {
"pending": "settlement items of the window not yet reported by the partner (count, signed sum, credits and debits apart)",
"settled": "items the partner reported SETTLED",
"failed": "items the partner reported FAILED (a later SETTLED report is a retry)",
"collateralHeld": "minus the balance of HOUSE partner_custody:{partnerId}: the collateral the partner holds for our clients per our books, now (the BOOKED collateral reports plus the interfirm payments recorded, positive = we paid the partner)",
"clientCash": "the sum of the partner clients' cash plus reserved, now; equals collateralHeld plus the interfirm outstanding over the whole history",
"interfirm": {
"owedToPartner": "the sum of the positive settlement items of the window (client gains the partner credited on our behalf)",
"owedByPartner": "the sum of the negative settlement items of the window, as a positive figure (client losses and commissions the partner debited and owes us)",
"net": "owedToPartner minus owedByPartner, signed: positive = we owe the partner, negative = the partner owes us",
"settlementsRecorded": "the interfirm settlements finance recorded whose period OVERLAPS the window (periodFrom <= to and periodTo >= from), each counted in full, signed: positive = we paid the partner",
"outstanding": "net minus settlementsRecorded: what is still to be paid, signed like net",
"window": "settlement items by occurredAt, every status (a PENDING item is owed as soon as it exists; the partner's report only confirms it)"
}
}
}
}
The three buckets carry the count, the signed sum (amount), and the credits (the positive items) and debits (the negative items, as a positive figure) apart. collateralHeld and clientCash are figures as of now, not of the window; clientCash is what our ledger holds for the partner's clients (cash plus reserved) and equals the collateral held plus the outstanding interfirm balance over the whole history (1000 + 68 in the example before any payment; after finance pays 50, collateralHeld reads 1050 and outstanding 18), because every interfirm record moves collateralHeld by the payment (revision 10; before it the identity read the net of every item ever produced). The definitions travel in every answer.
The interfirm block. From OUR point of view: the positive items are client gains the partner credited on our behalf (owedToPartner), the negative items are client losses and our commissions the partner debited and owes us (owedByPartner); net is the difference, signed (positive = we owe the partner). Our finance records each interfirm payment (section 15.6); settlementsRecorded is the sum of the recorded settlements whose period overlaps the window, each counted in full (revision 6; positive = we paid the partner), and outstanding = net minus settlementsRecorded is what is still to be paid, signed like net.
Worked example: the summary and the interfirm balance. One client, a collateral deposit of 1000, then three items: a realised profit of +100, a realised loss of -30 and a commission of -2, all PENDING. The summary shows pending {count: 3, amount: "68.000000", credits: "100.000000", debits: "32.000000"}, collateralHeld 1000, clientCash 1068, and interfirm {owedToPartner: "100.000000", owedByPartner: "32.000000", net: "68.000000", settlementsRecorded: "0.000000", outstanding: "68.000000"}: we owe the partner 68. The partner reports the +100 item SETTLED and the -30 item FAILED: the buckets become pending {1, -2.000000}, settled {1, 100.000000}, failed {1, -30.000000}, and net stays 68 (a report never moves the interfirm balance). Our finance then pays the partner 50 for the period and records it: net 68, settlementsRecorded 50, outstanding 18 and collateralHeld 1050 (revision 10: the record posts the payment into the mirror, partner_custody:<partnerId> against partner_interfirm:<partnerId>). A window that ends before the items and the recorded period counts neither; a window that covers the items and ANY part of the recorded period counts both (revision 6: a record counts in full in every window its period overlaps; before, only in a window containing the whole period), so a window cut through a settled period still shows the payment against the items it shows. Agree the period boundaries with our finance all the same, so that a payment and the items it settles fall in the same windows.
GET /partner/v1/settlement/interfirm?limit&beforeThe interfirm settlements our finance recorded, newest first (limit 1 to 500, default 50, keyset on the settlement id):
{
"code": 0,
"data": {
"settlements": [
{"settlementId": 12, "partnerId": 7, "currency": "USDC", "periodFrom": 1787788800000, "periodTo": 1788393599999, "amount": "50.000000", "direction": "PAID_TO_PARTNER",
"txRef": "wire-2026-09-02-0001", "note": "first half of the week", "recordedBy": "finance.ops", "recordedAt": 1788480000000, "ledgerTxId": 9107}
],
"hasMore": false, "nextBefore": null,
"definitions": {"owedToPartner": "...", "owedByPartner": "...", "net": "...", "settlementsRecorded": "...", "outstanding": "...", "window": "..."}
}
}
amount is signed like settlementsRecorded (positive = we paid the partner, direction PAID_TO_PARTNER; negative = the partner paid us, PAID_BY_PARTNER). A record is written by our finance from the back office with the payment reference; the partner cannot create or change one. A payment is recorded once: the record is idempotent on its payment reference (txRef) per partner, so a retried entry never doubles a payment (revision 6). The summary subtracts a record from every window its period overlaps (section 15.5). ledgerTxId (revision 10) is the ledger posting bound to the record on our books (one per record, written with the record; the collateral we hold per our books, collateralHeld of 15.5, moves by the payment: the BOOKED collateral reports plus the payments recorded, positive = we paid the partner); null only on a record written before revision 10 and not yet backfilled by our finance.
PENDING item older than 48 hours, or any FAILED item, opens one compliance case per partner per UTC day on our side, and our back office shows the pending count and the oldest pending age per partner. Settle and report items promptly; report a FAILED item with a note that says why, and retry it with SETTLED once the client side is fixed.PENDING or FAILED for more than 120 hours by default, or an unsettled net (the absolute value of the signed sum of the PENDING and FAILED items) above the lower of a USDC cap and a percentage of collateralHeld, both operator parameters fixed in the partner agreement, puts the partner's clients in close-only: an opening order, a barrier request or the accept of an opening request, and a staking take answer 403 code 4041 PARTNER_CLOSE_ONLY (section 4; over FIX 9110=4041), while a reducing order, an early close, the collateral and settlement reports and every read keep working. The state lifts on our hourly check once every item older than the escalation age is settled and the unsettled net is back under the cap (a SETTLED report does not lift it at once); the day's compliance case is escalated on our side. GET /me is unchanged.SETTLED by the end of the Business Day following its creation and in any event within 48 hours; a FAILED report does not discharge the item; a correction is a new item, never a change of an existing one: a MANUAL item from our finance, or a further item our reconciliation posts when it restates a posting (for example a FUNDING restatement, 15.3, revision 22). Suspension (code 4037) remains a separate manual measure.interfirm block, so a reconciliation built for BROKER custody keeps working for a PARTNER-custody partner.PUT /partner/v1/webhook (TRADE), body {url} (an absolute https URL): registers or replaces the endpoint and answers the signing secret ONCE. Every PUT rotates the secret, so a PUT is also the rotation procedure (deploy the new secret to your verifier before the next delivery arrives: deliveries already queued are signed with the secret current at send time).
The URL must point at a PUBLIC host (revision 5): https only (400 code 4000 "The webhook URL must be an absolute https URL."), and the host, an IP literal or a name resolved at registration, may not be a loopback, private (10/8, 172.16/12, 192.168/16, the shared 100.64/10 space, IPv6 unique local), link-local, multicast or otherwise unroutable address, nor localhost; a name that resolves to any such address, or to nothing, is refused (400 code 4000 "The webhook host must resolve to a public address (no loopback, private, link-local or multicast address)." or "The webhook host does not resolve."). The check runs again before every delivery and once more when the connection is dialled, so a name that later resolves inside our network is refused rather than delivered (the delivery log shows the sentence as the error). Any port is accepted.
{"code": 0, "data": {"url": "https://partner.example/hooks/barriers", "secret": "whs_4c1d2e3f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d", "active": true, "createdAt": 1788307200000, "updatedAt": 1788312300000}}
GET /partner/v1/webhook: {url, active, createdAt, updatedAt, lastDeliveryAt, pending, maxPending} (404 code 4004 when none is registered). pending is the number of deliveries not yet sent and still within their attempts; maxPending (revision 8) the count at which further rfq.quote events are dropped (section 16.3): the distance between the two is how far you are from losing quotes; lastDeliveryAt the newest delivery sent within the kept log.
The delivery log is kept 30 days (our configuration Partners:WebhookRetentionDays): a SENT or FAILED delivery older than that leaves the log and the list below (a FAILED delivery can no longer be redelivered once it left); a delivery still PENDING or RETRYING is never removed, whatever its age. DELETE /partner/v1/webhook: deactivates ({active: false, updatedAt}); events are not queued while inactive; a later PUT reactivates with a new secret. POST /partner/v1/webhook/test: enqueues a webhook.test delivery, {deliveryId, event, queuedAt}; 409 code 4092 while inactive. Since revision 17 the route takes an OPTIONAL JSON body that asks for a SAMPLE of a questionnaire event instead: {event: "appropriateness_test.version_changed", stage} with stage one of ANNOUNCED, IN_FORCE, WITHDRAWN, GRACE_ENDED, or {event: "appropriateness_test.text_changed", kind} with kind one of LOCALE_ADDED, TEXT_CORRECTED, TEXT_WITHDRAWN; the answer is {deliveryId, event, stage or kind, sample: true, queuedAt} and the delivery carries data.sample: true (section 18 says what a sample's data names). A body without an event member is today's test (no body, an empty one, {} and any other object deliver webhook.test); a body that is not JSON answers 400 code 4000; an event that names no sample answers 400 code 4000 "event names no sample we deliver.". Use a new Idempotency-Key per sample: the same key with another {event, stage} or {event, kind} is another request and answers 409 code 4092 (section 5; before revision 20 it replayed the first sample). GET /partner/v1/webhook/deliveries?limit&before: the delivery log, newest first, limit 1 to 200: {deliveries: [{deliveryId, event, clientId, clientUid, status, attempts, lastStatus, error, ts, sentAt, nextAt}], hasMore, nextBefore} with status PENDING, RETRYING, SENT, FAILED.
Each event is one POST to your URL with Content-Type: application/json and the headers
| Header | Value |
|---|---|
X-Brokerage-Event | the event name (deposit.confirmed) |
X-Brokerage-Delivery | the delivery id (retries carry the same id) |
X-Brokerage-Timestamp | Unix SECONDS at send time, taken for each delivery when it is sent (a retry carries a fresh timestamp and signature) |
X-Brokerage-Signature | v1= + lower-case hex of HMAC-SHA256(secret, timestamp + "." + body) |
Body:
{
"id": 5120,
"event": "deposit.confirmed",
"partnerId": 7,
"clientId": 412,
"clientUid": "BARR-00000412",
"partnerClientRef": "cust-001",
"ts": 1788312400000,
"data": {"noticeId": 77, "partnerRef": "dep-2026-0001", "currency": "USDC", "amount": "1500.000000", "status": "CONFIRMED", "ledgerTxId": 9950}
}
clientId, clientUid and partnerClientRef are null on partner-level events: webhook.test and, since revision 17, the two questionnaire events appropriateness_test.version_changed and appropriateness_test.text_changed (16.4), which are delivered once per partner and never per client. ts is the time the event was queued. A settlement item, for example:
{
"id": 5231, "event": "settlement.item_created", "partnerId": 7, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "ts": 1788393760000,
"data": {"itemId": 9102, "clientId": 412, "clientUid": "BARR-00000412", "partnerClientRef": "cust-001", "type": "PNL", "currency": "USDC", "amount": "10.000000",
"refs": {"TradeId": 88002, "ClientOrderId": 4187, "Instrument": "BTC-USD-PERP"}, "occurredAt": 1788393760000, "status": "PENDING", "partnerTxRef": "", "settledAt": null, "reportedAt": null, "note": "", "ledgerTxId": 10242, "createdAt": 1788393760000}
}
Answer any 2xx status within 10 seconds to acknowledge; the response body is ignored (on a failure at most 16 KiB of the body is read and at most 256 bytes of it are kept as the error text of the delivery log). A redirect (3xx) is never followed: it counts as a failed attempt. Deliveries can arrive out of order and, after a timeout on your side, more than once: deduplicate on id.
A non-2xx answer, a timeout (10 s) or a connection failure schedules a retry after 5 s x 2^n where n is the number of attempts so far, capped at 2^7: 10 s, 20 s, 40 s, 80 s, 160 s, 320 s, 640 s, 640 s, 640 s. After 10 attempts the delivery is FAILED and stays in the log for the kept 30 days (16.1); our operations team can redeliver it from the back office while it is there. The worker runs every 5 seconds and a delivery is claimed by one worker instance at a time (a claim older than 60 s is taken over).
Deliveries run per partner: each pass of the worker takes the due deliveries of every partner, at most 50 per partner, with every other event ahead of rfq.quote (a margin call or a fill never waits behind a quote backlog of yours), since revision 28 rfq.quote ahead of every perpetual demo event (demo.*, 23.9), and oldest first within each class, and posts them partner by partner, the partners in parallel and one delivery at a time within a partner. A timeout or a connection failure on your endpoint ends your turn for that pass (the deliveries after it stay queued and are attempted on the next pass), so an endpoint that is down costs one timeout per pass and never delays another partner, and no other partner's outage delays you. The order within a class is the queue order, so rfq.quote of one request can still overtake a later rfq.quote of another after a retry: order by the object's own timestamps (16.4) and deduplicate on id.
The pending cap (revision 8; since revision 28 it counts LIVE deliveries only): when your pending live deliveries (every event but the perpetual demo's demo.* events of 23.9) reach maxPending (5000 by default, our configuration Partners:WebhookMaxPending, never below 100), further rfq.quote events are DROPPED, not queued, and our log records the drops (one line per partner per minute). No other live event is ever dropped: a margin call, a fill or a contract close is worth a long queue, while a quote is the only event you can re-read without loss (GET .../rfq/{rfqId} carries every live quote). On the development venue a market maker connected to it quotes every barrier request within seconds, so an endpoint that stays down for hours would otherwise accumulate a quote per request per maker indefinitely. Since revision 28 the perpetual demo's demo.* events (23.9) are never counted toward it, so demo traffic never costs you a quote, while pending on GET /webhook counts every pending delivery, the demo's included, and can read above the live count the cap compares with. The demo's own cap (1,000 pending deliveries of every event, never above this one) drops further demo events alone (23.9).
Explicit events (the data object):
| Event | When | data |
|---|---|---|
client.onboarded | the reliance onboarding granted level MICA (status ACTIVE) | {verificationLevel, lifecycleState, kycPackageId} |
client.review_required | our screening or the grant needs a compliance decision (the PENDING_REVIEW path of the onboarding, or a KYC revision that changed the identity); the later decision arrives as client.verification_decision | {reason: "screening review", kycPackageId, caseId} |
client.level_changed | the MiFID grant made through your calls moved the level from MICA to MICA_MIFID; it fires for that upgrade and for nothing else: a downgrade, or a grant our compliance decides in our back office, reaches you as client.verification_decision only | {from: "MICA", to: "MICA_MIFID", appropriateness: {version, outcome, score, attempt, locale}} (locale since revision 17: the language of the words the client read) |
appropriateness.result | an assessment was scored | {version, outcome, score, attempt, retakeAt, requiresAcknowledgement, locale} (locale since revision 17) |
deposit.confirmed | finance confirmed a notice (or the sandbox POST) | {noticeId, partnerRef, currency, amount, status, ledgerTxId} |
deposit.rejected | finance rejected a notice | {noticeId, partnerRef, currency, amount, status, reason} |
withdrawal.status | every withdrawal transition (REQUESTED, APPROVED, PAID, REJECTED, CANCELLED) | {requestId, partnerRef, currency, amount, status, decidedAt, paidAt, note} |
webhook.test | POST /webhook/test | {message, requestedAt} |
kyc.documents_requested | our compliance requested the underlying documents for a KYC package (the package is DOCUMENTS_REQUESTED, section 10.2; upload through section 10.3) | {kycPackageId, revision, note} |
kyc.rejected | our compliance rejected reliance for the client: every block REJECTED, level NONE, lifecycle KYC_PENDING; re-onboard the client with fresh data or documents through PUT .../kyc (section 10.1) | {kycPackageId, revision, reason, verificationLevel, lifecycleState} |
collateral.booked | a collateral report was booked (section 15.1) | the report: {reportId, partnerId, clientId, clientUid, partnerRef, direction, currency, amount, sourceAsset, txRef, occurredAt, status: "BOOKED", refusal: "", ledgerTxId, createdAt} |
collateral.refused | a collateral report was refused for an economic reason and recorded (section 15.1) | the report with status: "REFUSED", refusal the reason, ledgerTxId null |
settlement.item_created | a settlement item was created, queued in the same transaction as the ledger posting (section 15.3); nothing fires on a settlement report | the item: {itemId, clientId, clientUid, partnerClientRef, type, currency, amount, refs, occurredAt, status: "PENDING", partnerTxRef: "", settledAt: null, reportedAt: null, note: "", ledgerTxId, createdAt} (partnerClientRef inside data can be empty on this event; the envelope's is always set) |
client.state_changed | a reserved name that nothing emits: a level or lifecycle decision made in our back office reaches you as client.verification_decision or kyc.rejected, a restriction (which never moves lifecycleState, 11.5) as margin.restriction_set, and a categorisation change as client.categorization_changed |
A partner MUST ignore event names it does not handle: new events are added to the catalogue without a version change of the endpoint.
Perpetual demo events (revision 28): the eight demo.* names of section 23.9 (demo.order.filled, demo.order.triggered, demo.order.canceled, demo.margin.call, demo.margin.call_cleared, demo.close_out.started, demo.close_out.done, demo.account.deleted) report the simulated accounts of section 23. They are never among the names of this catalogue, no live event ever carries a demo fact, every demo event's data carries demo: true, and they are delivered after every live event and after rfq.quote (16.3), best effort.
Life-cycle events (revision 8, the addendum of 2026-09-06): explicit, emitted at the state change itself, never derived from the notification feed, and each exactly once per fact (the delivery may still repeat after a timeout on your side: deduplicate on id). Money is a 6-place decimal string in USDC, prices and quantities decimal strings, times Unix milliseconds, as on the reads they mirror. Deliveries can arrive out of order: order by the object's own timestamps (updatedAt, closedAt, at) and deduplicate on the envelope id.
| Event | When |
|---|---|
margin.call | the account's persisted risk state moved from NORMAL to MARGIN_CALL (equity below the margin call level); marginCallPct and closeOutPct in the data are the value that applies to this client, never below the brokerage value (a partner may agree a tighter level for its clients with us) |
margin.call_cleared | back from MARGIN_CALL to NORMAL; the two thresholds in the data as on margin.call, the value that applies to this client, never below the brokerage value |
margin.close_out_started | the close-out began (equity below the maintenance margin, or a negative cash balance on a flat account; forced: true when our operations forced it); the two thresholds in the data are the value that applies to this client, never below the brokerage value |
margin.close_out_done | the close-out ended with the account flat; the state is NORMAL again; the two thresholds in the data as on margin.call, the value that applies to this client, never below the brokerage value |
order.accepted | the venue acknowledged the order (ACKED) or holds it (PENDING_TRIGGER); once per order, a later trigger fires nothing. When the venue's acknowledgement was lost on its way to us, or the venue answered that it could not confirm the order (revision 23), our reconciliation adopts the venue's view within minutes and delivers the event then, before any fill or cancel of that order. A close-out may also place a SELL on a spot market in the client's name (the forced sale of the client's holdings after a close-out left its cash negative, origin CLOSE_OUT, family SPOT, docs/13 section 8; it runs for a client only once that client has accepted the Terms version carrying the right-of-sale clause), whose order.* events fire like any other |
order.partially_filled | a fill that did not complete the quantity (a fill on an order already CANCELED, PENDING_CANCEL or REJECTED fires too: the figures advanced) |
order.filled | the fill that completed the quantity |
order.canceled | the venue confirmed the cancel (reason canceled) or the order expired (expired), whether the confirmation reached us as a frame or was applied from the venue's records by our reconciliation; close-out flatten orders and attached legs are orders of the client and fire like any other (origin says which) |
order.rejected | the venue refused the order, on the answer to your request or by a later frame, or our reconciliation established that the venue never received it (reason "venue never received", rejectCode null); a refusal by our own checks is the synchronous 400 of your request and fires nothing |
rfq.quote | a live quote reached the request, once per quote id (a refreshed quote with the same id fires nothing; read the live price on GET .../rfq/{rfqId}); subject to the pending cap of 16.3 |
rfq.accepted | the request traded (an opening, or an early close when closeContractId is set) |
rfq.expired, rfq.canceled, rfq.rejected | the request left OPEN without trading: the window passed, it was withdrawn (yours or the venue's), or the venue refused it (rejectCode) |
barrier.closed | a contract ended, once per contract (the settlement item of the P&L under PARTNER custody is the separate settlement.item_created and stays) |
client.categorization_changed | our compliance function applied, rejected or revoked a professional categorisation request of the client |
client.consent_required | a new version of a consent document the client has to accept was published (stage ANNOUNCED) or came into force without the client's acceptance (stage IN_FORCE), at most once per stage, version and client (section 10.5; the data below); the row was missing from this table until revision 15 while the event has been delivered since revision 10 |
incident.notice | our compliance function sent the client the DORA Article 19(3) service incident notice (revision 10, BROKER-39): once per incident and client, with the rendered notice (subject, text) for you to mirror to the client you introduced |
appropriateness_test.version_changed | PARTNER LEVEL (revision 17; the envelope's client members are null, one delivery per partner with an active webhook, never per client): a questionnaire version was ANNOUNCED (published ahead of its instant), came IN_FORCE, was WITHDRAWN before its instant, or the acceptance of the previous version was ended early (GRACE_ENDED); once per version and stage; the data below |
appropriateness_test.text_changed | PARTNER LEVEL (revision 17): the served words of a regulated text family changed without a new version: a language was added (LOCALE_ADDED), a served text was corrected (TEXT_CORRECTED, a new textRevision under the same version) or withdrawn (TEXT_WITHDRAWN, the language falls back to English); once per change; the data below. family names the family of the words: APPROPRIATENESS_TEST (the questionnaire, version the questionnaire version), LANGUAGE_ELECTION (the reserved election sentence) and, since revision 18, DECLARATIONS_MIFID (the MiFID declaration sentences of 11.4 and 11.7, version the family's key set version, read the texts read; TEXT_CORRECTED when our compliance function approved a new English revision or a corrected translation, TEXT_WITHDRAWN when a translation was withdrawn or every translation was superseded by a moved English, then with textRevision and textHash null) |
The data of margin.call, margin.call_cleared, margin.close_out_started and margin.close_out_done (from and to are NORMAL, MARGIN_CALL, CLOSE_OUT; marginLevel is null when no margin is used; on the forced start every figure is null and forced is true; riskEventId names the risk event on our side, at the time of the transition; on margin.close_out_done the figures are the snapshot at the tick that found the account flat):
{
"from": "NORMAL", "to": "MARGIN_CALL",
"equity": "812.500000", "marginLevel": "90.28", "initialMargin": "900.000000", "maintenanceMargin": "450.000000",
"marginCallPct": "100", "closeOutPct": "50",
"marginCallLevel": "900.000000", "closeOutEquity": "812.500000", "cash": "1000.000000", "unrealizedPnl": "-187.500000",
"riskEventId": 90231, "forced": false, "at": 1788400000000
}
(The example is a long 0.03 BTC-USD-PERP opened at 60,000 at leverage 2 with 1,000 USDC of cash, marked at 53,750: the initial margin is the margin required to open, 0.03 x 60,000 / 2 = 900, not 0.03 x 53,750 / 2 = 750 as before 11 September 2026, so the call is raised at 900 and the close-out level is 450.)
There is no notice period between margin.call and margin.close_out_started. The close-out starts on the first tick that finds closeOutEquity strictly below maintenanceMargin, whether or not a margin call was recorded before it (a margin.close_out_started with from: "NORMAL" is that case); the margin call at marginCallPct of the perpetual margin (100 by default) is the notice, and attached stop loss legs are the client's own protection.
The data of order.accepted, order.partially_filled, order.filled, order.canceled and order.rejected: the order of GET .../orders (section 12.3) read after the change, flattened, plus fill (the fill on the two fill events, null otherwise), reason (canceled or expired on order.canceled, the reject sentence on order.rejected, null otherwise) and rejectCode (the venue code on order.rejected, null when the frame carried none, null otherwise):
{
"orderId": 4185, "clOrdId": "BARR-4185", "symbol": "BTC-USD-PERP", "family": "PERP", "side": "BUY", "type": "LIMIT", "qty": "0.5", "price": "64250.0",
"state": "PARTIAL", "reject": null, "filledQty": "0.2", "createdAt": 1788311500000, "reduceOnly": false, "tif": "GTC", "trigger": null, "updatedAt": 1788311600120,
"attach": null, "ocoGroup": null, "parentOrderId": null, "parentClOrdId": null, "origin": "CLIENT", "note": null, "replacedBy": null, "replaces": null, "reservation": null,
"channel": "REST", "channelKeyId": null, "channelRef": null,
"fill": {"tradeId": 88002, "price": "64250.0", "qty": "0.2", "commission": "1.285000", "realizedPnl": "0.000000", "role": "MAKER", "ts": 1788311600100},
"reason": null, "rejectCode": null
}
An order your app placed with an app order command (section 22) fires the same five events from the same sites, with channel APP and channelRef the token's jti (12.3); a cancel or a leverage change sent from the app fires what the same change sent over /partner/v1 fires. The app order routes themselves answer your app, never your webhook: the events are how your backend learns what the app did.
The data of rfq.quote (premium is the maximum loss at the quoted entry, the wire's impliedPremium; acceptable and notAcceptableReason by the same rule the request's quotes carry; expiresAt the quote's validity; on a close request closeContractId names the contract and premium and impliedLeverage are null):
{
"rfqId": 311, "venueRfqId": 5207, "symbol": "BTC-USD-KO", "side": "BUY", "closeContractId": null,
"quoteId": 902, "price": "65471.5", "qty": "1", "premium": "2224.350000", "premiumBps": 9000, "impliedLeverage": "26.49",
"acceptable": true, "notAcceptableReason": null, "expiresAt": 1788311470000, "autoAccept": true, "seenAt": 1788311428000
}
The data of rfq.accepted (premium null and closeContractId set on an early close; commission the entry commission on an opening accept and, since revision 10 (BROKER-14), the early-close commission on a close accept; tradeId the venue trade id, null when the venue's answer carried none):
{"rfqId": 311, "quoteId": 902, "contractId": 4711, "closeContractId": null, "entry": "65471.5", "premium": "2224.350000", "commission": "22.243500", "tradeId": 771310, "symbol": "BTC-USD-KO", "side": "BUY", "qty": "1"}
The data of rfq.expired, rfq.canceled and rfq.rejected (state the request's terminal state EXPIRED, CANCELLED or REJECTED; reason the sentence the request carries as rejectReason, for example "The request was withdrawn at the trading venue.", "This contract is no longer open." on a close request whose contract ended first, or "This market is not currently offered." on an opening request our operator withdrew when the market was disabled (revision 10, BROKER-84), null on a plain expiry; rejectCode the venue's code on a create-time refusal):
{"rfqId": 311, "state": "EXPIRED", "reason": null, "rejectCode": null, "closeContractId": null}
The data of barrier.closed (reason is knockOut, takeProfit, expiry, earlyClose, tearUp or other; closeReason the contract's own value as GET .../barriers?state=history shows it, liquidation for a tear-up, finalSettlement, reconciler:ledger on a close inferred from the venue ledger; side the request side and direction the holder direction; pnl the settled result, premium the maximum loss, commission the opening commission and, since revision 10 (BROKER-14), closeCommission our early-close commission (0.000000 on every other close); closeTradeId the venue trade id of an early close, null otherwise, and null when the venue's close frame settled the contract before the accept answer stamped the id, whichever lands first settles: GET .../barriers?state=history carries it once the answer landed, and the contract read is the final record):
{
"contractId": 4711, "symbol": "BTC-USD-KO", "side": "BUY", "direction": "LONG", "qty": "1", "entry": "65471.5", "barrierLevel": "63000", "takeProfit": "66500",
"reason": "knockOut", "state": "KNOCKED_OUT", "closeReason": "knockOut",
"settlePrice": "63000", "pnl": "-2224.350000", "premium": "2224.350000", "commission": "22.243500", "closeCommission": "0.000000",
"openedAt": 1788311428000, "closedAt": 1788390000000, "rfqId": 311, "closeTradeId": null
}
The data of client.categorization_changed (section 11.6; status APPLIED, REJECTED or REVOKED; basis ELECTIVE or PER_SE; categorization the client's categorisation AFTER the event, unchanged on REJECTED, RETAIL on REVOKED; requestId null on a revocation no applied request produced; a withdrawal fires nothing; an applied opt-down is one APPLIED event with to: "RETAIL", the professional request it ends is closed on the file without a second event):
{"requestId": 77, "from": "RETAIL", "to": "PROFESSIONAL", "status": "APPLIED", "basis": "ELECTIVE", "decidedAt": 1788390000000, "effectiveAt": 1788476400000, "appliedAt": 1788476400000, "reviewAt": 1820012400000, "categorization": "PROFESSIONAL"}
The data of client.consent_required (section 10.5, revision 10): a new version of a consent document the client has to accept, at two instants at most per version and client, stage ANNOUNCED (the publication of a version effective later; reconsentRequired is then the client's standing at that instant, usually false since the version is not yet in force) and IN_FORCE (the version came into force and the client's standing does not name it, reconsentRequired true); gateFrom the instant the TERMS re-consent gate would refuse new positions (null while the document is not gated or our switch is off); the source reference is consent:{versionId}:{clientId}:{stage}, so a repeat is a no-op:
{"clientId": 512, "clientUid": "BARR-00000512", "partnerClientRef": "sb-usr-10457", "doc": "TERMS", "title": "Terms and Conditions", "url": "https://barriers.com/legal/terms",
"version": "2026-10", "effectiveAt": 1791158400000, "gateFrom": 1793750400000, "stage": "IN_FORCE", "reconsentRequired": true}
The data of incident.notice (revision 10, BROKER-39): our compliance function sent the client the DORA Article 19(3) service incident notice (a major ICT or operational incident on our side that affected the client's financial interests), by e-mail on our side; the event carries the incident register entry's reference, title and status (OPEN, CONTAINED, RECOVERED), its detection and recovery instants (recoveredAt null while not recovered), and the rendered notice the client received (subject, text: the same wording, so you mirror it to the client you introduced verbatim in your app or by your own channel; never our internal description, root cause or lessons), with sentAt the instant it was queued. One event per incident and client; the source reference is incident:{entryId}:{clientId} (written incident:{entryId}:client:{clientId}), so a repeat is a no-op:
{"ref": "INC-2026-0003", "title": "Order entry outage", "status": "CONTAINED", "detectedAt": 1789000000000, "recoveredAt": null,
"subject": "Service incident notice INC-2026-0003", "text": "Service incident notice. On 2026-09-10 at 09:12 UTC we detected an incident affecting order entry on the barriers platform. ...", "sentAt": 1789003600000}
Events derived from the client's notification feed (the same items GET .../notifications lists), data: {kind, ref, ts} where ref is the id of the object the notification is about:
| Event | Notification kind | About |
|---|---|---|
swap.settled | SWAP_SETTLED | a staking swap paid out (ref the swap) |
swap.defaulted | SWAP_DEFAULTED | a writer defaulted; a claim stands |
barrier.near_take_profit | BARRIER_NEAR_TP | an open contract within 10 bps of its take profit |
barrier.near_barrier | BARRIER_NEAR_KO | within 10 bps of its barrier |
barrier.expiry_soon | BARRIER_EXPIRY_SOON | 20 minutes before expiry |
margin.restriction_set | RESTRICTION_SET | a restriction was engaged on the account (ref the restriction's id; its kind, family and ground are on no read of this API today, see below); since revision 10 also the close-only pause of a legal entity whose LEI left good standing after the cure window (11.4), one delivery per family paused; the identity document pause of 10.6, one delivery at most (10.6 says why a delivery can be missed) |
margin.restriction_lifted | RESTRICTION_LIFTED | a restriction was lifted (the LEI pause included, once the registration is found in good standing again; the identity document pause on the hourly pass after the renewed date arrived) |
client.verification_decision | VERIFICATION_DECISION | a verification decision was recorded for the client (ref the decision's id): a decision of our compliance (the approval or the refusal of a level, the downgrade from MICA_MIFID to MICA included) and ALSO the grants your own calls produce (the reliance grant at onboarding and the MiFID grant), so it arrives beside client.onboarded and client.level_changed; the payload carries neither the level nor the decision, re-read GET .../verification |
money.movement | MONEY_MOVEMENT | a money movement on our own rails reached a terminal state |
statement.ready | STATEMENT_READY | a statement was generated for the client |
The feed is swept every 5 seconds per partner with an active webhook; the first sweep after activation starts at "now" (nothing older is sent). Each sweep re-reads the last 30 seconds of the feed (revision 5), so an item whose record was written a little after the time it carries is still forwarded; the re-read never produces a second delivery of the same item (one row per item per client), but an item written more than 30 seconds after its own timestamp is not forwarded: poll the feed for those. The life-cycle events above never come from the feed and have no feed item; GET .../rfq, GET .../barriers and GET .../orders/open remain the reads to converge on when a delivery failed for good.
Reading a verification decision. client.verification_decision carries {kind: "VERIFICATION_DECISION", ref, ts} and nothing else: no level, no approved or refused flag. Re-read GET .../verification (11.5) in the handler: the level, the block statuses and, on a refused block, the client-facing sentence in blocks[].reason are there. A downgrade (our compliance refusing the MiFID level of a client at MICA_MIFID: the level goes back to MICA and the blocks named in the decision read REJECTED) and a level our compliance grants in our back office reach you as this event ONLY; client.level_changed never fires for them. A rejected package (kyc.rejected: every block REJECTED, level NONE) records no verification decision, so no client.verification_decision follows it. The MiFID blocks a refusal names are locked against your MiFID writes until our compliance releases them, and the release fires no event (revision 14, the introduction of section 11): re-read GET .../verification.
No expiry event. The catalogue has no expiry event because nothing expires (10.6). The identity document pause reaches you as margin.restriction_set and margin.restriction_lifted; our periodic review fires nothing by itself.
A restriction cannot be read on this API today. margin.restriction_set carries the restriction's id and nothing about its kind (NO_NEW_ORDERS, CLOSE_ONLY, NO_WITHDRAWALS, FROZEN; an information-only kind without any trading or money effect exists too, so an event need not be followed by a refusal), its family or its ground. GET .../verification has no restrictions member, GET /trading-status (12.1) answers the venue-wide halts only, and the notification item is {kind, ref, ts, event}. You learn of a restriction from the event, and of its effect from the sentence the client's next refused request carries (11.4, 15.1).
The two questionnaire events (revision 17). Both are PARTNER LEVEL: the envelope's clientId, clientUid and partnerClientRef are null, one delivery reaches every partner with an active webhook (a sandbox partner too), and nothing is delivered per client. The data of appropriateness_test.version_changed is {stage, version, previousVersion, currentVersion, publishedAt, effectiveAt, previousAcceptedUntil, changeClass, renderingChanged, sentencesChanged, changedQuestionIds, changeNote, urgent, rehearsal, textHash, availableLocales, read}: stage one of ANNOUNCED, IN_FORCE, WITHDRAWN, GRACE_ENDED; version the version the stage is about; previousVersion its predecessor; currentVersion the version in force at the emit (after a WITHDRAWN above all; on a LATE IN_FORCE notice, one delivered after the version's instant, the version itself, its successor's notice following); previousAcceptedUntil the instant until which the previous version is accepted once version is in force (exclusive; equal to effectiveAt when nothing is accepted; moved by a GRACE_ENDED); the schedule members as on 11.1; read the path of the read that serves the version (/partner/v1/appropriateness-test?version=N). The data of appropriateness_test.text_changed is {kind, family, version, locale, textRevision, textHash, previousTextHash, previousAcceptedUntil, correctionClass, defective, read}: kind one of LOCALE_ADDED, TEXT_CORRECTED, TEXT_WITHDRAWN; family the family of the words, APPROPRIATENESS_TEST for the questionnaire (the only texts that change today; for the reserved languageElection block of 11.1 the family is LANGUAGE_ELECTION, version the master version of that sentence and read the questionnaire read of the version in force, on which the block is served); version the questionnaire version whose words changed; locale the language; textRevision and textHash the text now served (null on a TEXT_WITHDRAWN that leaves the language unserved); previousTextHash the text replaced; previousAcceptedUntil the instant until which the replaced text is still accepted on a submission (exclusive; null on LOCALE_ADDED); correctionClass COSMETIC or MATERIAL on a TEXT_CORRECTED; defective on a TEXT_WITHDRAWN; read the path of the read that serves the words (/partner/v1/appropriateness-test?version=N&locale=xx). An ANNOUNCED delivery, for example:
{
"id": 6310, "event": "appropriateness_test.version_changed", "partnerId": 7, "clientId": null, "clientUid": null, "partnerClientRef": null, "ts": 1790845200000,
"data": {"stage": "ANNOUNCED", "version": 2, "previousVersion": 1, "currentVersion": 1, "publishedAt": 1790845200000, "effectiveAt": 1793577600000, "previousAcceptedUntil": 1793581200000,
"changeClass": "STRUCTURE", "renderingChanged": true, "sentencesChanged": true, "changedQuestionIds": ["strictTakeProfitKnowledge"], "changeNote": "One knowledge question added and the warning sentence extended.",
"urgent": false, "rehearsal": false, "textHash": "sha256:<the hash of the announced version's English text>", "availableLocales": ["en"], "read": "/partner/v1/appropriateness-test?version=2"}
}
and a LOCALE_ADDED sample as the development environment delivers it on request (section 18; a sample carries data.sample: true and names the pseudo locale qps, never a real language):
{
"id": 6311, "event": "appropriateness_test.text_changed", "partnerId": 7, "clientId": null, "clientUid": null, "partnerClientRef": null, "ts": 1790845260000,
"data": {"kind": "LOCALE_ADDED", "family": "APPROPRIATENESS_TEST", "version": 1, "locale": "qps", "textRevision": 0, "textHash": "sha256:<the hash of the pseudo text>", "previousTextHash": null,
"previousAcceptedUntil": null, "correctionClass": null, "defective": false, "read": "/partner/v1/appropriateness-test?version=1&locale=qps", "sample": true}
}
A text_changed sample of the declarations family is delivered on request too (section 18; POST /webhook/test {event, kind, family} since revision 18): it names family DECLARATIONS_MIFID, version 1 and read /partner/v1/texts?family=DECLARATIONS_MIFID&locale=qps.
New stage, kind and family values may be added; ignore one you do not handle. Deliveries can arrive out of order and twice, so on any appropriateness_test.* event re-read the path read names and trust the read (11.1 for the questionnaire, 11.7 for the declarations): the read is the truth, the event the signal to re-read and to drop your cache.
Recompute the signature over the RAW request body (the bytes as received, before any JSON parsing) and the timestamp header, compare in constant time, and refuse a timestamp more than 300 seconds from your clock (replay protection). Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: the whs_ value answered by PUT /partner/v1/webhook.
// rawBody: the request body as a UTF-8 string, byte for byte.
export function verifyBrokerageWebhook(secret, headers, rawBody, nowMs = Date.now(), maxSkewSeconds = 300) {
const ts = headers["x-brokerage-timestamp"];
const given = String(headers["x-brokerage-signature"] ?? "").trim().toLowerCase();
if (!/^\d+$/.test(String(ts ?? ""))) return "missing or malformed timestamp";
if (Math.abs(Math.floor(nowMs / 1000) - Number(ts)) > maxSkewSeconds) return "timestamp outside the accepted window";
const expected = "v1=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest("hex");
const a = Buffer.from(given, "utf8"), b = Buffer.from(expected, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return "signature mismatch";
return null; // verified
}
Answer 200 only after the verification passed; process the event asynchronously if your handling takes longer than a few seconds.
The four stored statement kinds our clients get in the portal are available per client, built from the ledger, fills, contracts and funding: ACCOUNT (opening balance, every posting with a running balance, closing balance), COSTS (the ex-post costs and charges disclosure), TRANSACTIONS (the transaction confirmations; we generate the previous UTC day's statement nightly for every client with activity and issue an updated version automatically, generatedBy SYSTEM with supersedes set, when the day's records change after it), TAX (calendar year, or 6 April to 5 April for a GB tax residency) and CLIENT_ASSETS (the quarterly client assets statement).
GET /clients/{clientId}/statements?kind&limit: (bare) {statements: [{id, clientId, kind, periodFrom, periodTo, basis, generatedAt, generatedBy, title, formats, pdfBytes, sha256, version, supersedes, supersededBy}]}, newest first, limit 1 to 200. A statement row names its client by clientId only, without clientUid or partnerClientRef (revision 25, section 7).POST /clients/{clientId}/statements (TRADE), body {kind, preset, from, to, year}: preset one of LAST_30_DAYS, LAST_MONTH, THIS_YEAR, LAST_YEAR, CUSTOM (with from and to, YYYY-MM-DD, at most 24 months), SINCE_LAST_STATEMENT, LAST_QUARTER, THIS_QUARTER; year for TAX (up to 5 years back). Answers the new row (bare). 30 generations per client per hour (429 code 4290).GET /clients/{clientId}/statements/{sid}: the row with its model (bare). The model's account names the client by clientId and by the uid as clientRef, the uid the PDF prints beside the client's name: an opaque uid like any other (section 7), to be matched as served.GET /clients/{clientId}/statements/{sid}/pdf and /csv: the file as an attachment (application/pdf, text/csv); 404 code 4004 when the kind offers no CSV.Statement PDFs are deterministic (the model's SHA-256 is in the footer and in sha256); a statement is never deleted, a correction is a new version that names the one it supersedes.
The custody caveat of the quarterly CLIENT_ASSETS statement: its balances note and its MiCA note read the partner's custody model (section 13). Under BROKER custody the client's money is with us and the notes say so. Under PARTNER custody the USDC balance is described as the mirror, one to one, of the collateral you hold for the client under your own licence as your collateralAsset ("its own stablecoin" when none is recorded), PM MTF Ltd as holding no funds for the client, the economic results of the client's trading as settled between you and the client in that asset, and only the client's crypto-asset holdings and the part of its balance deployed as margin or for delivery as recorded on PM MTF Ltd's member account at the Kronos X MTF; the statement of position for the mirrored crypto-asset is yours, and the statement stays the client's statement of position for its spot holdings under MiCA. The shared disclaimer and every other kind are unchanged.
"Sandbox" here is the partner flag of this section and the development environment, never the perpetual demo of section 23 (revision 28): the orders of a sandbox partner's clients go to the development venue like every order and their money settles at once, while a demo account is virtual USD that reaches no venue at all, on any environment where your demo switch is ON.
On the development environment:
sandbox (shown on GET /partner/v1/me) under BROKER custody has deposit notices confirmed inside the POST and withdrawal requests approved and paid inside the POST, with the decision note "sandbox partner: instant settlement", so a full money round trip needs no finance action. The webhooks fire exactly as they would in production, in the same order. The flag can only be set on the development environment (revision 3): a partner record on the production environment never carries it, so the production money path always waits for our finance desk.PARTNER-custody partner's collateral reports are booked at once in every environment and its settlement items are produced by the same ledger hook as in production; the sandbox flag changes nothing for it. The development venue's fills, funding and barrier events produce real settlement items, so a settlement integration can be exercised end to end (the interfirm records are written by our finance on request).order.accepted and its fills, a barrier request its quotes and its end, a contract its barrier.closed, and a position the mark moves against delivers a real margin.call (then margin.close_out_started and margin.close_out_done when the equity reaches the close-out level). Because a market maker connected to the development venue quotes every barrier request within seconds, rfq.quote is the busiest event there: keep your endpoint answering and watch pending against maxPending on GET /webhook (section 16.3).leiStatus reads FORMAT_OK and counts as good standing: the MiFID level of a legal entity is granted on the format check alone on the development environment (where the lookup runs, a FORMAT_OK status is looked up at the grant first, 11.4). The document table of 9.7 binds the development environment exactly as production: the four groups, the register extract dated within the last 90 days, the legal-form kind and the listed-market carve-out (the list is empty there, so it never applies). The entity itself (by legal name and LEI) and every person are screened against the same sample list as a natural person: a person among kyb.persons named "Screening Testsanction" born 1970-01-01 exercises the PENDING_REVIEW path with the case naming that person.requestId). The cooling period after an approval is our configuration (24 hours by default) on the development environment as well, so client.categorization_changed {status: "APPLIED"} follows an approval within a minute of the end of that period (our scheduler applies it; no read of yours is needed), while a rejection or a revocation delivers at once. A scoped request or one with another warning version stays PENDING until our officer records the published values or rejects it.gated reads false, gateFrom null and no route answers code 4035 with the TERMS sentence and {doc, currentVersion, gateFrom} there; the consent standing, the acceptance record and the client.consent_required webhook work exactly as in production (ask our operations team to publish a test version of a document to exercise the two stages).data.error "KID_REQUIRED" until the market's current document is acknowledged through POST .../consents {doc, version}, and with the sentence "The key information document for this market is not available yet, so this contract cannot be requested by retail clients." and data.currentVersion null while no document is published for the market (kid and kidStrict read null on GET /market-info then); a strict take profit request or preview answers data.error "RISK_DISCLOSURE_CONSENT_REQUIRED" while a Risk Disclosure version is registered and the client has not accepted it; over FIX both are 297=5 9110=4035 with the sentence in Text(58). The development environment's documents are placeholder PDFs published by our operations team: read the kid block of every barrier market you test and ask our operations team to publish the document of a market whose block reads null; a PROFESSIONAL client and the preview, the accept, the cancel and the early close are never gated.HYPE-USD-PERP, DOGE-USD-PERP, ZEC-USD-PERP, NEAR-USD-PERP, TAO-USD-PERP, US500-USD-PERP ("US 500"), US100-USD-PERP ("US Tech 100"), XAG-USD-PERP ("Silver") and USDJPY-USD-PERP ("USD/JPY") and the nine -USD-KO twins. USDJPY-USD-PERP and USDJPY-USD-KO are USD-settled quanto contracts: the price is the USD/JPY rate in yen per dollar and each unit pays 1 USD per point of that rate, never converted at the live rate (unitLabel null); JPY-USD-PERP and JPY-USD-KO (1,000 JPY units) stay offered beside them. SP500-USD-PERP, SP500-USD-KO, NDX100-USD-PERP and NDX100-USD-KO then read offered false there (the two knock-out markets once the documents of US500-USD-KO and US100-USD-KO are published): a new opening order or barrier request answers 400 code 4000 "This market is not currently offered.", while a reducing or closing order, the early close and every read of existing exposure keep working. Read GET /instruments and offered on GET /market-info; never hard-code the list. A RETAIL client's opening request on a new knock-out market needs that market's key information document like any other (the bullet above).crypto_asset_risk_acknowledged is accepted there, after it the key is required exactly as on production, whose cut-over date we announce with the revision 10 notice.version (11.2 and 11.3, revision 15, the code in revision 17): the development environment's cut-over date for version REQUIRED as a JSON integer on POST .../appropriateness and on POST .../appropriateness/acknowledge is 2026-11-01 (00:00 UTC); on production the requirement holds from its first day. Until that instant an absent or malformed version is scored as absent on the development environment, as 11.2 says. The same date is the development environment's cut-over for the locale and textHash requirement of 11.2 (section 21 item (2)): no questionnaire version is served in a second language there before it.version: 0: it answers 409 code 4092 with data.currentVersion and writes nothing, so your re-read and re-render path can be exercised on any client at any time without spending an attempt.next.changeClass REHEARSAL, next.rehearsal true, renderingChanged false, nothing a client sees changes); every sandbox partner with an active webhook sees it: the ANNOUNCED event, the announced version on ?version=, then IN_FORCE under a new number. Version numbers on the development environment therefore run ahead of production: never hard code one. A submission of the copy's predecessor inside its acceptance answers the scoring-only shape of 11.2 (version the copy, submittedVersion the number you sent), because the rendering is identical.POST /webhook/test with {event, stage} or {event, kind} delivers a SAMPLE of either questionnaire event to your endpoint at any time, on every environment, with data.sample true (data.sample: true; a real delivery carries no such member). A sample's version may name no real version (the ANNOUNCED sample announces the version in force plus one, some days ahead), while its read always answers: it names the version in force. A text_changed sample names the pseudo locale qps (below), never a real language.qps (11.1; revision 17, the development environment only): GET /appropriateness-test?locale=qps serves the English text with every string wrapped [!! ... !!] and its own textHash, and a submission with locale: "qps" and that hash is scored and recorded under qps, so the whole language path, the required locale and textHash included, can be exercised end to end while the environment serves English only. It is never listed in availableLocales, never counts as a second language, and is not available on production: there a read with ?locale=qps answers the English with localeFallback true and a submission with locale qps answers 400 APPROPRIATENESS_LOCALE_NOT_AVAILABLE, exactly like any language we do not serve. Since revision 18 the same holds on the texts read (11.7): GET /texts?family=DECLARATIONS_MIFID&locale=qps serves every sentence wrapped with its own textHash, and declarationsMifidText {locale: "qps", textHash} is accepted and recorded under qps on the development environment; a text_changed sample with family DECLARATIONS_MIFID names it.PENDING_REVIEW path when the sample screening list is installed.https://dev.brokerage.perpetuals.com/app, set in our router's configuration (empty on a host that serves no app order command, where every /app/ path answers 404). Our own partner simulator SBSIM walks the whole path there with a development key set that our edge host serves at https://dev.brokerage.perpetuals.com/dev/jwks/{name}.json (every key id of such a set starts dev-, which a production router refuses outright) and tokens minted by a tool of ours; your own issuer and key set URL are configured for you by our operations under four eyes like on production, and your web app origins are listed at our edge (22.5). The development key set path is ours alone: publish your keys at your own https URL.| Revision | Date | Change |
|---|---|---|
| 28 | 2026-10-24 | PERPETUAL DEMO ACCOUNTS (issue #122, the operator's decisions of 2026-10-24; section 23; OpenAPI 2.13.0; docs/12 revision 21 with its new section 13). Numbered 28 at its rebase onto revision 26, after revision 27, which an open change claims; nothing of this revision depends on revisions 25 to 27. ADDITIVE under section 21: nothing changes for a partner whose demo switch is OFF, the default. A partner may open ONE virtual USD perpetual account per client (10,000.00 USD at the open and after a reset, top-ups of 1.00 to 10,000.00 a call and at most 90,000.00 per reset, adultConfirmed: true as the partner's attestation that the client is 18 or older) and trade it from its backend under /partner/v1/clients/{clientId}/demo/perp with the live order body: 19 routes (23.5), every answer in the envelope with demo: true, the demo's own identifiers DPA-, DPO-, DPF-, DPJ-. The economics are the live ones (the client's live leverage caps, margin, margin call, the close-out, negative balance protection, the client's commission scheme, funding at the venue's settlements) with FILLS AT THE MARK: an immediate fill takes the whole quantity at the mark of a venue ticker read at the instant of the fill and nothing while the book's opposite side is empty, a resting limit fills at its own price when the mark reaches it, every trigger is evaluated on the mark; live fills differ by the spread and the depth of the order book (23.4, 23.11). No league, ranking or reward of any kind. A 30 day retention (23.3) and the deletion on request (DELETE .../demo/perp/account). New: the code 4047 DEMO_NOT_ENABLED (section 4; no demo route answers 4030, 4035, 4036, 4041, 4043 or 4044), the data.error values NO_DEMO_ACCOUNT, DEMO_ORDER_NOT_FOUND, ACCOUNT_CLOSED and DEMO_AT_CAPACITY on the demo answers, GET /me demo (section 8), the demo's own rate windows (section 6), the seven keyed demo writes (section 5), the eight demo.* webhook events, delivered after every live event and after rfq.quote and never counted toward the live rfq.quote cap (16.3, 16.4, 23.9), the served notice naming PM MTF Ltd with the virtual funds and simulated results sentences, English master and not yet approved copy (23.10). FIX (section 20) and the app order commands (section 22) carry no demo; "sandbox" (section 18) is not the demo. |
| 27 | 2026-10-21 | The 24 hour volume served as null (issue #100, our operator's decision of 2026-10-21; OpenAPI 2.13.1: TickerRow.volume24h typed string or null with its description, quoteVolume24h of the Tickers rows typed string or null as the router has always served it, and the revision sentence; no route, enum value, code or event moved and no other member changes on the wire; docs/12 revision 20). Numbered 27 by revision 28, which reserved the number and was published first; nothing of this revision depends on revisions 25, 26 or 28. The venue's ticker figure that volume24h carried is the traded base quantity of every trade since the venue's gateway process last started and never drops a trade older than 24 hours (it restarts with each venue restart and can differ between venue nodes), so it was not the 24 hour volume its name says. Since this revision GET /ticker answers data.volume24h null (every other member of data as the venue sent it) and GET /tickers answers volume24h null on every row, until the venue's rolling 24 hour window is live on the environment you use; that return will be a revision of its own (12.1). Before this revision volume24h was a string on every answer of both routes, never null. quoteVolume24h on GET /tickers is unchanged (the 1 hour candles, null while no candle of the last 24 hours could be read for the market, as before), and 12.1 now lists the members of a GET /tickers row. Section 21: a retyping of volume24h, a BREAKING change under that section, made on /partner/v1 as an EXCEPTION to its breaking rule by our operator's decision of 2026-10-26, in force at the publication of this revision (nothing is live and no partner has gone live). What to change on your side: read volume24h as nullable and show no base 24 hour volume while it is null; read the traded value of the last 24 hours from quoteVolume24h on GET /tickers, which is null while no candle of that window could be read for the market. |
| 26 | 2026-10-20 | The conditional limit band (issue #94; OpenAPI 2.12.6: the descriptions of PlaceOrderRequest.trigger.limitPrice and AttachLeg.limitPrice and the revision sentence, no schema, route, field, enum value, code or event moved; docs/12 revision 19; docs/14 revision 15; docs/13 revision 6). Numbered 26 at its rebase, after revisions 20 and 23 to 25, which were published while it was open; nothing of this revision depends on them. The correction of a defect that undermines a regulatory control (section 21: shipped at once, the "what to change on your side" sentence there). The venue checks a plain limit price against the market's price band but neither the trigger nor the limit of a conditional order (at placement, on amend or when it fires), so a client's triggered STOP_LIMIT or TAKE_PROFIT_LIMIT could rest and trade far from the market or sweep the book past the venue's market-order protection (reproduced on 2026-10-20 against the venue's code: a stop limit sell with limit 1 traded three bid levels down, a stop limit buy rested 23 percent over the mark and was filled there). Since this revision POST .../orders, the preview, POST /app/v1/orders and its replace, and the FIX 35=D refuse, before anything is written and before the venue, a conditional limit order whose trigger.limitPrice lies outside the market's band (priceBandBps on GET /market-info) around the price at which it becomes a limit order: its trigger price, or the mark when the mark has already reached the trigger (12.3); 400 code 4000 with orderId null and the sentence (the app replace 4001 before its cancel, the old order standing; the preview accepted: false with the sentence; FIX 150=8 103=99 9110=4000, docs/14 3.4). The trigger price is not bounded. Reduce-only orders are checked too; on a market that publishes a band a conditional limit order needs a fresh mark (the existing "There is no fresh mark price for ..." sentence, a reduce-only one included, where before it passed; a reduce-only STOP_MARKET or TAKE_PROFIT_MARKET still passes); while the venue's instrument list cannot be read the existing specification sentence. An attach leg's limitPrice is checked against the band around that leg's price when the parent is placed; the legs placed after the fill are never refused by it. A market that publishes no band has none. |
| 25 | 2026-10-22 | Documentation only, no wire change and no behaviour change (issue #104; OpenAPI 2.12.5: the descriptions of clientId and clientUid on every schema that carries them, of the {clientId} path parameter and the clientId query parameter of GET /settlement/items (15.3), of the app token's sub on the security scheme partnerUserToken, of PlaceOrderResult.clOrdId, Rfq.clOrdId and Statement.model, and the revision sentence; no schema, route, field, enum value, code or event moved; docs/12 revision 18; docs/14 revision 14). Written from a partner's question of 2026-10-22 (why two client identifiers, and whether they are the same with a prefix) and on our operator's decision of the same day to publish the uid as an opaque identifier. Section 7 now says what the identifiers ARE: clientId is our numeric account number of the client, clientUid the client's stable pseudonymous identifier, the one under which the client's orders reach the Kronos X MTF; both are permanent and one to one with the client. THE UID IS OPAQUE: store clientId and clientUid exactly as we return them, send the uid back exactly as we issued it, and never compute one from the other; its format is not part of the contract and may change for clients created later. Stated as built (sections 7, 3.5, 9.1 and 22.3, docs/14 section 2): the uid is accepted only exactly as issued, and any other spelling of it is refused like an unknown client, 404 code 4004 on a route, 35=j 380=1 9110=4004 on FIX and the one 401 code 4010 as the sub of an app order token; BARR- also begins our order references (BARR-{orderId}, the order id unpadded), our barrier request references (BARR-RFQ-{rfqId}) and our swap references (BARR-SWP-{swapId}), which are not client identifiers and are never sent where a client is named; a client of another partner is refused the same way on each channel; the margin read and the statement list rows carry clientId only (12.5 and section 17), while a single statement's model.account.clientRef (printed on its PDF) is the client's uid; your partnerClientRef is on the webhook delivery envelope, not on the rows of the delivery log. Corrected: the barrier request example of 12.2 printed its clOrdId in a form the router never writes (the prefix without its second hyphen); it reads BARR-RFQ-311, the router's own prefix BARR-RFQ-. What to change on your side: nothing, if you already store both identifiers as returned and never build a uid from an id. |
| 24 | 2026-10-20 | THE AMOUNT OF A FUNDING ITEM IS THE VENUE'S OWN ARITHMETIC, and its refs say how to reproduce it (issue #93; 15.3; OpenAPI 2.12.4: the SettlementItem.refs description and the revision sentence; docs/12 revision 17). Numbered after revisions 20 and 23, which are published in their own changes; nothing of this revision depends on them. A CORRECTION of the amount, and an ADDITIVE wire change under section 21. Until this revision a client's funding charge was minus its signed position times fundingPerUnit, rounded half-even to 6 places, as 15.3 said; but the venue's fundingPerUnit is the charge of ONE LOT rounded to a millionth and divided by the lot, so that product differs from what the venue charged us (on a position of 10,000 at a mark of 76.37 and a rate of 0.0001: 76.00 against 76.37, every 8 hours) and the difference stayed with us. From the deployment of this revision each client's charge is the venue's own figure: minus the signed position times the settlement's markPrice times its fundingRate, rounded half-even to 6 places, and the settlement's rounding remainder (the venue's charge on our net position minus the sum of our clients' rounded charges, less than one millionth per client of ours holding a position) on the ONE client of ours holding the largest position in that market, chosen across ALL our clients (direct clients and every partner's, not yours alone), as the venue attributes it; the sum over all our clients is exactly the venue's charge, the sum of your items of a settlement is not. The refs of a FUNDING item gain three members after side, on the settlement's charge and on a restatement: fundingRate and markPrice (the venue's rate and mark of that settlement, decimal strings) and roundingRemainder (a JSON integer in millionths of a USDC, signed like amount, non-zero on at most one item of the settlement across all our clients, so usually 0 on every item you receive; non-zero on yours means your client held our largest position in that market at that settlement), so amount = -round_half_even(qty x markPrice x fundingRate, 6 places) + roundingRemainder / 1,000,000 on the charge, and that figure minus previouslyPosted / 1,000,000 on a restatement. fundingPerUnit stays on the charge's refs as the venue's display figure; never compute an amount from it. A settlement charged before the deployment keeps its amount and is never restated to the new one; its items, and a restatement of it within the seven days of 15.3, carry the earlier shapes without the three members. The one exception: a settlement whose charging a fault on our side interrupted before the deployment and which we completed after it is charged the new way, and its items posted before the deployment are restated to the new amount (qty equal to previousQty, the size of the difference between the two ways). Since this revision a charge can also be restated by the remainder alone when the position at that settlement of another client of ours, yours or not, was restated and the remainder moved (qty equal to previousQty). |
| 23 | 2026-10-20 | The venue's outcome-unknown answer (issue #69; section 21: a defect correction ships at once; no OpenAPI change: no schema, route, field, enum value, code or event moved, and the shared 502 response VenueUnavailable that the cancel, the request, accept and close and the staking take already declare keeps its description, "a trading write usually stays in flight and the poll resolves it", which covers a write the venue could not confirm as it covers one whose answer was lost, the two being resolved the same way; the cancel's own sentence is stated here and in section 12; docs/12 revision 16; docs/14 revision 13). Numbered after revision 20, which is published in its own change; nothing of this revision depends on it. The venue answers a write it could not confirm with its code 5000 "outcome unknown" (today an unhandled fault of a single write; once the venue's pending change lands also a write it had taken in before a leader change of its cluster), and a proxy between us and the venue can answer a 5xx without a venue code: either way the write may still take effect. Before this revision we read such an answer as the venue's refusal: an order answered 400 code 4000 "The trading venue refused the order: outcome unknown ..." and was REJECTED with order.rejected (FIX 150=8) although it could be live; a barrier request or close request was REJECTED with rfq.rejected; an accept or a staking take answered 400 (the request free to be accepted again, the swap REJECTED); a cancel answered 400 with the venue's text (FIX 35=9). Now each answers what a venue transport failure of the same call answers (12): an order 200 with state SENT (stored under the Idempotency-Key; over FIX nothing on the session), then order.accepted from our reconciliation, or order.rejected "venue never received"; a barrier request or close request 502 code 5020 with data.rfqId (FIX 297=10); an accept 502 code 5020 with accepting: true; a staking take 502 code 5020 with {swapId, offerId, accepting: true}; a cancel of an order or a request 502 code 5020 "The trading venue could not confirm whether the cancel took effect. Please check whether it is still open and cancel it again if it is.", the object unchanged until the venue's confirmation or our reconciliation moves it (FIX 35=F: 35=j 380=4 9110=5030; FIX 35=Z: 35=j 380=0 9110=5020 with that sentence; never a 35=9, docs/14 3.5, 7.5 and 8.3); the app cancel and replace their pending answers, recorded PENDING (22.7, 22.11). The venue's 5001 (engine busy) and every other venue code answer as before. What to change on your side: nothing if you already read the object back after a SENT order, a 502 or a pending answer; never enter an order again whose answer was SENT before you read it back, and never read a 502 on a cancel as "not cancelled". |
| 22 | 2026-10-19 | Documentation only, no wire change (issue #75; OpenAPI 2.12.2: the descriptions of SettlementItem.type and SettlementItem.refs and the revision sentence, no schema, route, field, enum value, code or event moved; docs/12 revision 15). Numbered after revision 20 (OpenAPI 2.12.1), which is published in its own change; nothing of this revision depends on it. THE FUNDING RESTATEMENTS, as the router has posted them since the funding pass-through, now stated (15.3, the introduction of section 15, 15.7): our reconciliation re-derives every funding settlement of the last seven days from the client's position at that settlement as our records then hold it, and when that position changed after the charge (a fill that reached our records late from the venue, a corrected measuring instant) it posts the DIFFERENCE as a further FUNDING ledger transaction, never an edit; under PARTNER custody that posting is a settlement item of its own, with its own itemId, settlement.item_created delivery and occurredAt (the time of the restatement, possibly days after the settlement), and refs {symbol, settledAt, qty, previousQty, previouslyPosted, side}: no fundingPerUnit, previouslyPosted a JSON integer in millionths of a USDC (the sum of the earlier FUNDING items of that client and settlement), side FLAT when the restated position is none, and a charge restated to zero yields an item that offsets the earlier ones exactly. One settlement can be restated more than once, and a client first found with a position by such a run is charged late with an item of the first shape. 15.3 says how to book them: key the items by itemId, never assume one FUNDING item per client, symbol and settlement, never require fundingPerUnit, book a late item at its own occurredAt (never at refs.settledAt); it counts in the interfirm net like any item. Also stated: qty in the refs of a funding item is the SIGNED position (negative for a short), and refs.settledAt is the settlement's time, not the item's own settledAt. |
| 21 | 2026-10-13 | The app order commands (issue #58; section 22; OpenAPI 2.12.0; docs/12 revision 14 with the walk-through of its section 12; docs/14 revision 12). Numbered after revision 20 (an Idempotency-Key reused with another body answers 409 code 4092, OpenAPI 2.11.2), which is published in its own change; nothing of this revision depends on it. ADDITIVE under section 21: nothing changes for a partner whose app orders are OFF, the default. Your app may send its signed-in users' perpetual order commands to us DIRECTLY, each request carrying a short-lived token your backend signs: POST /app/v1/orders (the place body and answer of 12.3), DELETE /app/v1/orders/{orderId}, POST /app/v1/orders/{orderId}/replace (a cancel and a new order, {orderId, clOrdId, state, cancelledOrderId}, 400 codes 4001 and 4002) and PUT /app/v1/leverage ({symbol, leverage}, never forced: 409 code 4090), on the edge host under the new base path /app/v1 (section 2), perpetual markets only. The token (22.3): ES256, the claims exactly iss, aud (ours: https://dev.brokerage.perpetuals.com/app on the development environment), sub (our client uid), iat, exp (at most 5 minutes) and jti, 60 seconds of skew; your key set at an https URL our operations configure under four eyes, with the rotation term (publish a new key at least 15 minutes before its first use, keep the old one until its last token expired) and a deny list of key ids (22.4); CORS for your web app origins at our edge (22.5). Every authentication failure is ONE 401 code 4010 with WWW-Authenticate: Bearer error="invalid_token"; a suspended partner 403 code 4037; 429 code 4290 and 503 code 5030 with Retry-After (22.6, section 4). A client who cannot trade is answered 403 on the place and the replace: 4030, 4035, 4041, and the NEW code 4044 TRADING_RESTRICTED for a client restriction, a trading halt, an account not active or a legal-entity account (22.6, section 4; your backend's routes keep their 400 code 4000 for those). The Idempotency-Key is REQUIRED there, scoped to the client, looked at only after the token was accepted (22.7, section 5); the windows are per user, per caller address and an app ceiling per partner, never the Partner API's (22.9, section 6). We record every command with its token and jti, kept as long as the orders (22.11). New: appOrders on GET /me (section 8); the value APP of channel on the order records and order.* events with channelRef the jti (12.3, 16.4); over FIX such an order reaches the drop copies only, 9121=APP without ClOrdID(11) (section 20); the code 4043 SERVER_ORDER_ENTRY_CLOSED (22.10): our operations may close a partner's server-side order entry (its REST order, cancel and leverage writes and FIX 35=D and 35=F) only on our compliance function's decision, announced at least 30 days ahead under section 21, no date set. Section 1 states the go-live condition on order entry: on the reading of our lawyers relayed by our operator on 2026-10-07, operating your app is not reception and transmission of orders on your part provided the order-entry parts are operated by PM MTF Ltd and the app clearly tells the user so, recorded on your go-live record before reliance is approved (docs/12 10.6). The development environment (section 18): the audience above, our partner simulator's development key sets on the edge host. A draft of a different design for the same need (sealed order commands relayed by your backend) was shown on the development edge from 2026-10-07 and withdrawn on 2026-10-09 without being published here; nothing of it is served. |
| 20 | 2026-10-20 | The correction of a defect in the Idempotency-Key comparison (issue #52; section 21: a defect correction ships at once; OpenAPI 2.12.3, description strings only, no schema, route, field, enum value, code or event moved; docs/12 revision 13). Numbered 20 by revision 21, which reserved the number and named this change OpenAPI 2.11.2, named 2.12.1 by revision 22, and published after both: the served document moves on from revision 22's 2.12.2 to 2.12.3, never back to a lower version, and carries the three revisions. Before this revision the store identified a keyed request by its method and its path only, so the same key on the same path with ANOTHER body was answered with the FIRST request's stored answer and the second request was never executed: a second order sent under a used key answered 200 with the first order's orderId and never reached the venue, a second deposit notice, collateral report or barrier request likewise, while your system read a success. Now the body is part of the comparison (section 5): the same key with another body answers 409 code 4092 "This Idempotency-Key was already used for another request." and executes nothing, in flight or answered; the same JSON content (members in any order, any whitespace) replays the first answer byte for byte as before; a body that is not JSON is compared byte for byte. The sample deliveries of 16.1 follow the rule (another {event, stage} under a used key answers 409 code 4092, no longer the first sample). A key first used before the deployment of this revision keeps the old comparison for the rest of its 24 hours. The app order commands of section 22 are unchanged: their key has compared the method, the route, the path's order id and the SHA-256 of the body bytes since revision 21 (22.7). FIX is unchanged: a ClOrdID(11), QuoteReqID(131) or QuoteRespID(693) reused with another payload has been refused since revision 9 (docs/14). What to change on your side: nothing if you mint one key per request and reuse it only to retry that request unchanged; if you reuse a key for a request your user changed (another quantity, another amount) or derive one key for several requests, mint a new key per request, and treat this 409 as "not executed": send the request again under a new key. |
| 19 | 2026-09-28 | The correction of a defect that undermined at-most-once execution (section 21: a defect correction ships at once; OpenAPI 2.11.1, the Idempotency-Key description and the revision sentence, no schema, route, field, enum value, code or event moved; docs/12 revision 11; docs/14 revision 11). Before this revision a trading write whose venue call had succeeded and whose next step failed on our side (a database fault, as in our store incident of 2026-09-27) answered 500 and GAVE THE KEY BACK, so a retry with the same Idempotency-Key, or a resend of the same ClOrdID(11) or QuoteReqID(131) over FIX, reached the venue a SECOND time: a second order, barrier request or staking take for one request. Now POST .../orders, POST .../rfq, POST .../barriers/{contractId}/close and POST .../staking/{offerId}/accept mark their key just before the venue call and never give it back afterwards (section 5): a failure on our side after the venue call answers the route's pending answer, the one a venue transport failure gives (an order 200 with state SENT; a barrier request or close request 502 code 5020 with data.rfqId; a staking take 502 code 5020 with data {swapId, offerId, accepting: true}), stored and replayed under the key; when even that answer cannot be stored, the key answers 409 code 4092 "still in progress" until the claim is five minutes old and then replays the pending answer. Over FIX the order answers nothing on the session (its reports follow our reconciliation) and a resend of its ClOrdID restates it as 150=I 9117=Y; a barrier request answers its creation report (docs/14 3.3 and 8.3). A failure BEFORE the venue call still gives the key back, as before. What to change on your side: nothing if you already retry a 5xx under the SAME key and read the object back after a pending answer; if you re-sent a failed trading write under a NEW key, stop, and read the state back first (GET .../orders/open, GET .../rfq, GET .../staking/swaps). |
| 18 | 2026-09-23 | The MiFID declaration sentences served as the regulated text family DECLARATIONS_MIFID (the declarations tranche of the partner questionnaire work, docs/design/partner-declarations-family-2026-09-23.md; OpenAPI 2.11.0; docs/12 revision 10). A WIRE CHANGE, ADDITIVE under section 21, in force on the development environment from the router deployment that follows this publication (the reference page at /docs is live at the merge, the OpenAPI and the behaviour arrive with that deployment). (1) THE READ (new 11.7): GET /partner/v1/texts?family=DECLARATIONS_MIFID&locale=xx serves the three sentences the keys of declarationsMifid stand for as an ARRAY in the key order shown to the client, with family, masterVersion (the family's key set version, 1), asOf, the language members of 11.1 (locale, requestedLocale, localeFallback, availableLocales) and textRevision and textHash; family is REQUIRED (an absent, unknown or other family 400 TEXT_FAMILY_UNKNOWN); the pseudo locale qps on the development environment (section 18). (2) THE SENTENCES (11.4): the three sentences are printed AS SERVED AT THIS PUBLICATION, revision 1 with its hash sha256:c04b33d65088c08d79bb62a3333d0741c098c541885e18f92e20ec50ecbaeafe, the text ids of revision 15 kept as the label of revision 1, the sentence behind complex_products_risk_acknowledged held in revisions 15 to 17 now printed; THE READ IS THE TRUTH: render what GET /texts serves, never the table; a sentence may change at any instant by an approval of our compliance function, in force at the instant (the content rule of section 21), appropriateness_test.text_changed with family DECLARATIONS_MIFID is the signal (16.4), the display duty of section 8 applies to the served revision. (3) THE WRITE (11.4): PUT .../mifid takes the OPTIONAL top level object declarationsMifidText {locale, textHash} (the read you rendered before the user acknowledged the three keys) under 11.2's rules with the labels DECLARATIONS_TEXT_HASH_REQUIRED, DECLARATIONS_LOCALE_REQUIRED, DECLARATIONS_TEXT_HASH_UNKNOWN, DECLARATIONS_LOCALE_NOT_AVAILABLE (each a field of the route's 400 validation map under mifid.declarationsMifidText.locale or .textHash, the label the first word of the sentence, and data.error carrying the label beside errors, additive) and the 409 code 4092 DECLARATIONS_TEXT_WITHDRAWN (re-read, re-render, a new Idempotency-Key); every acknowledgement is RECORDED with the revision and the hash of the words (attested when you sent the hash, else the current English assumed), the 200 carries declarationsText and GET .../verification (11.5) the latest record. (4) THE EVENT (16.4): appropriateness_test.text_changed carries the new family value DECLARATIONS_MIFID (version the key set version, read the texts read); the catalogue's rule that on any appropriateness_test.* event you re-read is stated as re-read the path read names; POST /webhook/test {event, kind, family} delivers a sample of the family (section 18). (5) THE RECIPE (11.1): the lines of a sentences family (t.<key>=) with a worked example and its hash beside the questionnaire's. (6) SECTION 21 gains two dated items: the two members of declarationsMifidText VALIDATED from the deployment of this revision (item (4); the same class as revision 17's item (3)), and declarationsMifidText.locale and .textHash REQUIRED by content exactly like 11.2's (item (5): production from its first day, the development environment serves no family in a second language before 2026-11-01 00:00 UTC; send both always and it never meets you). Nothing removed, retyped or given another meaning. Pinned by PartnerOpenApiTests, PartnerOnboardingTests, PartnerWebhookTests, RegulatedTextsTests and DeclarationTextsParityTests; please confirm that your client ignores unknown family values (section 21). |
| 17 | 2026-09-23 | The announced questionnaire versions and the language mechanism (the partner questionnaire work, increment 1B and the language mechanism of its build specification; OpenAPI 2.10.0; docs/12 revision 9). A WIRE CHANGE, in force on the development environment from the router deployment that follows this publication (the reference page at /docs is live at the merge, the OpenAPI at /openapi.json and the behaviour arrive with that deployment; the changelog row of /docs and the served /openapi.json may differ until then, section 21). (1) VERSIONS ARE ANNOUNCED AHEAD (11.1, section 8, 16.4): GET /appropriateness-test carries the schedule (status, publishedAt, effectiveAt, asOf, previous {version, acceptedUntil, scoredUnder}, next {version, publishedAt, effectiveAt, previousAcceptedUntil, changeClass, renderingChanged, sentencesChanged, changedQuestionIds, urgent, rehearsal, changeNote, textHash, availableLocales, missingLocales}) and takes ?version= (the announced version ahead of its instant, the version in force, a former version; 400 and 404 as 11.1 says); GET /me carries appropriatenessTest; the partner-level webhook appropriateness_test.version_changed (stages ANNOUNCED, IN_FORCE, WITHDRAWN, GRACE_ENDED) announces every stage; the questionnaire's notice rule is stated as the read states it (at least 14 calendar days for a change you render, in practice 30, an urgent path, one pending version, previous and next.previousAcceptedUntil the truth about the acceptance of the previous version, exclusive bounds); the RENDERING RULES (never switch on your own clock, cache at most 5 minutes per version and locale, next.effectiveAt a hard expiry, drop the cache on any appropriateness_test.* event and on a stale 409, submit the version, locale and hash you rendered). (2) THE SUBMISSION (11.2, section 4, section 5): version REQUIRED as a JSON integer from the cut over announced in revision 15 (production from its first day, the development environment from 2026-11-01 00:00 UTC; AppropriatenessAnswers.required and the acknowledgement's required list move with this revision); a submission of the previous version inside its acceptance is scored under the previous version after a STRUCTURE or TEXT change (supersededBy on the 200) and under the rules in force after a SCORING_ONLY change (submittedVersion on the 200); the labels APPROPRIATENESS_VERSION_STALE (with acceptedVersions and renderingChanged) and APPROPRIATENESS_VERSION_NOT_IN_FORCE (with effectiveAt and serverTime, the ONE refusal whose Idempotency-Key claim is given back); warning {text, acknowledgeText, version} on a WARN in the recorded language; the 200's locale, textRevision, textHash, textAttested, submittedVersion, supersededBy, failure (reserved); locale on appropriateness.result, on the appropriateness object of client.level_changed, of 11.5 and of PUT .../mifid's answer, with submittedVersion and supersededBy on the last two. (3) THE ACKNOWLEDGEMENT (11.3): version REQUIRED from the same cut over, accepted when it equals the result's version or its submittedVersion, else 409 APPROPRIATENESS_ACK_VERSION_MISMATCH with resultVersion and submittedVersion and never currentVersion; locale and textHash under 11.2's rules against the result's version, the result's own hash always accepted; the answer carries version. (4) THE LANGUAGE MECHANISM (section 7, 11.1, 11.2, 16.4): ?locale= on the read (a language tag xx or xx-YY, _ for -, resolved to xx; a language we do not serve answers the English as a whole with localeFallback true, never a mix), the read's locale, requestedLocale, localeFallback, availableLocales, textRevision and textHash; the textHash recipe printed with a worked example (textHash names the words, version names the rules; equal text has an equal textHash on every environment); locale and textHash on the submission and the acknowledgement, REQUIRED by content (every version that is or ever was served in more than one language, and whenever the locale sent is not en; send both always; a write never falls back) with the labels LOCALE_MALFORMED, APPROPRIATENESS_LOCALE_REQUIRED, APPROPRIATENESS_TEXT_HASH_REQUIRED, APPROPRIATENESS_TEXT_HASH_UNKNOWN, APPROPRIATENESS_LOCALE_NOT_AVAILABLE and the 409 APPROPRIATENESS_TEXT_WITHDRAWN (a replaced text may stay acceptable until the instant appropriateness_test.text_changed states in previousAcceptedUntil; never rely on it); clientLanguage recorded, never refused; the partner-level webhook appropriateness_test.text_changed (kinds LOCALE_ADDED, TEXT_CORRECTED, TEXT_WITHDRAWN); every text of this API is still served in English and no other language is announced (availableLocales is the truth). (5) RESERVED, null today, do not build on them, a later revision defines them: languageElection on the read and on the submission, failText on the read, failure on the 200, and the 4035 label LANGUAGE_ELECTION_REQUIRED (not returned today; a date would be announced under section 21). (6) THE SANDBOX (section 18, 16.1): POST /webhook/test with {event, stage} or {event, kind} delivers a sample of either event (data.sample: true, a new Idempotency-Key per sample; a body without an event member is today's test); the rehearsal copy on request (changeClass REHEARSAL; version numbers on the development environment run ahead of production); the pseudo locale qps for the whole language path end to end. (7) SECTION 21 gains three dated items: version required on both routes (announced in revision 15), locale and textHash required by content (the development environment's date 2026-11-01 00:00 UTC, production from its first day; the requirement follows the content, and no version is served in a second language before the date), and the members and query parameters that had no meaning before this revision (locale and version on the read; locale, textHash, clientLanguage and languageElection on the writes) VALIDATED from the deployment of this revision, a tightening by the section's own definition: if your build already sends any of them, confirm that it sends a well formed value (the rehearsal checklist of docs/12 10.6 asks). What this revision does NOT do, by the decisions on file: it names no language other than English, prints no figure of any acceptance, grace, tolerance or correction window (the read states what applies), and states no standing clause and no general translation rule beyond the display duty of section 8 (open questions of our compliance function). Pinned by PartnerOpenApiTests, AppropriatenessVersioningTests, RegulatedTextsTests, PartnerWebhookTests and PartnerOnboardingTests; please confirm that your client ignores unknown response members, unknown data.error values, unknown stage, kind and family values and unknown event names (section 21). Corrected in place on 2026-09-23, before this revision's deployment, no revision bump: 11.1 and section 21 print the operating commitment (until the code of this revision is deployed on an environment you use, no new version is published on it without telling you directly beforehand; in force since 2026-09-22, ending on each environment with that deployment); 16.4 states family as the family of the words and currentVersion on a late IN_FORCE notice; 11.2 says the version check comes first among the checks of the submission itself and that a well formed clientLanguage is never refused; section 18 says the pseudo locale is not available on production. |
| 16 | 2026-09-22 | The retail risk warning is no longer required of a partner (the operator's decision of 2026-09-22: the CFD-related standard retail risk warning is not required for barrier contracts, and this reference requires it for no product offered through this API; OpenAPI 2.9.2: description strings and deprecated: true flags, additive, no schema, route, field, enum value, code or event moved; docs/12 revision 8). Section 8's display duty no longer names riskWarning.standard on the tickets or riskWarning.abbreviated on the screens that offer a trade; the eight riskWarning members of disclosures on GET /me (standard, abbreviated, reducedCharacter, lossPercent, figureDate, windowFrom, windowTo, fallback) are DEPRECATED under section 21: still served for compatibility with their shape and meaning unchanged, removed on /partner/v2; a partner MAY stop rendering them at once, and a client that still renders them renders them as served; the same members of the document our own clients read on our public route stay served unchanged (a change there is a revision of that route's own changelog). What stands: operatorDisclosure.signpost with the conflicts summary link on the account screen, the daily re-read, nothing on push notifications, and the texts a partner must still render as served (the operator disclosure, the key information documents, the declaration sentences, the questionnaire), never rephrased, translated or shortened by the partner; section 7's Language bullet follows; the example of section 8 abridges the three deprecated sentences. Section 21 records that this revision is a RELAXATION (additive, no notice period) and that the removal of the members waits for /partner/v2. Our own portal stopped rendering the three forms on the same decision, and our app does from its next build; the quarterly loss figure is still computed and signed on our side and served in the deprecated members. Open on our side: whether a barrier-specific risk sentence set is served here and required of a partner, whether the figure computation is retired with /partner/v2, the date of /partner/v2, and whether item (4) of the categorisation warning CAT-WARN-2026-09 v1 (11.6.1), which names the standardised risk warning among the protections a retail client receives, is re-versioned by our compliance function (the version in force stands as served until a new version is published and announced). The OpenAPI document 2.9.2 is served from the next router deployment; the reference page at /docs carries this revision from its publication. |
| 15 | 2026-09-21 | Documentation only, no wire change (OpenAPI 2.9.1: description strings only, no schema, route, field, enum value, code or event moved; docs/12 revision 7). The questionnaire versioning clarifications a partner asked for, stated as built and pinned (AppropriatenessVersioningTests), plus the change notice policy this reference had never written down. (1) AN ATTEMPT IS A SCORED SUBMISSION (section 11 introduction, 11.2, section 4): the call that answers 200, carries attempt, fires appropriateness.result and starts the cooling period; a refusal of the route (the stale version 409 with data.currentVersion, the 400, the 429, the other 409s) records no result, numbers no attempt, starts no cooling period and fires nothing; the version check is the first decision of the route. (2) ONE IDEMPOTENCY RULE for that route (section 5, 11.2): one Idempotency-Key per submission attempt, minted when the user taps Submit, reused only to retry the same call after a timeout or a 5xx, a NEW key after ANY refusal of the route, the 409 "still in progress" (an answer about the key) excepted; the example appr:<recordId>:<submitSeq>. (3) version: always send it as a JSON integer (11.2); today an absent or malformed value is scored as absent. ANNOUNCEMENT under the new section 21: version becomes REQUIRED, as a JSON integer, on POST .../appropriateness AND on POST .../appropriateness/acknowledge (absent or not a JSON integer: 400 code 4000), on production from its first day and on the development environment from 2026-11-01 00:00 UTC (section 18); the code ships with a later revision. (4) How a version change reaches you TODAY (11.1): at publication, no notice, no event, no field; render from the read when the user opens the assessment, never from a copy shipped with a release; version: 0 rehearses the stale path on the development environment (section 18). (5) NEW SECTION 21, Compatibility and change notice: the codification of the practice since revision 9 (additive changes at any time with a changelog row and the duty to ignore unknown members, event names and data.error, stage, status and kind values; a tightening announced at least 30 days ahead with a cut over date per environment that has live integrations, an environment that opens later strict from its first day; breaking changes only on /partner/v2; the correction of a defect that undermines a regulatory control at once; the notice figures and channels; what the questionnaire's cadence is). (6) LANGUAGE (section 7, descriptive): every text is served in English today, what is planned and that the served regulated texts are not to be translated by the partner until approved translations are served here. (7) THE DECLARATION SENTENCES (9.2, 11.4): two of the three MiFID sentences printed byte for byte with a text id each as approved by our compliance function on 2026-09-09; the complex products acknowledgement HELD until its re-confirmation; the MiCA set to be confirmed and not printed. (8) The client.consent_required row of the 16.4 life-cycle table, missing since revision 10. The OpenAPI document 2.9.1 is served from the next router deployment; the reference page at /docs carries this revision from its publication. REQUEST TO EVERY PARTNER: please confirm that your client ignores unknown response members, unknown data.error values and unknown event names, the rule section 21 now states and that revisions 9, 10 and 11 relied on. |
| 14 | 2026-09-18 | The compliance lock of the MiFID writes and the attempt cap on the partner grant path (a code review of 2026-09-18, two defects corrected; OpenAPI 2.9.0; docs/12 revision 6). BEHAVIOUR CHANGE, no new route, field or event. (1) THE COMPLIANCE LOCK (section 11 introduction, 11.2 to 11.5, 10.1, 16.4, section 4 code 4092, section 5): before this revision a repeat PUT .../mifid rewrote and approved a MiFID block our compliance function had set REJECTED, and the grant ran again, so a partner's call undid a refusal of the MiFID level (a repeated acknowledgement or a new assessment did the same when the refusal named the APPROPRIATENESS block alone). Now a MiFID block REJECTED by our compliance (the refusal of the MiFID level, or a rejected package) is LOCKED: PUT .../mifid, POST .../appropriateness and POST .../appropriateness/acknowledge answer 409 code 4092 with a sentence naming the blocks and data {error: "MIFID_BLOCKS_REJECTED", lockedBlocks}, nothing is written, no grant is attempted, the refused call is recorded on our side. The lock is lifted by an officer of our compliance function alone (a release in our back office, the invitation to submit again): the blocks then read DRAFT with decidedBy null and the client-facing sentence kept in reason; no event announces it, re-read GET .../verification, and send the new PUT .../mifid under a NEW Idempotency-Key (the stored 409 replays for 24 hours otherwise). A REJECTED APPROPRIATENESS block with decidedBy null is the client's own failed attempt and locks nothing. After a re-onboarding (10.1) the MiFID blocks rejected with the package stay locked until released. (2) THE ATTEMPT CAP (section 11 introduction): the rule of our own onboarding now binds the partner path too: a result completed after MORE THAN TWO attempts is never granted automatically; the moment both halves are in place the five MiFID blocks go SUBMITTED, the level stays MICA, the call answers 200 with granted false and the grantRefusal sentence "The appropriateness assessment was completed after more than 2 attempts: the MiFID level of this client is decided by our compliance function, and the MiFID blocks are under its review.", client.level_changed does not fire, and our compliance decides (client.verification_decision); while the blocks are SUBMITTED, PUT .../mifid answers 409 code 4092 with data {error: "MIFID_UNDER_REVIEW", lockedBlocks}. Before this revision the partner path granted whatever the attempt number. What to change on your side: treat a 409 code 4092 with data.error on the three MiFID writes as a standing to show, not an error to retry; read grantRefusal on a 200 with granted false. |
| 13 | 2026-09-18 | Documentation only, no wire change: clarifications of what happens to a client's verification after onboarding, written from a partner's questions and checked against the router (OpenAPI 2.8.2: the description of the block status enum on VerificationView and the revision sentence; no schema, route, field, enum value, code or event moved; docs/12 revision 5). (1) The EDD source-of-wealth ask DEFINED wherever it is named (section 4 code 4030, 13, 14, 15.1): it is the SOURCE_OF_WEALTH block of PUT .../mifid and nothing else, and 15.1 states its rule: the threshold risk parameter (500,000 EUR by default) converted at the operator-maintained EUR per USDC rate (0.92 by default, about 543,478 USDC), the lifetime gross sum of BOOKED collateral DEPOSIT reports (confirmed deposit notices under BROKER custody) never reduced by a withdrawal, the crossing deposit books and the NEXT report in either direction answers 403 code 4030 with data.missingBlocks ["SOURCE_OF_WEALTH"] and the quoted sentence, no report row and no webhook, trading unaffected, SUBMITTED or APPROVED satisfies it, a client sent through PUT .../mifid does not see it unless our compliance later rejects that block, a client kept at level MICA is cured only by the full four-block PUT .../mifid, on POST .../collateral the gate is checked before the partnerRef replay (the deposit notices and withdrawal requests of section 14 replay first, sections 5 and 14). (2) 11.4: no partner route submits one MiFID block alone; the four blocks are approved before the grant and whatever the appropriateness outcome; as built today a repeat PUT .../mifid rewrites and approves all four again and attempts the grant only while the level is MICA. (3) 11.5: the block status EXPIRED explained (set only on IDENTITY_CHECK of a client of our own hosted identity flow whose session expired or was abandoned; it cannot occur for a client you onboarded; no block expires by time); the read carries no enhanced due diligence standing and the requirements map listing SOURCE_OF_WEALTH on the two money actions is the one advance signal. (4) CORRECTED, 16.4 and 11.5: a partner cannot read a client's restriction today. The margin.restriction_set row had pointed at GET .../verification and the trading status, but the verification read has no restrictions member and GET /trading-status answers the venue-wide halts only; you learn of a restriction from the event (the restriction's id only) and of its effect from the refused request's sentence. CORRECTED, 11.5: RESTRICTED is a lifecycle state of its own (an account held pending a decision of our compliance, for example after a KYC revision whose re-screening needs one); a client restriction never moves lifecycleState. (5) 16.4: client.verification_decision carries {kind, ref, ts} with no level and no decision, fires for the grants your own calls produce too, and is the ONLY event of a downgrade or of a level granted in our back office (client.level_changed fires for the upgrade made through your calls and for nothing else); a rejected package records no verification decision; there is no expiry event because nothing expires; re-read GET .../verification, the client-facing sentence of a refused block is in blocks[].reason. (6) The NEW 10.6: the identity document's expiry date (kyc.identification.expiration_date is tracked on an hourly pass, identityCheck.documentExpiry is validated and not tracked; reminder e-mails to the client 60 and 30 days before; on the day after the date a CLOSE_ONLY restriction on every family, crypto spot included, the blocks, the level and lifecycleState unchanged, collateral reports, deposits and withdrawals not blocked, margin.restriction_set and margin.restriction_lifted; the cure is PUT .../kyc with the new expiration_date, effective on the next hourly pass, a date not after today refused) and our internal periodic review (every 12, 24 or 36 months by our own risk rating; nothing on the wire by itself; it can lead to a documents request, a restriction or a rejection; whether you push refreshed KYC data on your own cycle is a matter for the reliance agreement, this API states and enforces no cadence). (7) 9.3: the 366 day age of identityCheck.verifiedAt is checked at submission only. Section 11: an appropriateness result has no expiry, retakeAt is the earliest next attempt, a new questionnaire version invalidates no recorded result, the level is lost only by a decision of our compliance. (8) 10.2 and 10.3: a documents request is an officer's decision in an individual case, its note is the only carrier, it can sit on any package that is not REJECTED while the reads show the latest package's note, it has no deadline and gates nothing in our code; supporting source-of-wealth evidence, when our compliance asks for it, arrives as such a request and is uploaded with purpose: "other", unlinked to any MiFID block; this reference states no rule for when evidence is required at the threshold (our compliance function decides that in the individual case). (9) 10.5: beside the notices our own regulatory duties require, two more kinds of mail from our direct-client flow reach your clients today because that flow has no partner variant, the identity document reminders and expiry notice and the verification decision notice of our back office; these are written for a direct client of ours (our portal, the client's account with us, depositing crypto), which a client of yours cannot use, so tell your client yourself. GROWN IN PLACE by the review of the same day (documentation only, the same revision and OpenAPI version): section 4 and 15.1 carry the caveat that the ask returns when our compliance rejects the block; sections 5 and 14 state the order of the partnerRef replay and the gate per route (a deposit notice or a withdrawal request replays first, a collateral report after the closed-account check and the verification gate), and 14 that a deposit notice in a currency other than USDC counts at the USDC value recorded at its confirmation; 10.6 no longer counts what runs after onboarding as two (our screening, the LEI re-check of 11.4 and the categorisation review of 11.6 continue beside them), says that the review is opened for our compliance function, that the accept of an opening request is refused in close-only mode too (11.4 likewise), that a restriction delivery of the pass can be missed under the 30 second rule (at most one delivery, read GET .../notifications), and that the review cycle counts from the latest move of the level to MICA or MICA_MIFID; 11.5 and 16.4 name the information-only restriction kind, whose events need not be followed by a refusal; the 16.4 row of client.state_changed no longer lists a restriction among the lifecycle changes. |
| 12 | 2026-09-16 | Fractional knock-out contracts and the minimum request value (the venue's ADR-25 of 2026-09-16, Member API 3.71; docs/08 revision 69; OpenAPI 2.8.1, the RfqRequest description and the revision sentence grown in place, no schema, route, field or code moved). A barrier request or preview (POST /clients/{clientId}/rfq, POST .../rfq/preview, the FIX 35=R) worth less than the market's minNotional at the current mark is refused BEFORE anything is written, with the figure in the market's settlement currency: 400 code 4000 "The request value is below the minimum for this market (100.00 USD)." with no data, no rfqId, no request record and no rfq.rejected webhook (the preview answers accepted: false with the sentence as reason; exactly at the minimum passes; without a fresh mark the margin check refuses as before); the market's minQty is checked beside the lot grid on the request and the preview ("The quantity must be at least 0.0001."). A request the venue still refused for its minimum (the mark moved, or a quote priced below the mark: the venue values the request at the mark but every quote at ITS entry) ends REJECTED with the same sentence as rejectReason, data {rfqId, state: "REJECTED"} and the webhook, as before; a close request the venue refused for it reads "This contract is worth less than the market minimum (100.00 USD) at the current mark; it ends at its barrier, its take profit or its expiry." (the venue values a close request and its quotes at the contract's whole quantity; we run no check of our own on a close). The served VALUES move once the venue re-lists (on the development environment from the date its operations announce, on production from the first day): lotSize, qtyStep and minQty on GET /market-info and GET /instruments read the perpetual's grid on every knock-out market (0.0001 on BTC-USD-KO, 0.001 on ETH, BNB, SP500, NDX100 and XAU, 0.01 on SOL, WTI and BRENT, 1 on XRP, EUR, GBP and JPY) and minNotional reads 100; contractMultiplier stays 1, unitLabel and every price grid are unchanged, a qty on the served grid is valid whatever the grid and "1" stays valid everywhere, the symbols are unchanged and a quantity keeps its meaning. 12.2 states the rule in the qty row, the errors paragraph (with a fractional example and a refused one) and the close paragraph; section 4 names the refusal; docs/12 revision 4 section 7.2 and docs/14 revision 8 (7.1) say the same. |
| 11 | 2026-09-12 | The code audit of 2026-09-11, portal chunk 1 (B-545; docs/08 revision 57; OpenAPI 2.8.0): GET /clients/{clientId}/balances portfolioValue is computed from the same margin snapshot as GET .../account/overview and the equity history (the margin equity plus the valued holdings plus the staked principal), so a pending USDC withdrawal request's reservation is outside it on every route as it is outside equity (before, this route summed the USDC total, the hold included, a break of the amount between the two routes for as long as a request was REQUESTED); the additive portfolio.withdrawalHolds (12.5) publishes the pending USDC payouts the margin engine subtracts, so the components foot and cashTotal keeps its meaning. Nothing else moved. Grown in place by the router chunk 6 of the same audit (documentation only, B-261 / B-275): unitLabel on GET /market-info and on every row of GET /instruments names the quantity unit when the venue's listing makes it other than one base asset unit, READ FROM THE VENUE'S PUBLISHED TICK ("1,000 JPY" on the two JPY markets once the venue lists them at tickSize 0.0001, null while the venue lists 1 JPY at 0.000001, on every other market and on every row while the venue cannot be reached); the field's type is unchanged (string or null). The served value on the development environment follows the tick the venue lists: the venue's JPY re-denomination (its decision ADR18-A5 of 2026-09-09, Member API 3.70 section 2a, applied from the re-listing date its operations announce) was not on the development venue as of 2026-09-12, which listed one JPY per unit, so the label read null there; read baseAsset when unitLabel is null, as before. The two JPY display names read "JPY/USD Perpetual" and "JPY/USD Barrier". The examples of 12.1 are unchanged. Grown in place by the router chunk 7 of the same audit (B-305, documentation only, no OpenAPI change): the quarterly CLIENT_ASSETS statement of a PARTNER custody client describes its USDC balance as the mirror of the collateral you hold under your own licence as your collateralAsset, PM MTF Ltd as holding no funds for it and the statement of position for the mirrored crypto-asset as yours (section 17; the section 13 sentence on what does not change with the model qualified); the BROKER custody wording and every other kind are unchanged. Grown in place by the router chunk 9 of the same audit (2026-09-12, OpenAPI 2.8.0, B-389 and B-373): partnerClientRef must not start with BARR-, our own client uid namespace, reserved case-insensitively like every FIX reference because PartyID(448) on FIX reads a value of that shape as our uid; POST /clients answers 400 code 4000 with the field sentence partnerClientRef must not start with BARR-: that prefix is our own client uid namespace and PartyID(448) on FIX reads such a value as our uid. in data.errors.partnerClientRef before anything is looked up (9.1; the OpenAPI pattern is unchanged, the description names the rule; no existing client carries such a reference, so nothing on file changes). GET /me fix.enabled now means our FIX acceptor is LISTENING on the host you reached: false, with fix.port null, while a start failure of ours is being retried (section 8; a value rule, no field or schema moved, the Me.fix description grown in place). Grown in place by the router chunk 10 of the same audit (2026-09-12, documentation only, no OpenAPI change, B-407 / B-411 / B-415, B-416, B-422): section 13 names a sixth condition of a custody model change, your clients close-only under the settlement escalation of 15.7, which lifts on our next hourly check once the other conditions are met (so at most an hour); section 10.5 no longer promises that no mail of ours reaches your clients: the consent notices are the one notice the webhook substitutes, and the notices our own regulatory duties require (the categorisation notice, the LEI notices and reminders, the DORA Article 19(3) incident notice you also mirror, the statement notices where enabled, the address notice of section 1) still reach your clients by e-mail; section 9.7 states that the persons of a large legal-entity file are screened several at a time within a fixed budget and that a person our provider has not answered for inside it lands the client PENDING_REVIEW like a screening that could not run. Grown in place by the router chunk 11 of the same audit (2026-09-12, a value rule, OpenAPI 2.8.0 kept with the BalanceRow.staked description added, B-472): staked and stakedValue on GET .../balances, the portfolio block, the staked[] rows and the portfolio value of GET .../account/overview and the equity history count the principal STILL out with writers on an OPEN swap (the notional less the part a writer already returned to the client's cash through a partial settlement), never the returned part twice (section 7). |
| 10 | 2026-09-10 | The partner settlement tranche (BROKER-103, BROKER-105, BROKER-106; OpenAPI 2.7.0 grown in place): the second settlement tier of 15.7 (an item PENDING or FAILED past the escalation age, 120 hours by default, or an unsettled net above the lower of the USDC cap and the percentage of collateralHeld fixed in the partner agreement, puts the partner's clients in close-only until our hourly check finds both clear; the contractual standard of each item settled and reported by the end of the following Business Day and within 48 hours; suspension a separate manual measure) with the new code 4041 PARTNER_CLOSE_ONLY (section 4: 403 with data.error on an opening order, a barrier request or the accept of an opening request, and a staking take; closes, reducing orders, collateral and settlement reports and every read unaffected; 3.4 says it is a settlement measure, not a status; 12.2, 12.3 and 12.4 name it; over FIX 9110=4041, docs/14 revision 6 grown in place); the additive ledgerTxId on every interfirm settlement record of 15.6 (the ledger posting bound to the record on our books, collateralHeld moving by the payment; null only on a record written before this revision and not yet backfilled); and, documentation only, the commission scheme agreed for the partner's clients applied to every client the partner onboards from its first order as the partner's default per product family (section 1: our operations may assign an individual client a different scheme; a change never re-prices an order already filled; since 2026-09-11 a change to dearer terms for clients already onboarded takes effect after the notice period given to those clients, each of whom is notified, while a cheaper or equal change applies at once). The verification and KYB rules batch (BROKER-20, BROKER-66, BROKER-91; OpenAPI 2.7.0 grown in place): the fifteenth DECLARATIONS_MICA boolean crypto_asset_risk_acknowledged (9.2, the crypto-asset risk acknowledgement) with a dated partner grace: until the published cut-over date (00:00 UTC; 2026-10-15 on the development environment, section 18) a declarationsMica block WITHOUT the key is accepted on POST /clients and PUT .../kyc and stored as received, a present value other than true is refused at any time with "This declaration must be acknowledged.", after the cut-over the key is required; the FOURTH required document group on kyb.documents (9.7): a beneficial-ownership register extract UBO_REGISTER_EXTRACT OR the shareholder register SHAREHOLDER_REGISTER, either satisfying it, refused as kyb.documents with "Missing documents: ubo register extract or shareholder register." on a package carrying the three old kinds, and the DOCUMENT TABLE per jurisdiction and risk (a third-country entity adds the board resolution, a regulated entity its licence, a higher-risk entity, by industry, country or our HIGH rating, the shareholder register strictly, a HIGH-rated client the financial statements), the register extract's issuedOn within the last 90 days at the submit, kyb.entity.legalFormKind (COMPANY by default; a trust, foundation or partnership is not onboarded at launch, on the kind and on a free-text legalForm naming one) and listedExchange as the ISO 10383 operating MIC of the regulated market (the listed-company carve-out, while our list is seeded); a file already submitted keeps the set it was submitted under; where our registry lookup runs the MiFID grant looks up a FORMAT_OK or never-checked LEI at the Global LEI System first, once, on every grant path (11.4; FORMAT_OK grants only where no lookup is configured). Grown in place by the barrier commission decisions of the same batch (OQ-36 D16, BROKER-8, BROKER-14; OpenAPI 2.7.0 grown in place): GET /market-info gains commissionBasis (PREMIUM on a BARRIER market: commissionBps is basis points of the premium, the maximum loss, with commissionMin the per-contract minimum; NOTIONAL on PERP and SPOT) and commissionBps is a decimal number (the barrier default 100 bps of the premium, minimum 0.10 USDC per contract; 0.1 bps of the notional on the perpetuals); the early close is commissioned again on the premium at the accepted close price (closeCommission on the close accept answer, on the contract rows and on barrier.closed, the close request's commission, a COMMISSION settlement item with reason: "earlyClose" dated at the close, Commission(12) of the FIX early-close trade); every open contract reserves the estimated close commission (estimatedCloseCommission on the preview and the OPEN rows; requiredFreeMargin = premium + both; barrierMargin carries it). Also grown in place by the consents piece of the same batch (BROKER-60, docs/07 3.24; OpenAPI 2.7.0): section 10.5 (the client's consent standing GET .../consents with acceptedVia, gateFrom and gated; the acceptance recorded on the client's behalf with POST .../consents {doc, version, acceptedAt?, reference?}, channel PARTNER, 409 code 4092 with data.currentVersion on a stale version; the implied acceptance at the router's write time of the declaration blocks), the code 4035 CONSENT_REQUIRED (section 4, data {doc, currentVersion, gateFrom}; the TERMS re-consent gate on an opening order, a barrier request, the accept of an opening request and a swap take while our switch is on, never on a reducing order, an early close, a deposit or a withdrawal; over FIX 9110=4035 without 9118, docs/14 revision 6), the webhook client.consent_required (16.4, stages ANNOUNCED and IN_FORCE, once per client, version and stage, in place of the direct client's notice mail), section 12 naming the gate beside the verification gate and section 18 (the switch is off on the development environment; corrected 2026-09-11, code audit B-041: the TERMS switch is the only sandbox difference, the KID and strict consent refusals of code 4035 answer on every environment). The retail risk warning and the operator disclosure (docs/07 section 3.38; the operator decision batch, BROKER-94): GET /me gains the optional, additive disclosures object (section 8), the same document our own clients read on our public GET /v1/disclosures: legalEntity; riskWarning with the standard, abbreviated and reducedCharacter forms carrying our signed quarterly loss figure (lossPercent, figureDate, windowFrom, windowTo, fallback: false) or, while none is signed, the published range 74 to 89 percent (fallback: true, the figure and the dates null); operatorDisclosure with the signpost naming PM MTF Ltd as broker and operator of the Kronos X MTF and the conflictsSummaryUrl of the current conflicts of interest summary (null while none is published); asOf. The display duty: standard on every order and RFQ ticket, abbreviated on every screen that offers a trade, the signpost with its link on the account screen, a daily re-read, nothing on push notifications. Client categorisation (11.6): a decision now needs a GENERAL written request (a scoped request is still recorded, never approved), the published warning version in criteria.warningVersion and the statement signed at or after the warning; the route's shape is unchanged. The channel rule refined (section 1, grown in place, BROKER-104): our public sign-in, forgot-password, verification-resend and registration routes answer a partner client's address exactly as an unknown or direct one (no 4038 there any more, so our side is no oracle for your client list); the holder is mailed a neutral notice on a forgot-password request or a registration attempt; a reset with a token, the development sign-in and every direct request with a session or key keep 403 code 4038. Politically exposed persons (BROKER-47, the same day): a client whose pep section declares is_pep or has_associations, or who carries a PEP true match on our screening (for a legal entity, a PEP among its persons), lands PENDING_REVIEW at onboarding (section 9 step 7) until our senior management approves the MiCA level under our maker-checker kind MICA_PEP_APPROVAL; review.reason names it, the webhook is client.review_required (reason screening review) and the later decision arrives as client.verification_decision; no new value on the wire. The barrier listing facts of the same day (ADR18-A5, BROKER-86; additive partner-twin fields, the OpenAPI 2.7.0 grown in place): GET /market-info gains unitLabel (the quantity unit when it is not one base asset unit, 1,000 JPY on the two JPY markets since the venue's re-listing at Member API 3.70, null otherwise) and GET /instruments carries it per row; on a BARRIER market GET /market-info gains barrierExpiry {maxExplicitMs, defaultLifeMonths, venueMaxLifeHours} (the 8 h cap on an explicit barrier.expiryTs, the 12-month open-end default when it is omitted, the venue's published maximum contract life in hours; null on every other family), and an omitted barrier.expiryTs on POST .../rfq now gets the 12-month default clamped to that maximum less 60 s (expiryDefaulted true as before; over FIX the same for an omitted 9133, docs/14 revision 6). The OpenAPI document 2.7.0 (Me.disclosures, MarketInfo.unitLabel, MarketInfo.barrierExpiry, Instruments.unitLabel, the revision 10 sentence). Grown in place again by the KID documents tranche of the same batch (BROKER-95, BROKER-96; docs/07 3.24): GET /market-info on a barrier market carries kid and kidStrict (12.1: the key information document of the normal and the strict take profit contract, {doc, version, title, url, sha256, effectiveAt, acknowledged} with the public PDF at url and acknowledged null on this read, or null while none is published); POST .../consents records the acknowledgement of ANY registered document, the KID codes KID_<SYMBOL> / KID_<SYMBOL>_STRICT and ELECTRONIC_DELIVERY included (10.5), attributed on both audit streams; the barrier request answers two more 403 code 4035 refusals told apart by data.error (section 4, 12.2): KID_REQUIRED (a RETAIL client's opening request without the acknowledged current document; refused while none is published; the preview, the accept, the cancel and the early close never) and RISK_DISCLOSURE_CONSENT_REQUIRED (a strict take profit request or preview while the current Risk Disclosure version is not accepted); the strict expiry sentence reads "Strict take profit needs an expiration at most 8 hours ahead." (the binding 8 h cap, no rule change); over FIX both refusals are 297=5 9110=4035 (docs/14 revision 6). The OpenAPI document 2.7.0 grown in place (MarketInfo.kid / kidStrict as KidDocument, the two labels in the Codes sentence). The service incident notice (BROKER-39, the same day, grown in place; docs/07 3.11): the webhook incident.notice (16.4, data {ref, title, status, detectedAt, recoveredAt, subject, text, sentAt}) fires once per incident and client when our compliance function sends the DORA Article 19(3) service incident notice to a client you introduced; text is the rendered notice the client received, mirror it to the client verbatim; the OpenAPI event description names it (still 2.7.0, an additive event name). The market controls (BROKER-84, the same day, grown in place; docs/07 2.14): GET /market-info gains the additive offered flag beside enabled (12.1: our router currently offers the market; false while our operator disabled it; existing exposure stays closable) and every GET /staking/offers row gains offered the same way (12.4; the board stays unfiltered, GET /staking/markets omits the market); while a market is disabled the ACCEPT of an OPENING barrier request is refused 400 code 4000 with the sentence "This market is not currently offered." exactly as a new request is, manual and automatic (the automatic accept skips its pass with the sentence as autoAcceptNote and takes the same quote once the market is back), over REST and FIX alike (35=j 380=0 9110=4000; a close request and its accept never); and the disable itself withdraws the open opening requests of the market: each ends CANCELLED with the sentence as rejectReason and reaches you as rfq.canceled with the sentence as reason (16.4). The OpenAPI document 2.7.0 grown in place (MarketInfo.offered, the offer rows' offered). Legal-entity clients and the derivative families (BROKER-63, the same day, grown in place; no schema change, the OpenAPI document unchanged): while our EMIR client-leg reporting for legal entities is being set up, a client of yours whose clientType is LEGAL is refused an OPENING perpetual order on POST .../orders, a barrier request on POST .../rfq and the accept of an opening request on POST .../rfq/{rfqId}/accept with 400 code 4000 and the one sentence "Perpetuals and barrier contracts are not yet available to legal-entity accounts while the EMIR trade reporting for legal entities is being set up. Existing positions can still be reduced or closed. Please contact support." (the same envelope as a restriction; over FIX 150=8 / 35=j 380=0 with 9110=4000), POST .../rfq/preview answers accepted: false with it as reason, and an auto-accept request carries it as autoAcceptNote; the refusal comes BEFORE the verification gate, so no MiFID sections are requested for a family the client cannot open; a reduce-only or reducing order, an early close and its accept, spot orders, the staking swap take and every natural person are unchanged. The staking markets (the same day, grown in place; BROKER-28, venue register ADR19-Q5, BROKER-26): every GET /staking/markets row gains usualTenors (12.4: the terms writers usually offer on the market, whole days ascending, always present, [] when the market lists none; our operator's presentation list, validated against the venue's tenor bounds), name on GET /staking/markets, GET /staking/offers, GET /clients/{clientId}/staking/swaps and the take answer reads the Fixed Yield Contract product names (USD Fixed Yield Contract, EUR Fixed Yield Contract, BTC Fixed Yield Contract, ETH Fixed Yield Contract, SOL Fixed Yield Contract, USD1 Fixed Yield Contract; a VALUE change only: no field, route, enum or code moved, the family code STAKING, the symbols and every event name unchanged), and takerFeeRate on GET /staking/markets is the broker's effective venue taker rate on the market (the venue's listed rate, or the member's measured tier or programme rate; the rate the reservation and the fee leg use, so a preview from it matches the charge; the shape is unchanged). The OpenAPI document 2.7.0 grown in place (StakingMarkets.markets[].usualTenors, the takerFeeRate description, the revision sentence). The LEI lapse sweep (the same day, grown in place; BROKER-91 with BROKER-66 and BROKER-67 folded in; no wire change, the OpenAPI document unchanged): after the grant, where our registry lookup runs, an approved legal entity's LEI is re-checked at least every 30 days; a registration found LAPSED puts the client in close-only mode on perpetuals, barrier contracts and staking swaps 30 days later unless renewed, a registration found RETIRED, ANNULLED, MERGED, DUPLICATE or no longer found at once, crypto spot never; the pause and its end reach you as margin.restriction_set and margin.restriction_lifted (11.4, 16.4), the client row keeps its lifecycleState (the pause is a restriction, never a state change), opening orders answer the close-only sentence (400 code 4000), reducing and closing go through; a registration found ISSUED or pending again lifts the pause, a registry that cannot answer neither starts nor lifts one; the client is mailed the notice and renewal reminders; send the renewed LEI through PUT .../kyc. Off on the development environment. No other route moved. The partner risk thresholds (BROKER-100, the same day, grown in place; docs/13 section 3; no OpenAPI change, the document stays 2.7.0): marginCallPct and closeOutPct on GET .../margin (12.3) and on every margin.* event (16.4) are the value that applies to this client, never below the brokerage value; a partner may agree a tighter pair for the clients it onboards with us (within 50 to 80 and 100 to 200, applied to every client of the partner whatever its categorisation, set by our back office under four eyes, never on this API), and our brokerage values gained the floor 50 on the close-out and the rule that the margin call stays strictly above it. No field, route or code moved. The margin call level in money (BROKER-102 item 6, the same day, grown in place; OpenAPI 2.7.0 grown in place, MarginView.marginCallLevel; additive): GET .../margin (12.3) gains marginCallLevel, (positionMargin + orderMargin) x marginCallPct / 100 as a 6-place USDC string, the figure the margin.call event already carried, and 12.3 now states marginLevel as the equity over the TOTAL initial margin (barrier requirements included), the thresholds as percentages of the perpetual margin compared against closeOutEquity (docs/13 2.5); the pre-trade preview's estLiquidationPrice (item 4) now values the open barrier contracts at their close-out contribution exactly like the margin read (losses in full, profits excluded, a stale contract at minus its premium), so the preview of an order that leaves the position the read shows answers the read's figure; no field moved. The two close-out follow-ups of OQ-52 (BROKER-23, the same day; PRODUCTION measures that run for a client only once that client has accepted the Terms version carrying the authorising sentence and the right-of-sale clause, our configuration naming that version and neither running while it names none, corrected 2026-09-11, code audit B-011; no field, route, code or event moved): a resting spot BUY of a client whose close-out equity LESS the USDC the buy holds falls under the maintenance margin is cancelled by our risk engine before any close-out (the order ends CANCELED with the note "Cancelled by the risk engine: the funds this order held are needed to keep your open positions above the maintenance margin." on the record and reaches you as order.canceled with reason canceled; no state change, no margin.* event), and after a close-out that leaves a protected (RETAIL) client's cash negative with coins in the account our risk engine SELLS the coins in the client's name before the write-off (a SELL, MARKET, IOC order on the spot market with origin CLOSE_OUT and family SPOT in 12.3 and its order.* events in 16.4; sized to the shortfall, at most three placements per asset per episode, a holding under the market's minimum notional is not sold and counts zero; the remainder is then written off in full and a NEGATIVE_BALANCE receivable against coins remains only for a client outside the protection or after a refused, capped or unfilled sale; docs/13 sections 4 and 8). Documentation only, the same revision, no wire change (the docs-only half of the operator decision batch of 2026-09-10: BROKER-4, BROKER-40, BROKER-41, BROKER-90, BROKER-101, BROKER-106; the OpenAPI document untouched at 2.7.0): 11.4 states the document rule behind declarationsMifid (before the three booleans are sent the partner shows the client the CURRENT versions of our Order Execution Policy, our Risk Disclosure with its complex products section and our Client Categorisation Notice at the links we publish, the versions our consent register records, a new version announced before it takes effect; the client's acknowledgement in the partner interface is the partner's evidence under its attestation; the integration guide's 4.4 says the same). 11.6 states the rule at our decision in the field table (criteria.scope must be GENERAL, criteria.warningVersion must be the identifier of our published warning text, currently CAT-WARN-2026-09 v1, and statementSignedAt at or after warningShownAt; the routes keep accepting a scoped request or another version, which then stays PENDING until our officer records the published values or rejects it, section 18), the new 11.6.1 reproduces the warning and the statement verbatim (the warning shown in a durable medium, the statement collected as a separate document after it), the examples carry the published version, and the periodic review is stated (confirmed: reviewAt moves forward on the file, no webhook; revoked: the REVOKED webhook); the PER_SE row of the basis table gains one clarifying clause (a figure at a threshold meets it; section I(2) only: a regulated entity, a public body or an institutional investor of section I(1), I(3) or I(4) opts up under ELECTIVE). The FIX endpoint of the development environment is dev.brokerage.perpetuals.com:9850, the same hostname as this API, with that hostname's own publicly trusted certificate (section 20; docs/14 revision 6; previously barriers.dev.mtf.perpetuals.com:9850 with the router's certificate; no partner was live on FIX under the previous name); production connectivity stays confirmed at onboarding. Section 16.4 states that there is no notice period between margin.call and margin.close_out_started: the close-out starts on the first tick that finds closeOutEquity strictly below maintenanceMargin, whether or not a margin call was recorded before it, and the margin call at marginCallPct of the perpetual margin is the notice (operator decision D22 of 2026-09-10; the risk engine specification docs/13-risk-engine-for-partners.md revision 3, the integration guide docs/12-partner-integration-guide.md revision 3 section 5.4). The integration guide's go-live checklist (docs/12 10.6) opens its operational items with the partner's MiFID II Article 29 status as the firm operating the order-entry screens, settled with our compliance before reliance is approved. The wire half of the operator decision batch (2026-09-10; BROKER-30, BROKER-69, BROKER-84, BROKER-104, BROKER-105; OpenAPI 2.7.0 grown in place, its leading revision 10 sentence rewritten to carry these clauses): valuationBasis on every GET .../balances row, the portfolio block, the GET .../account/overview root with its holdings[] and staked[] rows and every GET /currencies row (section 7, 12.5; per row MARK | PAR | NONE | CASH, the aggregate MARK | PAR | MIXED; a PAR currency is valued at 1.000000 USDC per unit without a market price, mark null, and may not be realisable at that value; the staked principal at full value and unsecured, the claims apart; no currency is PAR yet; the OpenAPI BalanceRow, Balances.portfolio, AccountOverview and Currencies schemas); source EMPLOYEE on the document list of 10.4 (a corporate document our staff added to a legal entity's company file from the CRM, listed to you, outside your 20 upload slots; the OpenAPI Document.source enum); 12.2 names the accept refusal of an opening request on a market our operator withdrew (400 code 4000 with the sentence; 35=j 380=0 over FIX); the channel rule of section 1 rewritten (the routes that answer a partner address like any other stated once; the OpenAPI revision 3 sentence no longer lists the portal and the password routes among the surfaces answering 4038); the interfirm posting on our books (section 13: every record moves partner_custody:<partnerId> by the payment against partner_interfirm:<partnerId>, the unwind sentence; 15.5: the collateralHeld and clientCash definitions as served and the identity clientCash = collateralHeld plus the whole-history outstanding, the worked example's collateralHeld 1050 after the payment; 15.7: the record posts its ledger transaction in the same database transaction; records on file before this revision are backfilled by our finance, ledgerTxId null until then); the OpenAPI criteria descriptions of the categorisation request name the published warning version and the statement rule of 11.6. The disclosures tranche of the docs lane (BROKER-93, 2026-09-11; documentation only, the OpenAPI unchanged at 2.7.0): 11.6 and 12.3 state the leverage caps as the schedule for the categorisation in force on the environment connected to, the development figures named as such; production publishes its figures at go-live. The code audit of 2026-09-11 (finding B-456; values only, no field, route, code or event moved, the OpenAPI MarginPosition and MarginView descriptions grown in place at 2.7.0; docs/13 revision 4): a leveraged position's initialMargin is never below the margin required to open it, |netQty| x max(mark, avgEntryPrice) / leverage (12.3), so positionMargin, initialMargin, freeMargin, marginLevel, maintenanceMargin, marginCallLevel, roiPct and estLiquidationPrice change for a losing long (held at its opening margin: the margin call and the close-out come at the rule's price) and for a winning short (its margin no longer falls with the mark); the 16.4 margin.call example reads the new figures; the admission tightening for partners: a losing long and a winning short no longer free margin as the mark moves, so a pre-trade check or a collateral withdrawal that passed on the freed margin before is refused now; a fully funded position (leverage 1) is unchanged. Documentation only, the code audit of 2026-09-11 (the OpenAPI unchanged at 2.7.0; grown in place): 11.4 no longer describes the LEI lapse pause as a RESTRICTED client row: the pause is a restriction and never a lifecycle state (the row keeps its lifecycleState, state=RESTRICTED never finds a paused entity; B-039); item (1) of the published warning CAT-WARN-2026-09 v1 in 11.6.1 names the lost retail leverage limits by reference to our professional leverage schedule in force, published on the leverage read, and no longer a multiple (the same version id, an edit before the publication act; B-040); section 18 states that the TERMS gate is the only gate switched off on the development environment while the KID and strict consent refusals of code 4035 answer on every environment (B-041); 12.3, 16.4 and this row state the per-client rule of the two close-out follow-ups in place of a gate on the client agreement (B-011). Documentation only, no wire change (the code audit of 2026-09-11, B-053): the barrier.strictTakeProfit row of 12.2 now states the strict consent gate for every client whatever its categorisation and the KID gate for a RETAIL client only, as the router, section 4, the 12.2 prose, docs/12 7.2 and 7.4, docs/14 7.1 and the OpenAPI codes sentence always did. Documentation only, no wire change (the code audit of 2026-09-11, B-043, 2026-09-12): 10.5 states that a key information document rides the standing read once a version of it is published (reconsentRequired false until a holder faces a newer version, never gating) and that the KID gate reads the record for the current version, as GET .../consents always answered; the ConsentDocument.doc description of the OpenAPI names ELECTRONIC_DELIVERY and the KID codes; docs/12 3.12 step 1 says the same. Documentation only, no wire change (the code audit of 2026-09-11, B-048, 2026-09-12): the fix block of section 8 names the Partner API hostname of section 20 as the FIX hostname, as section 20 and docs/14 always did (it had read "the router's"). The partner-channel sentence (the code audit of 2026-09-11, B-056, 2026-09-12; OpenAPI 2.7.0 grown in place, the ConsentGateError description): the msg of the TERMS re-consent refusal of section 4 (403 code 4035 on the four routes, the refused preview's reason, autoAcceptNote, 297=5 and 35=j 9110=4035 over FIX) ends "Review them in your provider's app." for a client of yours; it had ended with the Settings path of our own portal, which a client of yours holds no sign-in with (section 1, docs/12 3.9 and 3.12 step 4). |
| 9 | 2026-09-08 | The Partner FIX gateway (the binding addendum docs/design/partner-fix-gateway-2026-09-08.md; the rules of engagement docs/14-partner-fix-gateway.md revision 1): a FIX 4.4 acceptor inside the Order Router on its own TCP port over TLS (the router's certificate, no proxy in between, the partner allowlist applied to the address the router sees; barriers.dev.mtf.perpetuals.com:9850 on the development environment), the Logon over the partner key (Username(553) the key prefix, Password(554) the whole TRADE key, ResetSeqNumFlag(141)=Y on every logon, HeartBtInt(108) 5 to 120 s, SenderCompID(49) the partner code, TargetCompID(56) BARRIERS, 9120=Y for a drop-copy session), every refusal a Logout with the sentence and code of section 4 in tag 9110 (4010 for every credential failure, 4037, 4034 for a READ key, 4290 for the fifth session of a partner), the logon throttle (five failures on a key prefix or fifty on an address lock it for 15 minutes), one live session per key with the replacement after two missed heartbeats, the credential re-check every 30 s (a revoked key or a suspended partner ends its sessions), no message store (a sequence gap names the recovery route, a ResendRequest is answered with a GapFill), heartbeats and TestRequest as the venue keeps them, the standard Parties component naming the end client on every business message (PartyRole 3, the partner reference or our uid, never the bare numeric id; a missing component 35=j 380=5, an unknown or foreign client 380=1 code 4004), Cancel/Replace (35=G) refused with 434=2 (no amend route exists), the reply policy (an exception after the router may have written answers 35=j 380=4 code 5030, never a fabricated report), the graceful restart Logout, the operations disconnect; GET /me gains fix {enabled, port, sessions} (section 8); section 4 names tag 9110. Order entry (docs/14 revision 2, the same day): NewOrderSingle(35=D) over the pipeline of POST .../orders with every pre-trade check and sentence of 12.3 unchanged (the code in 9110, a coarse OrdRejReason(103)), ClOrdID(11) as the order's Idempotency-Key (an identical reuse restates the order as 150=I with 9117=Y, a different payload answers 150=8 103=6 code 4092), OrderCancelRequest(35=F) by OrigClOrdID(41) or OrderID(37) with the 35=9 sentinels, OrderStatusRequest(35=H) as a 150=I restatement, the ExecutionReports (150=0, F, 4, C, 8, I) from the same hook sites as the order.* events with the event's source_ref in 9115 and the trade id in 9111, the drop-copy routing keyed on the session's key with 9121 (FIX, REST, CLOSE_OUT), in-memory delivery released after the commit (a report built without a live session is lost; recover over the reads and the webhooks); the order records of 12.3 and the order.* data of 16.4 gain channel, channelKeyId and channelRef (additive; Order.channel, Order.channelKeyId, Order.channelRef in the OpenAPI document). The barrier RFQ vocabulary (docs/14 revision 3, the same day): QuoteRequest(35=R) over the function of POST .../rfq with the barrier terms in the custom tags 9130 to 9136 (auto-accept by default) or, with 9155, over the function of POST .../barriers/{contractId}/close, its QuoteReqID(131) as the request's Idempotency-Key (an identical reuse restates the request's current state with 9117=Y, a different payload answers 297=5 code 4092), QuoteStatusReport(35=AI) at creation and at every state change (QuoteStatus(297) 16 active, 10 pending when the venue could not be reached, 0 accepted, 7 expired, 17 canceled, 5 rejected; 9160 the state of 12.2 verbatim), Quote(35=S) once per quote id with the premium and the acceptability verdict of 12.2, QuoteResponse(35=AJ) over the accept with its refusals as 35=j 380=0 and the QuoteRespID(693) replay, QuoteCancel(35=Z) with 298=5, the contract open and the early-close trade and the contract end as 35=8 150=F on the KO symbol with the contract facts in 9150 to 9159, all from the same hook sites as the rfq.* and barrier.closed events with the event's source_ref in 9115; the request records of 12.2 gain channel, channelKeyId and channelRef (additive; Rfq.channel, Rfq.channelKeyId, Rfq.channelRef in the OpenAPI document). The OpenAPI document 2.6.0 (Me.fix, Order.channel*, Rfq.channel*). The documentation piece (docs/14 revision 4, the same day, no wire change): the tag tables of the rules of engagement are generated from the router's registries and pinned by our tests, every example frame there carries a real BodyLength(9) and CheckSum(10) so the examples serve as test vectors, the rules of engagement are served at /fix by the edge host beside this reference at /docs, the integration guide's FIX section carries a sequence diagram for an order and one for a barrier request (docs/12 section 11), and section 1 of this document names the transport. The review corrections of the same day (docs/14 revision 5, OpenAPI 2.6.1): partnerClientRef must be printable ASCII (0x20 to 0x7E; it rides on FIX as PartyID(448), so POST /clients answers the field sentence partnerClientRef must use printable ASCII characters only (0x20 to 0x7E): it is carried on FIX as PartyID(448). for any other character, section 9.1); the FIX logon throttle counts per (key prefix, address) pair so a stranger who knows a public prefix cannot lock its holder out; a live FIX session whose address leaves the partner's allowlist ends within 30 s like a revoked key; FIX tag values are ASCII (a byte above 0x7F is refused by name); a FIX acknowledgement that lands after a fill or a cancel restates the record instead of regressing it to New; a FIX cancel by the partner's own reference works past the store's 24 hours and the key that cancels an order it did not place receives the outcome. This row is grown in place by every piece of the revision. |
| 8 | 2026-09-06 | The addendum of 2026-09-06 (docs/design/partner-api-events-and-clients-2026-09-06.md): the three wire pieces the workshop named as missing, in one revision. (1) Life-cycle events (section 16.4): sixteen explicit webhook events emitted at the state change and never from the notification feed, each once per fact with the documented data: margin.call, margin.call_cleared, margin.close_out_started, margin.close_out_done (the persisted risk state with the snapshot's figures and the risk event id; the forced close-out with forced: true), order.accepted, order.partially_filled, order.filled, order.canceled, order.rejected (the order of GET .../orders plus fill, reason, rejectCode), rfq.quote (once per quote id), rfq.accepted, rfq.expired, rfq.canceled, rfq.rejected, barrier.closed (the brief's reason beside the wire's closeReason) and client.categorization_changed; the two sentences of revision 1 that excluded the rfq.* and order.* events and the margin-call webhook from the catalogue are gone; a partner MUST ignore event names it does not handle. The pending cap (16.3): at maxPending pending deliveries (5000 by default, GET /webhook carries it beside pending, 16.1) further rfq.quote events are dropped, no other event ever. (2) Legal entities under reliance (section 9.7): POST /clients with clientType: LEGAL carries the account holder representative's own kyc and identityCheck plus the kyb object (the entity with a required, format-checked LEI, the CRS and FATCA entity classification, the persons with their identity check records and the role alias SENIOR_MANAGING_OFFICIAL, the documents held by the partner, the contact) and attestation.entityCdd; every sentence documented per field, the file's coherence checked before anything is written; on our side the entity file as our onboarding team writes it, the LEI check (GLEIF when configured, never blocking the MiCA grant), OUR screening of the account holder, of the entity itself and of every person, then the unchanged grant (ACTIVE or PENDING_REVIEW); the answer and PUT .../kyc carry clientType and kyb (statuses and screening labels); PUT .../kyc revises the entity, the persons and the documents as full lists and re-screens the changed persons, a new LEI rebuilds the RTS 22 record by LEI; POST .../documents gains purpose: entity with kind and reference; the three MiFID routes take representativePersonId (required for a legal entity, refused for a natural person) and answer grantRefusal; the client rows carry clientType and legalEntity, the summary clients.byType, the verification view the four KYB blocks with level KYB, a legal entity's package payload is the pair {kyc, kyb}. The natural-person body and answer are byte-identical. (3) Professional clients (section 11.6): POST /clients/{clientId}/categorization records the client's request for a PROFESSIONAL (or, as an opt-down, RETAIL) categorisation on the client's behalf (requestedBy CLIENT, the requester your partner code), on the basis ELECTIVE (Annex II section II: the criteria, the evidence and the three procedural stamps, which are required for a professional request; qualitativePass is refused since the qualitative assessment is our compliance function's) or PER_SE (a legal entity only: the three size figures in perSe, two of three required), with representativePersonId naming the representative assessed; the answer and every read carry our router's assessment (basis, perSe, complete, missing); GET .../categorization lists the client's requests with the thresholds; POST .../categorization/{requestId}/withdraw stops a request during the cooling period; the decision stays with our compliance function and reaches you as client.categorization_changed (APPLIED, REJECTED, REVOKED); the client rows carry pendingCategorizationRequest, the summary pendingCategorizationRequests; POST /clients keeps refusing a categorisation other than RETAIL with the sentence naming the request route (9.1); actor names never cross (role labels), nor does our officer's reason. The OpenAPI document 2.5.0 (the event names on the delivery envelope, WebhookStatus.maxPending, the Kyb* schemas, LegalEntitySummary, representativePersonId, grantRefusal, the categorisation paths and the Categorization*, PendingCategorizationRequest and ReasonBody schemas, ClientRow.pendingCategorizationRequest, Summary.pendingCategorizationRequests). The codes (section 4): 400 code 4000 with data.errors on every new body, 404 code 4004 for a categorisation request of another client, 409 code 4092 for a second request while one is open (data.requestId), a target equal to the categorisation in force or a request that can no longer be withdrawn; 409 code 4090 unchanged on the e-mail address. The configuration key Partners:WebhookMaxPending (16.3). Sections 12.2 and 12.3 name the events beside the reads they mirror; section 18 states how the events, a legal entity and a categorisation request behave on the development environment (the registry lookup off, leiStatus FORMAT_OK, the decision by our staff). The reference page served at /docs by the edge host is rebuilt from this document. Review corrections of 2026-09-07, the same revision (nothing renamed or removed; the OpenAPI stays 2.5.0): deliveries run PER PARTNER, at most 50 per partner per pass with every other event ahead of rfq.quote, the partners in parallel and one delivery at a time within a partner, a timeout ending the partner's turn for the pass (16.3), and the delivery log is kept 30 days (Partners:WebhookRetentionDays, 16.1); order.accepted, order.rejected (reason "venue never received") and order.canceled also fire when our reconciliation resolves an order whose acknowledgement or frame was lost (16.4); barrier.closed states that closeTradeId is null when the venue's close frame settled the contract before the accept answer stamped it (16.4); an applied opt-down closes the earlier professional request as REVOKED on the file with the opt-down's APPLIED as the one event, an APPROVED request is applied by our scheduler within a minute of effectiveAt without waiting for a read, criteria.notes never returns on the wire, perSe and portfolioEur figures are kept as sent (never rounded to two places), the request route needs reliance APPROVED (403 code 4036) and the file's shape names clientId, clientUid and partnerClientRef (11.6); PUT .../mifid answers a missing or invalid representativePersonId as the field representativePersonId of its validation map while the two appropriateness routes keep the plain 400 (11.2, 11.4, section 4); 11.4 names UNKNOWN and FORMAT_OK among the LEI statuses that grant; GET .../kyc carries clientType (10.2, the OpenAPI KycPackage); a legal entity's screening label reads CLEAR after a PEP or adverse-media hit dispositioned as a true match by our officer (9.7: the label is PENDING_REVIEW only while a hit awaits its disposition, while our provider could not run, or on a sanctions true match); the OpenAPI residencyCountry default names both shapes; and the development venue's quoter is a market maker connected to it (16.3, 18). |
| 7 | 2026-09-03 | Documentation only, no wire change. The partner integration guide docs/12-partner-integration-guide.md (every flow under PARTNER custody with a USD stablecoin, complete examples, a webhook verifier in TypeScript and in Python, a reference architecture and a go-live checklist) is referenced from the header. Section 12.2 states the cap the router has enforced since its client API revision 38 of 2026-08-26: an explicit barrier.expiryTs on an opening request may lie at most 8 hours ahead (400 code 4000 "The expiry can be at most 8 hours ahead, or leave it out for an open-end contract."), an omitted expiry is the open-end contract (12 calendar months). The OpenAPI document 2.4.1 (the same description on RfqRequest.barrier.expiryTs). |
| 6 | 2026-09-03 | The code audit of 2026-09-03, batch 4 (contract alignment). An address outside the partner's allowlist gets the unknown-key answer word for word (section 3.3). Idempotency-Key is honoured on six more creating routes: POST .../documents, POST .../appropriateness, POST .../appropriateness/acknowledge, PUT .../mifid, POST .../statements and POST /webhook/test; PUT /webhook stays unkeyed by design (section 5). Document references (utility_bill_document_id, w9_document_id, document_ids) are refused on POST /clients with a sentence naming the route to use and honoured on PUT .../kyc (9.2). decidedBy on the verification view, reviewedBy on the KYC package and decidedBy on deposit notices and withdrawal requests are ROLE labels (PARTNER, SYSTEM, COMPLIANCE, FINANCE, ADMIN, ...), never a person's name (10.2, 11.5, 14.1). POST .../rfq/preview and POST .../margin/preview document their 502 code 5020 (12.2). Every commission is a COMMISSION posting: SWAP_COMMISSION and BARRIER_COMMISSION left the ledger type lists and the settlement definitions (12.5, 14.3, 15.3). An interfirm record counts in every window its period OVERLAPS, in full, and is recorded once per payment reference (15.5, 15.6). The OpenAPI document 2.4.0. |
| 5 | 2026-09-03 | The code audit of 2026-09-03, batch 3 (edge, webhooks, robustness). HEAD is answered wherever GET is, at the edge as a body-less GET (section 3.2). The edge's body cap is 11.5 MiB (12,058,624 bytes) so an 8 MB document upload fits after base64 and JSON framing; the edge's own refusal is 413 code 4130 (sections 4 and 10.3). Webhook URLs must point at a public host: loopback, private, link-local, multicast and unroutable addresses and localhost are refused at registration and again before every delivery, redirects are never followed (a 3xx is a failed attempt), and at most 16 KiB of a failure body is read (16.1, 16.2). The signature timestamp is taken per delivery at the moment it is sent (16.2). The notification sweep re-reads the last 30 seconds so a late-written item is still forwarded, never twice (16.4). Behind our public hostname the partner's real source address now reaches the allowlist (a change on our side; nothing to do on yours). The OpenAPI document 2.3.0. |
| 4 | 2026-09-03 | The code audit of 2026-09-03, batch 2 (money and concurrency). Idempotency-Key gives at-most-once execution (section 5): the key is claimed before the request runs, a concurrent request with the same key waits up to 5 s for the stored answer and replays it, else answers 409 code 4092 "A request with this Idempotency-Key is still in progress."; a request that failed with an error before answering gives the key back. The money routes are idempotent on partnerRef (sections 5, 14.1, 14.2, 15.1): a reuse for the same client answers the original notice, request or report with 200 (a REFUSED collateral report its original 409), checked before the money gates; a reuse for another client stays 409 code 4092. The withdrawal request's and the collateral withdrawal's gates run under the client's lock inside their own transaction and count the implied risk state (14.2, 15.1); a withdrawal reserve no longer counts as margin (14.2); finance's approval re-runs the money-out gate and leaves a refused request REQUESTED (14.2). The custody model changes only on a partner with nothing standing under the old model, with the unwind procedure (13). The OpenAPI document 2.2.0. |
| 3 | 2026-09-03 | The code audit of 2026-09-03, batch 1. One channel per client (section 1): a partner-onboarded client cannot sign in to our portal, reset a password, register again or use our direct client API and money desk (403 code 4038 PARTNER_CHANNEL on our side; nothing on the partner routes changes). Re-onboarding after a rejection (sections 9.1 and 10.1): PUT /clients/{id}/kyc on a client at level NONE with a REJECTED package requires the six sections, the identity check and the attestation and re-runs the full reliance grant, answering reonboarded, grant, review and requirements; POST /clients with that client's partnerClientRef answers 409 code 4092 pointing at the re-onboarding with data.reonboardWith. A revision that changes the name or the date of birth refreshes the display name and rebuilds the RTS 22 identifier record (10.1). The partner sandbox flag is set only on our development environment (18). The OpenAPI document 2.1.0. |
| 2 | 2026-09-03 | Partner custody (the binding design addendum of 2026-09-02): the custody models BROKER and PARTNER with custodyModel and collateralAsset on GET /me (section 13); the collateral reports POST /clients/{id}/collateral (booked at once, 1:1 in USDC, the refusal rules and a worked example) and their two lists (15.1, 15.2); the settlement items the ledger produces for every economic event on a PARTNER-custody client with the item lifecycle and worked examples for a perpetual close, a barrier knock-out and a commission, GET /settlement/items (15.3); the single and bulk reports with their idempotency and state rules (15.4); GET /settlement/summary with the buckets, the interfirm block and outstanding (15.5); GET /settlement/interfirm (15.6); the base settlement report gains custodyModel, collateralAsset and interfirm (14.3); the deposit and withdrawal routes answer 409 code 4092 for a PARTNER-custody partner; the perpetual margin, pre-trade preview and leverage routes documented with a worked round trip beside the barrier lifecycle (12.3); the webhook events collateral.booked, collateral.refused and settlement.item_created, and kyc.documents_requested / kyc.rejected as the package review produces them (16.4); the error table (4090 on a leverage change, 4092 on the custody conflicts, 4004 on a settlement item); the sections renumbered (webhooks 16, statements 17, sandbox 18, changelog 19); the OpenAPI document 2.0.0. |
| 1 | 2026-09-02 | First revision: authentication and scopes, envelope and error codes, idempotency, rate limits, onboarding under reliance with the six MiCA blocks, KYC revisions and documents on request, the MiFID appropriateness flow, the trading and account routes delegating to the client API, deposit notices, withdrawal requests and the settlement report, webhooks with the HMAC signature and the notification sweep, statements, the OpenAPI 3.1 document. |
Since revision 9 a partner can trade over FIX 4.4 beside these routes and the webhooks: a FIX acceptor runs inside the Order Router on its own TCP port over TLS (its own publicly trusted certificate, the same hostname as these routes; dev.brokerage.perpetuals.com:9850 on the development environment, the production endpoint told at onboarding), with no proxy in between, so your partner CIDR allowlist applies to the address the router sees. A session logs on with a TRADE key (Username(553) = the key's prefix, Password(554) = the key), one live session per key, at most four per partner, SenderCompID(49) = your partner code, TargetCompID(56) = BARRIERS; every business message names the end client with the standard Parties component (your partnerClientRef or our client uid). The gateway keeps no message store: recovery after any disconnect is the reads of section 12 plus the webhooks of section 16.
The rules of engagement, tag by tag, are docs/14-partner-fix-gateway.md: the session layer, the client attribution, order entry (NewOrderSingle, OrderCancelRequest, OrderStatusRequest; Cancel/Replace is refused), the ExecutionReport stream, the drop-copy session, the delivery semantics, the reject doctrine and the reply policy, what FIX does not carry, the conformance checklist and the custom tag dictionary (9101 to 9199). The rule that binds the transports: FIX answers with the SAME functions, sentences and codes as these routes (the code in tag 9110): a 35=D goes through the pipeline of POST .../orders with every pre-trade check of 12.3 unchanged, its ClOrdID(11) is the order's Idempotency-Key (section 5), and the ExecutionReports are produced at the SAME hook sites as the order.* events of 16.4, carrying the event's source_ref in tag 9115 and the trade id in 9111, so REST, the webhooks and FIX never disagree about a fact and a partner running two of them dedupes on that reference. An order entered over FIX reads channel: "FIX", channelKeyId and channelRef on the records of 12.3. The barrier lifecycle of 12.2 rides the same way (docs/14 section 7): a QuoteRequest(35=R) goes through the function of POST .../rfq (or, naming a contract, of POST .../barriers/{contractId}/close) with every gate and sentence unchanged, its QuoteReqID(131) is the request's Idempotency-Key and its channelRef, a QuoteResponse(35=AJ) and a QuoteCancel(35=Z) run the accept and the cancel, and the QuoteStatusReport(35=AI), the Quote(35=S) and the two contract ExecutionReport(35=8) (the open or the early-close trade, and the end for any reason) are produced at the SAME hook sites as the rfq.* and barrier.closed events of 16.4, with the event's source_ref in tag 9115. GET /me fix (section 8) confirms the gateway from your side. Since revision 21 an order your app placed with an app order command (section 22) reports to your drop-copy sessions only (it has no session key), with 9121=APP and without ClOrdID(11), because its channelRef is the token's jti; and a partner whose server-side order entry is CLOSED (22.10) is answered 9110=4043 on a NewOrderSingle(35=D) and an OrderCancelRequest(35=F) (docs/14 revision 12). Since revision 26 the conditional limit band of 12.3 refuses a NewOrderSingle(35=D) 40=4 whose Price(44) lies outside the market's band around its StopPx(99) (or around the mark once the mark has reached it) with the gateway-built 150=8 103=99 9110=4000 and the sentence in Text(58) (docs/14 revision 15). FIX carries no perpetual demo (revision 28, section 23): there is no demo message, a demo order is never reported on a session or a drop copy, and a demo event of 23.9 never reaches FIX; the demo is the REST routes of section 23 alone.
Since revision 15 (2026-09-21). This section writes down the rule this reference has followed since revision 9: revisions 9, 10 and 11 added response members, events and data.error values "additively", and the catalogue of 16.4 said of event names alone that a partner must ignore the ones it does not handle. The rule below is the codification of that practice, not a new duty, and it binds both sides from now on.
Kinds of change and their notice.
data.error, stage, status, kind or a later discriminator of the same kind. A partner MUST ignore members it does not know, event names it does not handle and values it does not know, as it has had to since revision 9. A RELAXATION (a request formerly refused is accepted, or a duty of yours withdrawn) is treated as additive.version required as a JSON integer on POST .../appropriateness and on POST .../appropriateness/acknowledge (announced in revision 15, the code in revision 17; 11.2 and 11.3: production from its first day, the development environment from 2026-11-01 00:00 UTC); (2) locale and textHash required on the same two routes for every questionnaire version that is or ever was served in more than one language, and whenever the locale sent is not en (revision 17, 11.2; the requirement follows the CONTENT: production from its first day, and on the development environment no version is served in a second language before 2026-11-01 00:00 UTC, so the requirement cannot meet a partner there before that instant; send both always and it never meets you at all); (3) the members and query parameters that had no meaning before revision 17 (locale and version on GET /appropriateness-test; locale, textHash, clientLanguage and languageElection on the two writes) are VALIDATED from the deployment of revision 17: before it they were ignored, from it a malformed value is refused (a locale that is no language tag 400 LOCALE_MALFORMED, a version that is no positive integer 400, a textHash that is no sha256: hash 400), which this section's own definition makes a tightening (a request formerly accepted is refused); it ships with the code because a value a build already sends is what its platform gives it, a language tag or an integer, and the rehearsal checklist of docs/12 10.6 asks every partner whether its build sends any of them today. The precedent is the crypto-asset risk declaration of revision 10 (9.2, section 18); (4) the two members of declarationsMifidText on PUT .../mifid (11.4) had no meaning before revision 18 and are VALIDATED from its deployment (a locale that is no language tag, a textHash that is no sha256: hash or that names no text we served: a field of the route's validation map; the same class as item (3), a value a build already sends is what its platform gives it, and the rehearsal checklist of docs/12 10.6 asks); (5) declarationsMifidText.locale and .textHash REQUIRED on PUT .../mifid by content exactly like item (2): once the family DECLARATIONS_MIFID is or ever was served in more than one language, and whenever the locale sent is not en (revision 18, 11.4; production from its first day, and on the development environment no family is served in a second language before 2026-11-01 00:00 UTC, so the requirement cannot meet a partner there before that instant; send both always and it never meets you at all)./partner/v1: they need /partner/v2 with an overlap of at least six months in which both versions are served.The notice figures. The floor for a tightening or for a change to a content a partner renders is 14 calendar days; the practice is 30 days. An URGENT change (a regulator's instruction, a defect in a question or in the scoring, a defect that undermines a regulatory control) may be shorter and carries its reason in the changelog row; a refusal that follows from it still costs the client nothing under the attempts rule of section 11.
The questionnaire's cadence (revision 15). There is no fixed schedule for a change of the questionnaire, the warning or the acknowledgement sentence. Our compliance function reviews the questionnaire at least once a year and on product events (a product feature the knowledge questions must cover, regulatory guidance), and a change is made only when a review requires one; expect a small number of versions a year. One change is decided today and will be the first one published under the announcement mechanism of revision 17 (11.1): an additional knowledge question on the strict take profit contract variant with the scoring adjusted to it.
The channels. For your systems the channels of record are the reads of this API, the webhook events of section 16 and the changelog of section 19, read from the reference page at /docs (the OpenAPI document at /openapi.json follows at the next router deployment; the changelog row says so when they differ). For your people, until a retained notice mail to the compliance and technical contacts you register with us exists (a later revision), a direct notice given to you by a person of ours is filed on our side against your partner record. A shared chat channel is a courtesy a person may run beside these; it is never the notice of record. We ask every partner for a compliance and a technical contact so that the notice mail reaches them from its first revision.
What a partner must tolerate (the other half of the additive rule): unknown members on any answer or event; unknown data.error values on a refusal (act on code and, where you know it, data.error, never on the sentence in msg, which may be reworded); unknown event names on the webhook (the default branch answers 200); unknown stage, status, kind and family values (treat them as "a state you do not handle", never as an error); and a version of the questionnaire that is higher than the one you rendered (re-read and re-render, 11.1).
Applied to revision 16 (2026-09-22). The withdrawal of the duty to display the retail risk warning (section 8) is a RELAXATION: additive under this section, no notice period, a changelog row (section 19). The eight riskWarning members stay served with their shape and meaning unchanged, marked deprecated in this reference and in the OpenAPI document (deprecated: true and a description string on each member; no change of shape: no schema, route, field, enum value, code or event moved); their REMOVAL is a breaking change and waits for /partner/v2 with the overlap above. A partner that stops rendering them changes nothing on our side and owes us no notice. Should a barrier-specific risk sentence set later be served here and required of you, the serving is additive and the requirement a tightening announced under this section.
Applied to revision 17 (2026-09-23). The schedule members, the locale members, the two events, the query parameters and the sample body are ADDITIVE (a partner that ignores them is untouched). Three TIGHTENINGS ride with it, each listed above with its date: version required (item (1), announced in revision 15, its notice period served), locale and textHash required by content (item (2), a requirement no partner can meet before the development environment's date and that production carries from its first day), and the validation of the members and parameters that had no meaning before (item (3), from the deployment; a well formed value is never refused). Nothing is removed, retyped or given another meaning; the RESERVED members (languageElection, failText, failure, the 4035 label LANGUAGE_ELECTION_REQUIRED) promise nothing until a later revision defines them under this section.
Applied to revision 18 (2026-09-23). Every item is ADDITIVE: a new route (GET /texts, 11.7), a new optional request member (declarationsMifidText on 11.4), new response members (declarationsText on 11.4's 200 and on 11.5), new data.error values (TEXT_FAMILY_UNKNOWN and the five DECLARATIONS_* labels), a new family value of text_changed, a new family member of POST /webhook/test, a new data.error member on the 400 of 11.4. Two items are stated under the tightening rule with their dates, items (4) and (5) above, because this section's own definition makes them one. The CONTENT rule applies to the declaration sentences as 11.4 states it: a served sentence may change at any instant by an approval of our compliance function, the read is the truth and the event the signal. Nothing is removed, retyped or given another meaning.
Applied to revision 21 (2026-10-13). Every item is ADDITIVE: a new route family outside the Partner API (/app/v1, section 22, used only by a partner our operations switch ON), a new response member (appOrders on GET /me), a new value of channel on the order records (APP), a new value of 9121 over FIX (APP) and a new code (4043, never served while your server-side order entry is ALLOWED, the default). Nothing changes for a partner whose app orders are OFF. ONE TIGHTENING is announced as POSSIBLE under this section, with no date set: our operations may close a partner's server-side order entry (22.10), after which its POST .../orders, DELETE .../orders/{orderId}, PUT and POST .../leverage and its FIX 35=D and 35=F are refused with code 4043 and its clients' orders are entered through the app order commands alone. It is set only on our compliance function's decision, per partner, and announced to that partner at least 30 days ahead with its date per environment, under the notice rule above; it is never set without that notice. The token profile of 22.3, the key set rules of 22.4 and the limits of 22.9 are the agreed terms of the channel; a change to them that refuses a token formerly accepted is a tightening too.
Applied to revision 26 (2026-10-20). The conditional limit band of 12.3 refuses a request formerly accepted (a STOP_LIMIT or TAKE_PROFIT_LIMIT whose limit lies outside the market's band around the price at which it becomes a limit order, a conditional limit order of any kind without a fresh mark on a market that publishes a band, an attach leg whose limit lies outside the band around its trigger). It ships at once as the CORRECTION OF A DEFECT that undermines a regulatory control: the price check before an order reaches the venue, which the venue applies to a plain limit order and leaves to us for a conditional one; a triggered limit order of a client could otherwise trade far from the market or sweep the book. WHAT TO CHANGE ON YOUR SIDE: derive the limit of a conditional order from its trigger price and the market's priceBandBps (GET /market-info) at run time and keep it inside that band; treat the 400 code 4000 (4001 on a replace) as a refusal to show your user, never retry it unchanged. Nothing is removed, retyped or given another meaning; no code is new.
Applied to revision 27 (2026-10-21). volume24h on GET /ticker and GET /tickers reads null where it carried a figure (12.1). The figure was the traded base quantity of every trade since the venue's gateway process last started, not the 24 hour volume the member names. Before this revision the member was a string on every answer of both routes, never null (a market whose venue ticker failed carried the volume summed from the 1 hour candles), and the OpenAPI document typed it string; it now types it string or null. Under this section's own definition that is a retyping, a BREAKING change. It ships on /partner/v1 as an EXCEPTION to that rule, decided by our operator on 2026-10-26, and is in force at the publication of this revision: nothing is live and no partner has gone live. WHAT TO CHANGE ON YOUR SIDE: read volume24h as nullable and show no base 24 hour volume while it is null; read the traded value of the last 24 hours from quoteVolume24h on GET /tickers, which is null while no candle of that window could be read for the market. The OpenAPI type of quoteVolume24h, string or null, states what the router has always served; it is no change on the wire. Nothing is removed and no code is new.
Since revision 21 (2026-10-13; issue #58, the design SwissBorg chose on 2026-10-09 and the profile it confirmed on 2026-10-13). The code is AppJwt.cs (the token), AppJwks.cs (your key set as we cache it), AppOrderIssuers.cs (the issuers we know), AppOrderAuth.cs (the authentication of every request), AppOrders.cs (the four routes), AppOrderCommands.cs (the record) and PartnerAppSettings.cs (your settings and the server-side switch). The walk-through for your engineers is docs/12 section 12.
An APP ORDER COMMAND is a request your app (iOS, Android or a web app in a browser) sends to us DIRECTLY when the signed-in user places, replaces or cancels a perpetual order or changes the leverage of a perpetual market. Each request carries a short-lived token your backend signed for that user and that command; nothing else of your integration passes through it. We verify the token against the key set you publish, read the client from its sub, and run the command through the SAME pipeline as the Partner API route it mirrors (12.3): every pre-trade check, gate, sentence and code is unchanged. Everything else (onboarding, KYC, MiFID, money, every read, barriers, staking, spot) stays on the Partner API, server to server, and the order life cycle reaches your backend through the order.* webhooks (16.4) exactly as for any order.
Your partner record is switched for this channel by our operations (22.2). While it is OFF, the default, nothing of this section applies to you.
The app order commands carry no perpetual demo (revision 28, section 23): the four routes place, replace and cancel live orders and change the live leverage alone, a token never reaches a demo account, and the demo is served to your backend, server to server, under /partner/v1 only.
A token that verifies under your published key proves that YOUR SIGNING KEY signed, for the client it names, within its lifetime, a statement that the command may be sent. It does NOT prove that the user entered the order, what the screen showed, or that your app (rather than your backend, or anyone holding your signing key) sent the request: a party holding your signing key can mint a token for any of your clients. Therefore:
jti.Our operations configure, under four eyes (one ADMIN login requests, a different ADMIN or a COMPLIANCE login approves, within 72 hours):
| Setting | Meaning |
|---|---|
| issuer | The exact iss your tokens carry: 1 to 256 printable characters without spaces, unique across our partners. |
| key set URL | The https URL of your published key set (22.4); its host must resolve to public addresses only, checked at the request, at the approval and before every fetch. |
| algorithms | ES256 (the default; RS256 and PS256 on agreement). |
| app orders | OFF (the default) or ON; ON needs the issuer and the key set URL, and a host that serves the channel. |
Switching OFF is immediate (one ADMIN act with a reason, no second login), and so is the deny list of key ids (22.4). The settings and our last fetch of your key set read on GET /me as appOrders (section 8); nothing is written through this API. The AUDIENCE is ours, one per environment, the same for every partner: https://dev.brokerage.perpetuals.com/app on the development environment; production's is told at onboarding. A host whose audience is not configured serves no app order command at all.
A compact JWS (three base64url segments without padding, at most 8,192 characters), sent as Authorization: Bearer <token> on every request.
The protected header holds alg and kid, and at most typ (any value); nothing else. A header that names a key or a key location (jwk, jku, x5u, x5c, x5t), or carries crit, zip or b64, is refused: a key is never taken from the token. kid is 1 to 128 printable ASCII characters and names a key of your published set. alg is one of the algorithms agreed for you (ES256 unless agreed otherwise); none and every HMAC algorithm are refused. An ES256 signature is the 64-byte r||s form of JOSE (what WebCrypto and the JOSE libraries produce), never DER.
The claims are EXACTLY these six, and nbf when you send it; any other claim is refused, so no personal data beyond our client uid travels with a command:
| Claim | Rule |
|---|---|
iss | Your issuer, exactly as configured (22.2). |
aud | Our audience, exactly (22.2): a JSON string, or an array holding that one string. |
sub | Our client uid of the user, one of YOUR clients, exactly as we issued it (BARR-00000412 in the examples; the uid is opaque, section 7); the bare number, your partnerClientRef or any other spelling of the uid is refused with the one 401 (22.6). |
iat | Issued at, an integer NumericDate (Unix seconds). |
exp | Expiry, an integer NumericDate, after iat and at most 300 seconds (5 minutes) after it. |
jti | 16 to 64 characters of A to Z, a to z, 0 to 9, - and _, unique per token you issue (we record it, 22.11). |
nbf | Optional; an integer NumericDate, not after our clock plus the skew. |
We allow 60 seconds of clock skew either way: a token is refused once our clock passes exp plus 60 seconds, and before iat minus 60 seconds. Run your signing hosts on a synchronised clock. A token may carry several commands within its life (a user who places an order and moves its stop loss a minute later needs no second token), each with its own Idempotency-Key; the jti is recorded with every command it carried.
An example, decoded (the signature omitted):
{"alg": "ES256", "kid": "2026-10-a", "typ": "JWT"}
{"iss": "https://id.neobroker.example", "aud": "https://dev.brokerage.perpetuals.com/app", "sub": "BARR-00000412", "iat": 1791374400, "exp": 1791374700, "jti": "q8Hk2v0LJ3mZr7tYw1Xa"}
Publish your PUBLIC keys as a JSON Web Key Set ({"keys": [...]}) at the https URL of your settings. We fetch it ourselves; it never travels in a request.
kid. For ES256: kty EC, crv P-256, x and y; for RS256 or PS256: kty RSA, n and e, 2,048 to 4,096 bits. A key's alg, when present, must name the algorithm it is used with; use, when present, is sig. A key of another type, curve or use is skipped.kid, holds a malformed key or a key outside the size bounds, holds a PRIVATE member (d, p, q, dp, dq, qi, oth, k), or has no usable key. The body is read up to 64 KiB (a larger one is refused, never truncated), within 10 seconds, with no redirect followed.kid we do not hold triggers ONE immediate refetch, at most once a minute for you (never once per key id); within that minute the token is refused like any unknown key id, or answered 503 with Retry-After while the refetch is still running.appOrders.lastError on GET /me; once the last good set is older than 4 hours every command is answered 503 code 5030 until a fetch succeeds.dev- is a development key: a production host never accepts it, and refuses your whole set while it holds one.The four routes are served on the edge host of section 2 under /app/v1 (https://dev.brokerage.perpetuals.com/app/v1/... on the development environment), over TLS, and only there: the router behind it answers nothing under /app/ that did not come through our edge (a 404 without a body, a CORS preflight included: CORS on /app/ is the edge's alone). The edge forwards POST, PUT and DELETE under /app/ and answers the CORS preflight itself (below); every other verb (an OPTIONS that is not a preflight included) and every other path under /app/ answers 404 ({code: 4004, msg: "Not found."} from the edge, or a 404 without a body from the router). A request body is capped at 64 KiB (413 code 4130 "The request body exceeds the 64 KB limit."), and a Cookie header is dropped: the app authenticates with its token alone.
For a WEB APP, our edge answers the browser's CORS preflight itself, for the exact https origins you name to our operations (one or more, https://host or https://host:port, no wildcard, no path): a preflight from a listed origin answers 204 with Access-Control-Allow-Origin (the origin), Access-Control-Allow-Methods: POST, PUT, DELETE, Access-Control-Allow-Headers: Authorization, Content-Type, Idempotency-Key and Access-Control-Max-Age: 600; every answer to a listed origin carries Access-Control-Allow-Origin and Access-Control-Expose-Headers: Retry-After, WWW-Authenticate, and every answer Vary: Origin. Credentials (cookies) are never allowed. A preflight from any other origin answers 204 without those headers, so the browser refuses the request. The native apps send no preflight and need no origin.
Every answer is in the envelope of section 4 and carries Cache-Control: no-store.
| Status | Code | When | Headers |
|---|---|---|---|
| 401 | 4010 | EVERY authentication failure, one answer byte for byte ({"code": 4010, "msg": "Sign in again.", "error": "Sign in again."}, no description, no data): no token, two Authorization headers, a token that is not a compact JWS, your partner key (an X-Partner-Key header or a pbk_ bearer), an unknown issuer, a refused header, an algorithm not agreed for you, an unknown or denied key id, a bad signature, a claim set other than 22.3, a wrong aud, a token expired, not yet valid or living longer than 5 minutes, a sub that is not one of your clients, your partner record CLOSED, your app orders OFF. | WWW-Authenticate: Bearer error="invalid_token" |
| 403 | 4037 | Your partner record is SUSPENDED (decided only after the token's signature verified). | |
| 429 | 4290 | A window of 22.9. | Retry-After (whole seconds, at least 1) |
| 503 | 5030 | No usable key set of yours (none fetched yet, or the last good set older than 4 hours), or a fault of our store on the way: "Order commands are unavailable right now. Please try again shortly." | Retry-After |
On a 401 your app fetches a fresh token from your backend and sends the SAME request again with the SAME Idempotency-Key: nothing was executed or stored under the key (22.7). On a 429 or a 503 it waits Retry-After and does the same.
A token whose signature verified under your key but which we refuse for what it says (a sub that is not your client, a wrong aud, a lifetime above 5 minutes, an unagreed claim, your app orders OFF, your partner record stopped) is recorded as a SIGNED REFUSAL with the token (22.11); an expired or not yet valid token is not (an app holding a token past its 5 minutes is the ordinary case). A signed token for a client that is NOT yours opens a case with our operations at once: your signing key produced it. A token that fails before its signature verified is never recorded and never opens a case: anyone can send one.
A client who CANNOT TRADE is answered 403 on the place and on the replace (never on the cancel, which stays open to every client, nor on the leverage change), with the pipeline's own sentence: 403 code 4030 when the client's verification does not allow the market (the MiFID level missing, data.missingBlocks), 403 code 4035 for the consent gates, 403 code 4041 while your clients are close-only under the settlement tier (15.7), and 403 code 4044 TRADING_RESTRICTED (data.error) for a client restriction (frozen, new orders disabled on the market, close-only), a trading halt in scope, an account that is not active for trading and a legal-entity account (perpetuals are not yet open to legal entities, section 9.7) (the Partner API's own routes answer those with 400 code 4000 and the same sentence). On a replace each of these 403s means NOTHING changed, the old order stands, and its data carries cancelled: false beside the code's own members.
The Idempotency-Key header is REQUIRED on all four routes: exactly one, 1 to 128 printable ASCII characters without spaces (the characters ! to ~). Absent: 400 code 4000 "Idempotency-Key is required."; malformed (a space, a character outside that range, more than 128 characters, two headers): 400 code 4000 "The Idempotency-Key header must be 1 to 128 printable ASCII characters.".
application/json (415 code 4000) and a body that is not JSON of the route's shape (400 code 4000 "The request body is not valid JSON for this command."). Correct the request and send it again with the SAME key: it runs as a first use.state SENT; a cancel 200 {orderId, clOrdId, pending: true}; a replace 200 {orderId: null, clOrdId: null, state: null, cancelledOrderId, pending: true}. Read the orders back (from your backend, 12.3) for the outcome; never send the command again under a new key before you did. BEFORE that point a failure of ours uses nothing under the key: 502 code 5020 (the venue could not be reached, nothing was sent) and 503 code 5030 (our store) are not stored, and the same request with the same key runs as a first attempt.The body is JSON (Content-Type: application/json, else 415 code 4000 "The request body must be sent as application/json."; a body that is not JSON of the route's shape answers 400 code 4000 "The request body is not valid JSON for this command."). An order id in a path is our numeric order id (4185) or its reference (BARR-4185). The ORDER routes take PERPETUAL markets only: a symbol of any other family (spot, barrier and staking included) answers 400 code 4000 "This market cannot be traded from the app." before anything is checked or written; a symbol we do not offer keeps the pipeline's own answer.
POST /app/v1/orders: a new order for the client of the token. The body is the body of POST .../clients/{clientId}/orders (12.3: symbol, side, type, qty, price, tif, reduceOnly, trigger, attach, ocoGroup, leverage) and so is the answer, byte for byte: (bare) {orderId, clOrdId, state} on acceptance, 400 {code: 4000, msg, error, orderId, clOrdId, state} on a refusal, 403 4030, 4035 or 4041, 409 code 4091 {leverageSent, leverageStored}, 502 code 5020; the ONE difference is the account that cannot open new risk (22.6), 403 code 4044 {code: 4044, msg, error, data: {error: "TRADING_RESTRICTED"}, orderId, clOrdId, state} (the shape of the 4041) where the Partner API's route answers 400 code 4000. A stop loss and a take profit are attach {takeProfit, stopLoss} on a LIMIT or MARKET entry, or later reduce-only STOP_MARKET / STOP_LIMIT and TAKE_PROFIT_MARKET / TAKE_PROFIT_LIMIT legs paired by ocoGroup; MOVING one is a replace of that leg, removing one a cancel.
POST /app/v1/orders
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYtMTAtYSIsInR5cCI6IkpXVCJ9...
Idempotency-Key: 6f1d2c4e-buy-1
Content-Type: application/json
{"symbol": "BTC-USD-PERP", "side": "BUY", "type": "LIMIT", "qty": "0.01", "price": "60000", "tif": "GTC",
"attach": {"takeProfit": {"price": "63000"}, "stopLoss": {"price": "58500"}}}
200 {"orderId": 4185, "clOrdId": "BARR-4185", "state": "ACKED"}
DELETE /app/v1/orders/{orderId}: cancels any order of the client, by whatever channel it was placed (the app, your backend, FIX, a leg). (bare) 200 {orderId, clOrdId} when the venue accepted the cancel, or when the order was already being cancelled; 404 code 4004 for an order that is not this client's or does not exist; 400 code 4000 with the pipeline's sentence for an order that has ended or was not at the venue yet, or that the venue refused to cancel. Once the venue was asked to cancel, a failure on our side (a lost answer of the venue included) is never "nothing changed": the answer is the pending answer of 22.7, (bare) 200 {orderId, clOrdId, pending: true}, stored and replayed under the key; the venue may have cancelled the order, so read it back through your backend before anything else. The outcome reaches your webhook as order.canceled.
POST /app/v1/orders/{orderId}/replace: a CHANGE of an order is a cancel of it and a NEW order (there is no amendment in place at the venue, so the new order has a new id and loses the old one's queue position). The body is a place body (the new order, 12.3). The new order is checked in two halves, around the cancel:
STOP_LIMIT or TAKE_PROFIT_LIMIT whose limit lies outside the market's band around its trigger price, or around the mark once the mark has reached the trigger, 400 code 4000 on the place and 4001 here, never a 403), the OCO group, the attach, the market (offered, of a family the pipeline trades, not disabled for new risk), the account's gates (the verification gate, the client's restrictions and the trading halts, the consent gates, your clients' close-only state, an account not active for trading) and the leverage hint. A refusal here CHANGES NOTHING: 400 code 4001, a 403 of 22.6, or 409 code 4091.So moving a stop loss or a take profit (a replace of its reduce-only leg) can end with neither leg: when the position shrank below the new leg's quantity, or closed, the new leg is refused after the old one was cancelled. On a 4002 your app tells the user that the old order (the old leg) is gone and offers to place it again (a new POST /app/v1/orders under a new key); it never assumes the old order still stands.
| Status | Code | Meaning |
|---|---|---|
| 200 | 0 | {orderId, clOrdId, state, cancelledOrderId}: the NEW order in the place answer's shape, and the id of the cancelled one. |
| 200 | 0 | {orderId: null, clOrdId: null, state: null, cancelledOrderId, pending: true}: the pending answer of 22.7, once the venue was asked to cancel and our side failed after it (a lost answer of the venue included): the old order may be cancelled and the new one placed, or not; read both back through your backend before anything else. |
| 400 | 4001 | Nothing changed, the old order stands; the sentence says why: the order has ended, is already being cancelled or was not at the venue yet, the new order fails a check made BEFORE the cancel (any refusal of the place route there but the account's own, which is the 403 below), or the venue refused the cancel. |
| 403 | 4030, 4035, 4041, 4044 | Nothing changed, the old order stands: the client cannot open the replacement (22.6: the verification gate, a consent gate, your clients close-only, or the account's restriction, a halt, an account not active or a legal-entity account, TRADING_RESTRICTED), with the code's own data and cancelled: false beside it, e.g. {"code": 4044, "msg": "<the sentence>", "error": "<the sentence>", "data": {"error": "TRADING_RESTRICTED", "cancelled": false}}. |
| 400 | 4002 | "Your order BARR-4185 was cancelled, but the replacement could not be placed: <the sentence>", data {cancelledOrderId, orderId, state}: the old order IS GONE and the new one was refused AFTER the cancel, by the reduce-only check, the margin check or the venue (state REJECTED; orderId the new order's row when the venue refused it, null when it was refused before a row was written). Tell the user and offer to place the order again (above). |
| 404 | 4004 | The order is not this client's, or does not exist. |
| 409 | 4091 | The replacement's leverage hint no longer matches the stored choice, data {leverageSent, leverageStored}; nothing changed. |
| 502 | 5020 | The venue could not be reached before it was asked to cancel; nothing changed, nothing is stored under the key (22.7). Once the venue was asked, a lost answer is the pending answer of 22.7, never this. |
The replacement is an order of this channel with its own jti, whatever channel placed the order it replaces.
PUT /app/v1/leverage: the user's leverage choice on one perpetual market, body {symbol, leverage}; the answer is the one of PUT .../clients/{clientId}/leverage (12.3): (bare) 200 {symbol, leverage, cap, categorization, chosen}, 400 code 4000 above the cap or not a whole number, 404 code 4004 for a market that is not offered. A change that raises the initial margin and would leave the free margin negative or the account in margin call or close-out is ALWAYS refused with 409 code 4090 and data {wouldBreach: true, initialMarginAfter, maintenanceMarginAfter, freeMarginAfter, marginLevelAfter, riskStateAfter}: the app route NEVER applies it, and a force member in the body is ignored (we keep, in our record of the command, that it was sent). The sentence of that refusal reads "At <leverage>x your open positions and orders would need <amount> USDC of initial margin and your free margin would be <amount> USDC, <the consequence>. The leverage was not changed.", the consequence being "which is more than your account holds", "which would put your account in margin call" or "which would start a close-out of your positions": the figures and the consequence of PUT .../clients/{clientId}/leverage, whose sentence ends "Confirm to apply it anyway." instead (your backend's route accepts force, the app route never does). The leverage stays as it was; the app offers no confirmation and asks the user for a lower leverage instead.
Three sliding windows of 60 seconds, in memory per router host, none of which is a Partner API window (section 6) or your FIX sessions' bucket:
| Window | Limit | Counted |
|---|---|---|
| per caller address | 300 | every request that reaches the authentication, before the token is read (an IPv6 address by its /64) |
| per user | 120 | every command whose token was accepted, by the client |
| per partner (your app ceiling) | 6,000 | every command whose token was accepted, for all your users |
Beyond a limit: 429 code 4290 "Too many requests. Please slow down and try again." with Retry-After, the whole seconds until that window admits again (at least 1). The limits are our configuration, the same for every partner of an environment; a change that lowers one is announced under section 21.
Our operations hold, per partner, whether your SERVER-SIDE order entry is ALLOWED (the default) or CLOSED. While it is CLOSED, your backend's POST .../orders, DELETE .../orders/{orderId}, PUT and POST .../leverage answer 403 code 4043 SERVER_ORDER_ENTRY_CLOSED ("Server-side order entry is closed for this partner: orders, cancels and leverage changes are entered through the app order commands.", data.error), and over FIX a NewOrderSingle(35=D) and an OrderCancelRequest(35=F) answer 9110=4043 with the same sentence (docs/14 3.4 and 3.5), before anything is looked at or stored; every read, the FIX status request, barriers, staking, money and the app order commands stay open. Whether a partner's server-side order entry must close once its app sends the commands directly is a decision of our compliance function; it is set only on that decision and announced to you at least 30 days ahead under section 21. No date is set for any partner.
Every command whose token we accepted, and every signed refusal (22.6), is recorded on our side in an append-only table with: the token verbatim and its SHA-256, its iss, aud, sub, kid, alg, jti, iat and exp, the Idempotency-Key, the request's fingerprint, for a leverage change its symbol and leverage, the caller's address, origin and user agent, the instant, the outcome (EXECUTED, REFUSED, REFUSED_SIGNED, REPLAY, or PENDING for a cancel or replace that failed on our side past its point of no return and was answered its pending answer, 22.7; a place whose venue answer was lost, or not confirmed by the venue (revision 23), is answered state SENT by our pipeline itself and recorded EXECUTED, the order converging through our reconciliation) with the status, the code and the sentence, the order the command placed and the order it acted on. The record is never purged: it is kept as long as the orders (the 24 hours of the Idempotency-Key store are never the record). An order the command placed reads channel APP and channelRef the jti on every read and event (12.3, 16.4); our own order history names the partner, the issuer, the key id and the jti beside it, and our audit lines say that the client placed it through your app. To dispute a command, give our operations its jti: they find every command the token carried and can verify the token again against the archived key (22.4). On a client's erasure under data protection law the address, the origin and the user agent are blanked; the token, its claims and the order links stay as the order record.
The audience is https://dev.brokerage.perpetuals.com/app (section 18). Our partner simulator SBSIM exercises the channel end to end there with development keys (key ids dev-...) that our edge host serves at https://dev.brokerage.perpetuals.com/dev/jwks/{name}.json; those keys and that path are ours. For your integration our operations configure your own issuer and key set URL under four eyes, and list your web app origins at our edge (22.5); a key set published for the development environment may hold dev- key ids, a production one may not. The conformance list is docs/12 section 12.
Since revision 28 (2026-10-24; issue #122, the operator's decisions of 2026-10-24). The code is PartnerDemo.cs (the routes, the switches and the webhook door), DemoPerp.cs, DemoPerp.Engine.cs, DemoPerp.Loops.cs and DemoPerp.Wire.cs (the engine) and DemoPerpMath.cs (the pure core). The walk-through for your engineers is docs/12 section 13.
A PERPETUAL DEMO ACCOUNT is a simulated perpetual futures account in VIRTUAL USD that you open for one of your clients and trade for that client from your backend, server to server, under /partner/v1/clients/{clientId}/demo/perp. Your user practises our perpetual markets with the live economics (the client's own leverage caps, margin, margin call, close-out, negative balance protection, commission and funding) and no money at risk. Your demo switch is OFF by default (23.2); while it is OFF nothing of this section applies to you.
currency "USD", virtual true, whatever your custody model and collateral asset): it has no value, cannot be deposited, withdrawn, transferred or converted, and is never part of the client's real balances (the display rules are 23.10)./app/v1) and none over FIX (section 20).DPA-, DPO-, DPF-, DPJ- followed by digits), so an order id of section 12 answers 404 there, and a live route never binds a demo id.Every demo route decides, in this order, each before the next is looked at:
DEMO_NOT_ENABLED "The perpetual demo is not enabled for this partner." with data {"error": "DEMO_NOT_ENABLED"}, and no demo event is sent. It is decided before the client, the Idempotency-Key and our idempotency store: nothing is stored, and the same key runs as a first use once the switch is ON.Idempotency-Key (23.8).A router that does not run the demo answers 503 code 5030 "This service is not available on this router." after the global switch. GET /partner/v1/me demo.perp (section 8) shows the switches as they apply to you: enabled is your switch, available is your switch AND our global switch AND the demo running on the host you reached. On the production environment the demo is not offered before our compliance function has approved its served texts (23.10) and the go-live items of docs/12 10.6 are done; available is the truth.
The demo is NOT gated by your server-side order entry (22.10, code 4043), your reliance status (code 4036), our verification gate (4030), the TERMS, key information document and risk disclosure consent gates (4035), or the settlement close-only state of 15.7 (4041): no demo route ever answers 4030, 4035, 4036, 4041, 4043 or 4044, and 4047 is the demo's alone. The client restrictions and trading halts of our compliance function do apply (23.4).
While either switch is off the accounts stay as they are: the open positions keep their economics (resting orders fill, triggers fire, funding is charged, margin call and close-out run), you can neither read nor write them, and no event reaches you, including none for what happened meanwhile: read the account back when the demo is available again. The retention of 23.3 pauses meanwhile.
DPA-<n>.POST .../demo/perp/account with {"adultConfirmed": true}. The member is YOUR ATTESTATION that the client is 18 or older; we record it with your partner code, your key's prefix and the instant, and ask the client nothing. Without a JSON true the open answers 400 code 4000 "A demo account is available to adults only: confirm that the client is 18 or older with adultConfirmed set to true." A new account answers 201 with a balance of 10,000.00 USD; an existing one answers 200 as it stands (never a second starting balance).data.error ACCOUNT_CLOSED) on every write but the deletion; a client whose lifecycle state is not KYC_PENDING, KYC_APPROVED or ACTIVE (any state added later included), whose latest KYC package our compliance function rejected, who holds a standing sanctions match or whose latest screening result is a hit not yet decided: the open and every opening order answer 400 code 4000 "A demo account is not available for this client." Such a client keeps every read, cancels, reducing orders, top-ups, resets and the deletion. These are prudence rules, not a verification level.data.error DEMO_AT_CAPACITY). At most 50 live orders per account: "You can have at most 50 open demo orders." (400 code 4000). An order whose attach carries a take profit and a stop loss counts as the two live orders it becomes when it fills (one leg, one), so the legs a fill creates never take the account past the cap.POST .../demo/perp/top-up {"amount": "500.00"} adds virtual USD: a decimal string (or a JSON number) of at most two places, 1.00 to 10,000.00 a call, else "The top-up must be between 1.00 and 10,000.00 USD, in whole cents.", and at most 90,000.00 since the last reset, else "Top-ups since the last reset are capped at 90,000.00 USD; 1,500.00 USD remain." with what remains. At most 100,000 USD go into an account per reset.POST .../demo/perp/reset at any time, never refused for a restriction, a margin call or a close-out: every live order is cancelled (reason RESET), every position is closed WITHOUT a fill (no result is booked), the balance returns to 10,000.00 USD by one RESET journal entry, the top-ups since the last reset and the risk state are reset (NORMAL), and no funding settlement before the reset is ever charged. The leverage choices stay.FILLED or CANCELED more than 30 days ago is deleted with its fills, and every journal entry older than 30 days is deleted with its amount carried into the account (the balance does not move; the journal no longer sums to it). An account without a WRITE of yours for 30 days (an open, an order, a cancel, a replace, a close, a leverage change, a top-up or a reset; a read does not count) is deleted WHOLE, open positions included, with the event demo.account.deleted {reason: "INACTIVE"}. While the demo is unavailable to you (23.2) nothing is deleted, old rows included, and the 30 days count again from the moment it is available; an account whose partner switch has stayed OFF for 90 days is deleted all the same. deleteAfter on the account is the instant after which our hourly retention deletes it: 30 days after the latest of your last write, your switch's last change and our global switch's last start, every rule above applied. A deleted account answers 404 code 4004 "This client has no demo account." (data.error NO_DEMO_ACCOUNT); open a new one if your user wants to continue.DELETE .../demo/perp/account deletes every row of the account at once (orders, fills, positions, leverage choices, journal and the account), is never refused for a restriction or a closed client, answers 200 {deleted: true, counts} and sends demo.account.deleted {reason: "PARTNER"}; without an account it answers 200 {deleted: false}. Use it when your user asks you to delete their demo data. Like every demo route it answers 503 or 4047 while the demo is unavailable to you (23.2): send such a request to our operations then. Our compliance function deletes on a request that reaches us directly, whatever either switch reads (reason STAFF).system for our own passes) and goes with the row; it never lands on the client's supervisory record.Every figure comes from the SAME functions as a live account (the margin engine, the close-out state machine, the funding arithmetic and the commission arithmetic) over the demo account's own rows.
MARKET, a LIMIT that reaches the mark, the child of a triggered conditional that does) fills its WHOLE quantity at the venue's mark price read from the venue's public ticker AT THE INSTANT OF THE FILL, never at an older mark. Every order you send that is not conditional is decided on that same read: whether it can fill at once, whether a POST_ONLY order would cross, the price band and the order's value. It fills nothing while the opposite side of the venue's order book is empty (no best ask for a buy, no best bid for a sell): a MARKET or an IOC order is then cancelled IOC_UNFILLED, a FOK order FOK_UNFILLED, and a GTC LIMIT stays OPEN as an order that WAITS FOR THAT SIDE: it never fills at its own limit while the side is empty, fills whole at the mark of the ticker our mark loop reads once the side has an order, and, once that mark has moved away from its limit, rests as any limit does. The size is never bounded by the depth of the book and the demo never moves the book. A RESTING LIMIT fills whole at ITS OWN price as soon as the mark reaches it, gap or not, without a queue. A POST_ONLY order that would cross the mark is refused (23.7). A CONDITIONAL order triggers on the mark whatever trigger.source you sent (LAST, MARK or INDEX; the order reads trigger.evaluatedOn "MARK"), from the next pass of our mark loop (about every second over marks polled every 2 seconds). LIVE FILLS DIFFER BY THE SPREAD AND THE DEPTH OF THE BOOK: a live market order pays the spread and walks the book within the venue's price collar, a live limit order waits in a queue and may never fill, and a live order meets other traders and latency. A demo result is never what the same orders would have produced live.liquidity on a fill is a label, TAKER for an immediate fill and MAKER for a resting limit; the commission is the same for both.PUT .../demo/perp/leverage), bounded the same way. A demo choice never touches the client's live leverage, and a live choice never the demo's.NORMAL, MARGIN_CALL and CLOSE_OUT. On a close-out every live demo order is cancelled (CLOSE_OUT) and every position is closed at the mark, commission charged, in the pass that decides it; a position on a market the venue holds halted or in a post-only session (where the venue refuses a market order, a live close-out's included), or without a fresh ticker, is closed on a later pass. Once flat, a negative balance of a client our negative balance protection covers (RETAIL) is brought back to zero by an NBP_WRITEOFF journal entry; another client's negative balance stays until a top-up or a reset. A LIVE close-out cancels first and closes at the book's prices on a later tick, so it usually realises more loss than the demo's.fundingRate and markPrice, the position held at the settlement's fold line) with the live arithmetic -round_half_even(qty x markPrice x fundingRate, 6 places): a long pays a positive rate, a short receives it. One FUNDING journal entry per settlement and account, never for a settlement before the account's open or last reset, and none where the venue settles none (a market that cannot trade). It follows the venue's records shortly after we record them. A demo account is never part of our firm's net position at the venue, so no rounding remainder (15.3) ever reaches it.LIMIT, the minimum and the maximum order value, post-only, and each market's trading state as the venue publishes it (read every 5 seconds; a state older than 30 seconds reads as trading): no fill and no trigger on a market or venue the venue holds halted or delisted; while a market is reduce-only only an order that reduces the client's position AND is a MARKET, or a LIMIT IOC or FOK, without attach is admitted (the live router sends exactly such an order again with the venue's reduce-only flag; a resting order, a conditional or an order with attach is refused, reducing or not); every order that is not post-only is refused while a market is post-only. The minimum order value applies to every order you send, the close of 23.6 included, as it does live; only the close-out's own flatten and an order a reduce-only session admitted are exempt (the venue exempts its reduce-only flag). A member-level outcome of the venue (its margin, position and order caps on our firm, its liquidation, ADL and throttles) never applies to the demo.B stands for /partner/v1/clients/{clientId}/demo/perp; {clientId} is our numeric client id or the BARR- uid, as on every client route.
| # | Method and path | Scope | Keyed | Answer |
|---|---|---|---|---|
| 1 | POST B/account | TRADE | yes | 201 created or 200 existing: {account} |
| 2 | GET B | READ | 200 {account} | |
| 3 | POST B/top-up | TRADE | yes | 200 {account, entry} |
| 4 | POST B/reset | TRADE | yes | 200 {account, canceledOrders, closedPositions} |
| 5 | POST B/orders | TRADE | yes | 200 {order, fills, account} |
| 6 | DELETE B/orders/{orderId} | TRADE | no | 200 {order} |
| 7 | POST B/orders/{orderId}/replace | TRADE | yes | 200 {canceled, order, fills, account} |
| 8 | GET B/orders/open | READ | 200 {orders} | |
| 9 | GET B/orders?limit&before&state | READ | 200 {orders, next} | |
| 10 | GET B/orders/{orderId} | READ | 200 {order, fills} | |
| 11 | GET B/positions | READ | 200 {positions, marksFresh} | |
| 12 | POST B/positions/{symbol}/close | TRADE | yes | 200 {order, fills, account} |
| 13 | GET B/fills?limit&before&symbol | READ | 200 {fills, next} | |
| 14 | GET B/leverage?symbol= | READ | 200 {leverage} | |
| 15 | PUT B/leverage | TRADE | yes | 200 {leverage} |
| 16 | POST B/margin/preview | TRADE | no | 200 the preview |
| 17 | GET B/journal?limit&before&kind | READ | 200 {entries, next} | |
| 18 | POST B/leverage | TRADE | yes | the same as 15, for a client that cannot send PUT |
| 19 | DELETE B/account | TRADE | no | 200 {deleted: true, counts} or {deleted: false} |
Every answer is the envelope of section 4, {"code": 0, "data": {...}}, and every data carries "demo": true; no demo route is bare. Every route but 1 and 19 answers 404 code 4004 "This client has no demo account." with data.error NO_DEMO_ACCOUNT while the client holds no account. The paged reads take limit 1 to 200 (default 50, else "The page size must be a whole number between 1 and 200.") and before, the exclusive cursor in the route's own id form (DPO-<n>, DPF-<n>, DPJ-<n>; anything else "The paging cursor is not valid."), newest first; next is the cursor of the next page or null. state filters orders (PENDING_TRIGGER, OPEN, FILLED, CANCELED), kind filters the journal (OPEN, TOP_UP, RESET, REALIZED_PNL, COMMISSION, FUNDING, NBP_WRITEOFF, RISK_STATE); another state or kind answers 400 code 4000 naming the values (23.7). symbol filters fills; a market the account never traded answers an empty page. Money is a decimal string of 6 places in virtual USD, prices and quantities decimal strings, times Unix milliseconds.
POST B/account {"adultConfirmed": true} (23.3). 201 or 200:
{
"code": 0,
"data": {
"demo": true,
"account": {
"accountId": "DPA-41", "virtual": true, "currency": "USD",
"balance": "10000.000000", "startingBalance": "10000.000000", "toppedUp": "0.000000", "topUpRemaining": "90000.000000", "resets": 0,
"createdAt": 1793011200000, "resetAt": null, "lastActivityAt": 1793011200000, "deleteAfter": 1795603200000,
"riskState": "NORMAL", "categorization": "RETAIL", "closeOutPct": "50", "marginCallPct": "100", "negativeBalanceProtection": true,
"margin": {"equity": "10000.000000", "cash": "10000.000000", "unrealizedPnl": "0.000000", "initialMargin": "0.000000", "maintenanceMargin": "0.000000",
"marginCallLevel": "0.000000", "positionMargin": "0.000000", "orderMargin": "0.000000", "freeMargin": "10000.000000", "marginLevel": null,
"closeOutEquity": "10000.000000", "marksFresh": true},
"positions": [],
"openOrders": 0,
"notice": {"version": "demo-perp-notice-1", "sha256": "7164475b22e02cde2a19329ddb3dcdef49c7e11ee44c18feb71231abf9b91638",
"provider": "This demo is provided by PM MTF Ltd. It is a simulation: no order is sent to any market and no money is at risk.",
"virtualFunds": "The balance is virtual USD. It has no value, cannot be withdrawn and is not part of your real balances.",
"simulated": "Simulated results do not indicate future results. Live trading can lose more quickly than a simulation suggests.",
"notSimulated": "Demo orders fill at the mark price. Live fills differ by the spread and the depth of the order book, and live orders can wait in a queue or not fill at all.",
"text": "[the four sentences above, in this order, joined by a space]", "kid": null},
"riskWarning": {"standard": "[the standard retail risk warning as our disclosures read serves it]", "fallback": false}
}
}
}
The account (DemoPerpAccount): balance the virtual cash (the starting balance, the top-ups, the realised results, the commission, the funding and a write-off); startingBalance at the open or the last reset; toppedUp and topUpRemaining since the last reset; resetAt the last reset (null before one); deleteAfter the instant after which our retention deletes the account (23.3); riskState and the live facts applied (categorization, closeOutPct, marginCallPct, negativeBalanceProtection); margin the live GET .../margin figures of 12.3 for the demo account, perpetual members only (no barrier, spot or pending accept member); positions the DemoPerpPosition objects; openOrders the number of live orders; notice and riskWarning 23.10.
GET B: the account, as above.
POST B/top-up {"amount": "500.00"} (23.3): {account, entry}, entry the TOP_UP journal entry.
POST B/reset (no body, or {}; 23.3): {account, canceledOrders, closedPositions}, the two counts of what the reset cancelled and closed.
POST B/orders: the LIVE order body of 12.3, unchanged (symbol, side, type, qty, price, tif, reduceOnly, trigger, attach, ocoGroup, leverage), so your live ticket serves both. Perpetual markets only: another family answers 400 code 4000 "Demo accounts trade perpetual markets only." The checks run in the live order (the shape, the market, the restrictions and halts, the market controls, the OCO group, the grid, a fresh mark, the attached legs, the venue's market-level rules, the open order cap, the leverage hint 409 code 4091 with {leverageSent, leverageStored}, the live margin check with its sentences), and a refused order writes nothing. Then the order fills, rests (OPEN) or waits for its trigger (PENDING_TRIGGER) as 23.4 says. A filled parent with attach creates its take profit and stop loss legs as PENDING_TRIGGER reduce-only orders of origin ATTACHED in one OCO group, as live; the fill of one cancels the other (OCO); a reduce-only order the position no longer needs is resized or cancelled (REDUCE_ONLY).
{
"code": 0,
"data": {
"demo": true,
"order": {"orderId": "DPO-1207", "symbol": "SOL-USD-PERP", "side": "BUY", "type": "MARKET", "tif": "IOC", "qty": "10", "price": null, "trigger": null,
"reduceOnly": false, "ocoGroup": null, "attach": null, "parentOrderId": null, "replacesOrderId": null, "origin": "CLIENT", "state": "FILLED",
"filledQty": "10", "avgFillPrice": "150.25", "commission": "0.751250", "cancelReason": null, "leverage": 10,
"createdAt": 1793011260000, "triggeredAt": null, "doneAt": 1793011260000},
"fills": [{"fillId": "DPF-3301", "orderId": "DPO-1207", "symbol": "SOL-USD-PERP", "side": "BUY", "qty": "10", "price": "150.25", "liquidity": "TAKER",
"mark": "150.25", "commission": "0.751250", "realizedPnl": "0.000000", "origin": "CLIENT", "ts": 1793011260000}],
"account": {
"accountId": "DPA-41", "virtual": true, "currency": "USD",
"balance": "9999.248750", "startingBalance": "10000.000000", "toppedUp": "0.000000", "topUpRemaining": "90000.000000", "resets": 0,
"createdAt": 1793011200000, "resetAt": null, "lastActivityAt": 1793011260000, "deleteAfter": 1795603260000,
"riskState": "NORMAL", "categorization": "RETAIL", "closeOutPct": "50", "marginCallPct": "100", "negativeBalanceProtection": true,
"margin": {"equity": "9999.248750", "cash": "9999.248750", "unrealizedPnl": "0.000000", "initialMargin": "150.250000", "maintenanceMargin": "75.125000",
"marginCallLevel": "150.250000", "positionMargin": "150.250000", "orderMargin": "0.000000", "freeMargin": "9848.998750", "marginLevel": "6655.07",
"closeOutEquity": "9999.248750", "marksFresh": true},
"positions": [{"symbol": "SOL-USD-PERP", "side": "LONG", "netQty": "10", "avgEntryPrice": "150.25", "mark": "150.25", "markFresh": true,
"unrealizedPnl": "0.000000", "initialMargin": "150.250000", "roiPct": "0.00", "leverage": 10, "realizedPnl": "0.000000",
"estLiquidationPrice": null, "openedAt": 1793011260000, "updatedAt": 1793011260000}],
"openOrders": 0,
"notice": {"version": "demo-perp-notice-1", "...": "the notice of POST B/account"},
"riskWarning": {"standard": "[the standard retail risk warning]", "fallback": false}
}
}
}
(Illustrative figures: a commission scheme of 5 basis points on the notional of 1,502.50 USD, and the RETAIL cap of 10 on the market.)
The order (DemoPerpOrder): the live order members of 12.3 where they apply, orderId DPO-<n>, trigger {source, price, direction, limitPrice, evaluatedOn}, attach {takeProfit, stopLoss, legs, note} (legs the DPO- ids of the legs once the parent filled), parentOrderId and replacesOrderId as DPO- ids, origin CLIENT, ATTACHED or CLOSE_OUT, state PENDING_TRIGGER, OPEN, FILLED or CANCELED, filledQty, avgFillPrice, commission, cancelReason (CLIENT, REPLACED, IOC_UNFILLED, FOK_UNFILLED, OCO, REDUCE_ONLY, CLOSE_OUT, RESET, RESTRICTION, HALT), leverage (the effective leverage at admission), createdAt, triggeredAt, doneAt. The fill (DemoPerpFill): fillId DPF-<n>, orderId, symbol, side, qty, price, liquidity, mark (the mark the fill was decided on), commission, realizedPnl, origin, ts.
DELETE B/orders/{orderId} (DPO-<n>; anything else, or an order of another account, 404 code 4004 "This demo order was not found." with data.error DEMO_ORDER_NOT_FOUND): {order} with state CANCELED, cancelReason CLIENT; an order no longer live answers 400 code 4000 "This demo order is no longer open."
POST B/orders/{orderId}/replace {"order": <the body of POST B/orders>}: ATOMIC. The old order is cancelled (REPLACED) and the new one admitted in ONE step; any refusal changes nothing (the sentence, with data {cancelled: false, placed: false}). 200 {canceled, order, fills, account}, canceled the old order, order the new one with replacesOrderId. Unlike the live replace (codes 4001 and 4002) the demo never leaves you without the old order and the new one.
GET B/orders/open: {orders}, every live order, oldest first. GET B/orders: {orders, next}. GET B/orders/{orderId}: {order, fills}.
GET B/positions: {positions, marksFresh}. The position (DemoPerpPosition): symbol, side (LONG or SHORT), netQty, avgEntryPrice, mark, markFresh, unrealizedPnl, initialMargin, roiPct, leverage, realizedPnl (since the position opened), estLiquidationPrice, openedAt, updatedAt: the live position figures of 12.3.
POST B/positions/{symbol}/close {"qty": "4"} (optional; default the whole position, at most its size: "This reduce-only order is larger than your open position, so it cannot be placed.", a quantity that is not a positive number "The quantity must be a number greater than zero."; no position "There is no open demo position on this market."): a reduce-only MARKET IOC on the opposite side through the same checks as an order, the minimum order value included (a position below it cannot be closed this way, as live: reset the account). {order, fills, account}.
GET B/fills: {fills, next}, newest first.
GET B/leverage?symbol= (a symbol is required: "A market symbol is required."): {leverage} in the live shape {symbol, leverage, cap, categorization, chosen}, cap the client's live cap, chosen the demo account's choice (null until one is set), leverage the effective one.
PUT B/leverage and POST B/leverage {symbol, leverage, force}, the live body: the choice within the live cap (the live sentences), stored on the demo account; a change that would put the account in margin call or close-out without force answers 409 code 4090 with the live data (wouldBreach, initialMarginAfter, maintenanceMarginAfter, freeMarginAfter, marginLevelAfter, riskStateAfter); leverage missing "A leverage value is required.". {leverage} as above.
POST B/margin/preview: the live preview body (symbol, side, type, tif, qty, price, trigger, reduceOnly, leverage), no side effect and no key, the live preview members on the demo account (accepted, reason, orderValue, marginRequired, leverage, freeMarginBefore, freeMarginAfter, marginLevelAfter, estLiquidationPrice, equityBefore, initialMarginBefore, initialMarginAfter, estimatedCommission, slippageAllowance, estimatedCosts).
GET B/journal: {entries, next}, every balance move of the account and every risk state change. The entry (DemoPerpJournalEntry): entryId DPJ-<n>, kind (OPEN, TOP_UP, RESET, REALIZED_PNL, COMMISSION, FUNDING, NBP_WRITEOFF, RISK_STATE), amount (signed; 0 on RISK_STATE), balanceAfter, symbol, orderId, fillId, settledAt (a FUNDING entry's settlement), detail (a FUNDING entry {qty, rate, mark, foldAt}, a RESET {balanceBefore, canceledOrders, closedPositions, riskStateBefore}, a RISK_STATE the transition and its figures), ts.
DELETE B/account (23.3): {deleted: true, counts: {journal, fills, orders, positions, leverage, account}} or {deleted: false}.
The live sentences of 12.3 travel unchanged; the demo's own:
| Code | HTTP | When and sentence | data |
|---|---|---|---|
| 4000 | 400 | A refused order or request with the live sentence (the shape, the grid, the margin check, the restrictions and halts, the legal-entity rule), and the demo's own: "Demo accounts trade perpetual markets only.", "A demo account is not available for this client.", "A demo account is available to adults only: confirm that the client is 18 or older with adultConfirmed set to true.", "You can have at most 50 open demo orders.", "There is no open demo position on this market.", "This demo order is no longer open.", "The top-up must be between 1.00 and 10,000.00 USD, in whole cents.", "Top-ups since the last reset are capped at 90,000.00 USD; 1,500.00 USD remain." (with what remains), "A market symbol is required.", "A leverage value is required.", "There is no fresh mark price for SOL-USD-PERP right now. Please try again shortly." (the market named), "The state must be one of PENDING_TRIGGER, OPEN, FILLED, CANCELED.", "The kind must be one of OPEN, TOP_UP, RESET, REALIZED_PNL, COMMISSION, FUNDING, NBP_WRITEOFF, RISK_STATE." | {cancelled: false, placed: false} on a refused replace |
| 4004 | 404 | Unknown client ("Unknown client."); "This client has no demo account."; "This demo order was not found." (also an id that is not DPO-<n>, or another account's order); a market not offered ("This market is not offered.") | {error: "NO_DEMO_ACCOUNT"}, {error: "DEMO_ORDER_NOT_FOUND"} on the two demo answers |
| 4034 | 403 | A READ key on a demo write. | |
| 4047 | 403 | DEMO_NOT_ENABLED: "The perpetual demo is not enabled for this partner." (23.2) | {error: "DEMO_NOT_ENABLED"} |
| 4090 | 409 | A leverage change that would breach, without force. | the live members |
| 4091 | 409 | The order's leverage hint is not the account's choice. | {leverageSent, leverageStored} |
| 4092 | 409 | "This account is closed." (a CLOSED client's demo write); "The demo is at capacity."; an Idempotency-Key used for another request, or still in progress (section 5, 23.8). | {error: "ACCOUNT_CLOSED"}, {error: "DEMO_AT_CAPACITY"} on the two demo answers |
| 4290 | 429 | The demo window (23.8): "Too many requests. Please slow down and try again." | |
| 5020 | 502 | The venue's instrument facts could not be read: "The trading venue cannot be reached right now. Please try again shortly." Nothing was written. A failed read of the venue's public ticker answers the fresh mark sentence of the 4000 row instead. | |
| 5030 | 503 | Our global demo switch off ("The demo account is not available."), the demo not running on this router ("This service is not available on this router."), or the router without the venue's public ticker read ("The demo cannot price orders right now. Please try again shortly."). |
The venue's market-level rules answer 400 code 4000 with the LIVE client sentence of the venue's code, the same one a live order gets:
| Venue code | When | Sentence |
|---|---|---|
| 5002 | the venue halted | "The trading venue refused the order: Venue halted." |
| 3003 | the market halted | "The trading venue refused the order: Instrument halted." |
| 3006 | the market delisted | "The trading venue refused the order: Instrument delisted." |
| 3002 | while the market is reduce-only, every order but a reducing MARKET, or LIMIT IOC or FOK, without attach; while it is post-only, every order that is not post-only | "The trading venue is temporarily restricting orders on this market. Please try again shortly." |
| 2013 | the order value above the market's maximum (market orders at the mark, priced orders at their limit) | "The trading venue refused the order: Order value above the market maximum." |
| 2003 | a LIMIT price outside the band around the mark | "The price is outside the allowed band for this market." |
| 2028 | the order value below the market's minimum, every order you send (only the close-out's flatten and an order a reduce-only session admitted are exempt) | "The order value is below the minimum for this market." |
| 2005 | a POST_ONLY order that would cross the mark | "The trading venue refused the order: Post-only order would cross." |
A demo route never answers 4030, 4035, 4036, 4041, 4043 or 4044.
Idempotency-Key of section 5, optional as everywhere, the 24-hour store with the body compared): POST B/account, POST B/orders, POST B/orders/{orderId}/replace, POST B/positions/{symbol}/close, PUT B/leverage (and its POST twin), POST B/top-up and POST B/reset. NOT KEYED: DELETE B/orders/{orderId} (a repeat finds the order no longer open), DELETE B/account (a repeat answers {deleted: false}) and the preview (no side effect). A demo write never reaches the venue, so it marks no point of no return; the demo ALSO records the key on the order or journal row it wrote, so a retry after a fault of our store finds that row and answers it instead of writing a second order, top-up or reset (for 24 hours, and only for the same body; another body under the key answers 409 code 4092 "This Idempotency-Key was already used for another request.").GET and HEAD requests and 120 other requests per minute, shown on GET /me demo.perp.limits. Beyond them 429 code 4290 with the live sentence. Demo traffic never draws on your live readPerMinute and tradePerMinute (section 6), your FIX bucket or the windows of the app order commands, and live traffic never on the demo's.The demo's events reach your webhook (section 16) with the live envelope, signature, retry and retention, under names of their own that all start with demo. and are never among the live names of 16.4:
| Event | When | data (always demo: true, accountId, ts) |
|---|---|---|
demo.order.filled | every fill, the immediate ones included | orderId, fillId, symbol, side, qty, price, liquidity, commission, realizedPnl, origin, netQtyAfter |
demo.order.triggered | a conditional order activated | orderId, symbol, type, triggerPrice, mark, becomes (MARKET or LIMIT) |
demo.order.canceled | every cancel | orderId, symbol, reason (the cancelReason values of 23.6) |
demo.margin.call | the account entered margin call | equity, closeOutEquity, maintenanceMargin, marginCallLevel, marginCallPct |
demo.margin.call_cleared | the margin call ended | the same members |
demo.close_out.started | the close-out started | the same members and closeOutPct |
demo.close_out.done | the close-out ended | balance, writtenOff |
demo.account.deleted | the account was deleted | reason: INACTIVE (23.3), PARTNER (DELETE B/account) or STAFF (our compliance function) |
clientId, clientUid and partnerClientRef, as on the live events.rfq.quote and rfq.quote ahead of every demo event, and attempts at most 10 demo events of yours per delivery pass and none once your turn of the pass has taken 2 seconds, so demo traffic never holds back a live event of the next pass; and while 1,000 or more deliveries of yours are pending (every event counted; our setting Partners:Demo:WebhookMaxPending, never above the live cap of 16.3) a further demo event is DROPPED, not queued. The live rfq.quote cap of 16.3 counts no demo event, so demo traffic never costs you a live quote. A delivered demo event leaves our delivery log after a day (a live one after 30 days, 16.3). The account reads of 23.6 are the source of truth: re-read after a gap.id as for every event (16.2).THE NOTICE. Every account answer carries notice, the demo notice we serve in English, ENGLISH MASTER AND NOT YET APPROVED COPY: each sentence passes our marketing approval per language before the demo is offered on the production environment, and the version tag moves with every change of a sentence. Show the four sentences VERBATIM wherever the demo is offered, in the predominant font size of the screen, never rephrased, translated or shortened by you:
| Member | Sentence (version demo-perp-notice-1) |
|---|---|
provider | This demo is provided by PM MTF Ltd. It is a simulation: no order is sent to any market and no money is at risk. |
virtualFunds | The balance is virtual USD. It has no value, cannot be withdrawn and is not part of your real balances. |
simulated | Simulated results do not indicate future results. Live trading can lose more quickly than a simulation suggests. |
notSimulated | Demo orders fill at the mark price. Live fills differ by the spread and the depth of the order book, and live orders can wait in a queue or not fill at all. |
text is the four joined by a space, sha256 the lower-case hex SHA-256 of the notice's canonical text (family=DEMO_PERP_NOTICE, locale=en, then t.<member>=<sentence> per member in this order, each line ended by a line feed): 7164475b22e02cde2a19329ddb3dcdef49c7e11ee44c18feb71231abf9b91638 for version demo-perp-notice-1. kid is null: no key information document for a perpetual market is filed yet; once one is, kid carries {doc, version, url} and showing where it is becomes part of this duty. Re-read the account at least daily so a changed notice reaches your users within a day.
The notice is served in English only today. An app that shows the demo in another language agrees with us before go-live whether it shows the English notice or waits for a translation, which we serve only once our compliance function has approved it for that language (docs/12 10.6).
riskWarning is the standard retail risk warning form as our disclosures read serves it ({standard, fallback}). Whether your demo screen must show it is an open question of our compliance function (with the risk indication a marketing communication carries); until it is decided it is served for you to show. Revision 16 deprecated the same form on GET /me disclosures for the live products of this API, which the reference requires it for none of; the demo serves it beside the notice because the question for the demo is still open.
THE VIRTUAL LABEL. Every demo figure carries the word "virtual" or "demo" beside it; a demo balance, result or position is never summed with, shown inside or listed without a clear separation from the user's real balances (your stablecoin, our collateral figures, any real money). The demo order ticket says it is a demo ticket; the live order-entry notice of your app (orders received and executed by PM MTF Ltd) is NEVER shown on the demo ticket, because no order is received or executed there. The placement rules are docs/12 13.5.
GTC limit that waits for that side fills at the mark of the moment it is there, where live it would rest on the book and fill at its own price.enabled and offered true on GET /market-info, 12.1). An order that would open or add exposure on any other market is refused, as a live one is: a market that is not enabled answers 404 code 4004 "This market is not offered." (to every order without reduceOnly true), a market our operator switched off for new exposure (offered false) 400 code 4000 "This market is not currently offered.". A market switched off for new exposure stays marked, so its demo positions can still be reduced and closed and the demo close-out acts on them as on any other market. We read a market's mark while its offered-instrument row is enabled, while a live client holds a position on it or while a live barrier contract on it is open; a demo position alone never keeps a market's mark read. So if we DISABLE a market (its offered-instrument row) while demo accounts, and no live client, still hold positions on it, those demo positions are no longer marked and no longer closed out until the market is active again: every demo order on that market with reduceOnly true, a close included, is refused with 400 code 4000 "There is no fresh mark price for <symbol> right now. Please try again shortly." (any other order answers the 404 above), and the demo close-out does not act on the account's picture while that market's mark is stale. A reset (23.3), which closes every position without a fill, or the deletion of the account clears them. Live, such a market stays marked until the last live position on it is flat.