Revision 7, 2026-09-03. 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 code is normative (PartnerAuth.cs, Partners.cs, PartnerOnboarding.cs, PartnerEndpoints.cs, PartnerCustody.cs, PartnerWebhooks.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.
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.
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), categorised as RETAIL only. Legal entities and professional clients are refused with a field error and will be added in a later revision.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.") and our back office refuses to pay 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.
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). |
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.
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 and MiFID writes the message is "Please check the highlighted fields." and data.errors maps dotted field paths to one sentence each. | {errors} on validation |
| 4000 | 413 | A request body over the size cap (256 KB on the onboarding and KYC writes, 8 MB on a document). | |
| 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. | |
| 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 or webhook; a market that is not offered on the leverage and preview routes. | |
| 4010 | 401 | No key, an unknown or revoked key, the key of a CLOSED partner, or a source address outside the allowlist. | |
| 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). | {action, level, requiredLevel, missingBlocks} |
| 4034 | 403 | The key's scope does not cover the route (a READ key on a write). | |
| 4036 | 403 | RELIANCE_NOT_APPROVED: an onboarding, KYC or MiFID write 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"} |
| 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, a partnerRef reused, a stale appropriateness test version, 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. | sometimes {currentVersion}; {reportId, status, refusal, report} on a refused collateral report; {item} on the settlement conflict |
| 4290 | 429 | The partner's rate limit, or an appropriateness retake before the cooling period elapsed. | {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). | route specific |
| 5030 | 503 | The service the route needs is not built on this router (a test or api-only deployment). |
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.
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.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.
/clients/{clientId} route accepts our numeric client id (412) or the client uid (BARR-00000412). A client that does not belong to the partner answers 404 code 4004 ("Unknown client."). GET /clients/by-ref/{partnerClientRef} resolves your own reference."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.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 /partner/v1/meThe partner profile, reliance status, limits, the calling key and the webhook state.
{
"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},
"asOf": 1788310800000
}
}
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).
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",
"verificationLevel": "MICA", "lifecycleState": "ACTIVE", "categorization": "RETAIL", "riskState": "NORMAL",
"cashUsdc": "1500.000000", "createdAt": 1788307300000
}
],
"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.
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}},
"clientCash": [{"currency": "USDC", "cash": "184210.500000", "reserved": "2500.000000", "total": "186710.500000"}],
"openBarriers": 14, "openSwaps": 3, "pendingDeposits": 2, "pendingWithdrawals": 1, "pendingReviews": 1
}
}
pendingReviews counts KYC packages in SPOT_CHECK or DOCUMENTS_REQUESTED.
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 characters, 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). LEGAL or CORPORATE answer a field error "Legal entities are not supported for partner onboarding yet." |
categorization | no | RETAIL (default); anything else is a field error. |
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 |
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)**
Fourteen 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. Add acceptedAt (Unix ms, when the client made the declarations in your interface); it is stored with the package as received.
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.
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, "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.{
"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}
}
}
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). 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.
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", "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).
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. 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.
{
"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.
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) or PARTNER.
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, 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.
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.
{
"code": 0,
"data": {
"version": 1,
"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"
}
}
POST /partner/v1/clients/{clientId}/appropriatenessScope TRADE. Body {version, answers}: version the version you rendered (a stale one answers 409 code 4092 with data.currentVersion, so re-read the questions), answers an object of question id to option id, every question answered (400 code 4000 names the first missing or unknown answer). The result is recorded, the webhook appropriateness.result fires, and the MiFID grant is attempted at once.
{"version": 1, "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, "verificationLevel": "MICA_MIFID", "granted": true}
}
granted is true when this call moved the level to MICA_MIFID (the four blocks were already in place). 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.
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 {acknowledgedAt} (Unix ms, required, not in the future). The row carries our server time as the acknowledgement time and yours as the attested time.
{"code": 0, "data": {"acknowledgedAt": 1788311300000, "attestedAt": 1788311280000, "outcome": "WARN", "verificationLevel": "MICA_MIFID", "granted": true}}
400 code 4000 when there is no WARN result to acknowledge or acknowledgedAt is missing.
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.
{
"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}
}
{
"code": 0,
"data": {
"clientId": 412, "clientUid": "BARR-00000412", "verificationLevel": "MICA", "lifecycleState": "ACTIVE", "granted": false,
"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"]}
}
}
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},
"kycPackage": {"kycPackageId": 61, "revision": 2, "status": "ACCEPTED", "reviewNote": null}
}
}
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).
Lifecycle states a partner sees: KYC_PENDING (held for review), ACTIVE, RESTRICTED (a review or a restriction is open; opening new risk is refused), KYC_REJECTED, CLOSED.
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 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. 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}), GET /tickers (bare: {asOf, tickers: [...]}), 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: grids, limits, funding schedule, risk tiers, leverage schedule, the barrier parameters knockOutRebateBps / strictTakeProfitPremiumBps / strictTakeProfitOffered / barrierPremiumBps, the commission and the venue fees), GET /trading-status ({code: 0, data: {asOf, halted, halts: [{scope, since, until, message}]}}), 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.
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.
**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 |
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 24 h ahead and a market that offers it |
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 |
Answer 200 {code: 0, data: <rfq>}:
{
"code": 0,
"data": {
"rfqId": 311, "clientId": 412, "clientUid": "BARR-00000412", "venueRfqId": 5207, "clOrdId": "BARRFQ-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"
}
}
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 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).
**POST /clients/{clientId}/rfq/preview**: the same body, nothing written; {accepted, reason, mark, maxLoss, premium, estimatedCommission, requiredFreeMargin, freeMarginBefore, freeMarginAfter, impliedLeverage, potentialProfit, maxLeverage, expiryTs, expiryDefaulted, strictTakeProfit, premiumBps, knockOutRebateBps, strictTakeProfitPremiumBps, strictTakeProfitOffered, currency}. 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": "13.090000", "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; 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": "13.090000", "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).
**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, no broker commission on the close. 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.
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", "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 perpetual margin (null without positions), marginCallPct and closeOutPct the levels of that percentage 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), maintenanceMargin the close-out level in money. 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 and barrierMargin their requirement (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.
**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. 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 (RETAIL 10x on the crypto perpetuals, 20x on the index, metal and energy perpetuals, 30x on the FX perpetuals; PROFESSIONAL 50x), 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.
**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}` |
attach | no | LIMIT and MARKET parents: {takeProfit: {price, limitPrice?}, stopLoss: {price, limitPrice?}} | |||
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"}); 400 {code: 4000, msg, error, orderId, clOrdId, state} on a refusal (orderId null when nothing was written); 403 code 4030 on the verification gate; 409 code 4091 {leverageSent, leverageStored}. 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.
**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.
**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
}
**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}], disclosure, commissionRewardBps}.
**GET /staking/offers?asset&symbol**: {offers: [{offerId, symbol, name, asset, venueAsset, rateBps, ratePct, tenorDays, qty, remainingQty, minQty, expiryTs, createdTs, rewardPerUnit, commissionRewardBps}], asOf, stale, disclosure, commissionRewardBps}. The board is polled from the venue every 2 s and cached 3 s.
**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": "T-Bill Yield Swap (USD)", "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, 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.
**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"},
{"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}
],
"portfolioValue": "14500.410000", "portfolioCurrency": "USDC",
"portfolio": {"cashTotal": "9925.000000", "reserved": "125.000000", "holdingsValue": "3250.000000", "stakedValue": "1000.000000", "unrealizedPnl": "450.410000", "claimsValue": "0.000000", "holdingsValued": true, "currency": "USDC"}
}
**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), 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, whose deposit sum counts collateral deposits like any other deposit), the client restrictions, the trading routes, the statements, 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. 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, or while a withdrawal request is REQUESTED or APPROVED (the operator sees the standing items named). 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), 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).
**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, 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.
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) | 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 |
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.
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, 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, swapId, symbol, asset, rewardReceived, commissionRewardBps, reason} on a swap |
FUNDING | a funding settlement passed through to the client | {symbol, settledAt, qty, fundingPerUnit, side} |
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.
**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 (404 code 4004 for a 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",
"clientCash": "the sum of the partner clients' cash plus reserved, now",
"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 net of every settlement item ever produced (1000 + 68 in the example). 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. 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}
],
"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).
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.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} (404 code 4004 when none is registered). **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. **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). 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; our operations team can redeliver it from the back office. 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).
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 moved the level | {from: "MICA", to: "MICA_MIFID", appropriateness: {version, outcome, score, attempt}} |
appropriateness.result | an assessment was scored | {version, outcome, score, attempt, retakeAt, requiresAcknowledgement} |
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; not produced by revision 2 (lifecycle changes made in our back office reach you as client.verification_decision, kyc.rejected or margin.restriction_set) |
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 (read GET .../verification and the trading status) |
margin.restriction_lifted | RESTRICTION_LIFTED | a restriction was lifted |
client.verification_decision | VERIFICATION_DECISION | a verification decision was made on our side (a level change after a review, a rejection) |
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. Barrier RFQ and order life-cycle events (rfq.*, order.*) are not produced in revision 1: poll GET .../rfq, GET .../barriers and GET .../orders/open for those.
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), 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.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).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.
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).PENDING_REVIEW path when the sample screening list is installed.| Revision | Date | Change |
|---|---|---|
| 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. |