barriers Partner APIOpenAPI 3.1Reference edition. Use the browser's print function for a paper or PDF copy.

11. Partner API reference ("Brokerage as a Service")

PM MTF Ltd, brokerage division (brand barriers). Sandbox base URL https://dev.brokerage.perpetuals.com.

Contents

  1. Overview and legal model
  2. Environments and base path
  3. Authentication
  4. Envelope and error codes
  5. Idempotency
  6. Rate limits
  7. Conventions
  8. Partner-level reads
  9. Onboarding under reliance
  10. KYC maintenance and documents
  11. MiFID appropriateness
  12. Trading
  13. Custody models
  14. Money under BROKER custody
  15. Collateral and settlement under PARTNER custody
  16. Webhooks
  17. Statements
  18. Sandbox behaviour
  19. Changelog

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

  1. Overview and legal model
  2. Environments and base path
  3. Authentication
  4. Envelope and error codes
  5. Idempotency
  6. Rate limits
  7. Conventions
  8. Partner-level reads
  9. Onboarding under reliance
  10. KYC maintenance and documents
  11. MiFID appropriateness
  12. Trading
  13. Custody models
  14. Money under BROKER custody
  15. Collateral and settlement under PARTNER custody
  16. Webhooks
  17. Statements
  18. Sandbox behaviour
  19. Changelog

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:

Nothing in this document is legal advice. The reliance agreement and our client terms govern; this reference describes the wire contract that implements them.

2. Environments and base path

EnvironmentHostNotes
Development (sandbox)https://dev.brokerage.perpetuals.comConnected 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).
Productionto be announcedSame 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.

3. Authentication

3.1 Keys

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

3.2 Scopes

ScopeMay call
READEvery GET (and HEAD) route, the webhook reads included.
TRADEEverything: 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.

3.3 IP allowlist

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.

3.4 Partner status

Partner statusEffect
ACTIVENormal service.
SUSPENDEDEvery request with the partner's keys answers 403 code 4037 PARTNER_SUSPENDED.
CLOSEDThe 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.

4. Envelope and error codes

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.

CodeHTTPWhendata
0200, 201Success.the payload
4000400Validation, 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
4000413A request body over the size cap (256 KB on the onboarding and KYC writes, 8 MB on a document).
4130413The 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.
4004404Unknown 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.
4010401No key, an unknown or revoked key, the key of a CLOSED partner, or a source address outside the allowlist.
4030403The 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}
4034403The key's scope does not cover the route (a READ key on a write).
4036403RELIANCE_NOT_APPROVED: an onboarding, KYC or MiFID write while the partner's reliance is not APPROVED.{error: "RELIANCE_NOT_APPROVED"}
4037403PARTNER_SUSPENDED: any request while the partner is suspended.{error: "PARTNER_SUSPENDED"}
4090409An 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
4091409An order's leverage hint no longer matches the stored choice; nothing was written.{leverageSent, leverageStored}
4092409A 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
4290429The partner's rate limit, or an appropriateness retake before the cooling period elapsed.{retakeAt} on the retake
5020502The 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
5030503The service the route needs is not built on this router (a test or api-only deployment).

5. Idempotency

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.

6. Rate limits

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.

7. Conventions

8. Partner-level reads

GET /partner/v1/me

The 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&level

The 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.

9. Onboarding under reliance

9.1 POST /partner/v1/clients

Scope 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):

FieldRequiredRules
partnerClientRefyesYour 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.
emailyesA valid address; 409 code 4090 when an OPEN account already uses it. Normalised to lower case.
clientTypenoNATURAL (default). LEGAL or CORPORATE answer a field error "Legal entities are not supported for partner onboarding yet."
categorizationnoRETAIL (default); anything else is a field error.
residencyCountrynoISO alpha-2; defaults to kyc.personalInfo.address.country. A blocked jurisdiction is a field error.
kycyesThe six MiCA sections, section 9.2.
identityCheckyesHow the partner verified the identity, section 9.3.
attestationyesThe 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.

9.2 The 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)**

FieldRequiredRules
first_nameyesat most 50 characters
middle_namenoat most 50
last_nameyesat most 50
date_of_birthyesYYYY-MM-DD, year 1900 or later, the person must be at least 18
country_of_birthyesISO alpha-2
citizenshipsyesarray of ISO alpha-2, at least one, no duplicates
phone_country_codeyes+ followed by 1 to 4 digits
phone_numberyes4 to 15 digits
addressyesobject: 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_idnoa document id of this client (proof of address); optional under reliance; PUT .../kyc only, omitted on POST /clients

**identification (block IDENTIFICATION)**

FieldRequiredRules
typeyespassport, national_id, drivers_license, residence_permit
country_of_issuanceyesISO alpha-2
numberyesat most 50 characters
expiration_dateyesYYYY-MM-DD, after today, year 2100 or earlier
document_idsnoarray 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:

FieldRequiredRules
countryyesISO alpha-2
has_tinyesboolean
tinwhen has_tin is true5 to 20 letters and digits once spaces and dashes are removed
no_tin_reasonswhen has_tin is falsearray of not_issued, not_required, pending, other
no_tin_reason_otherwhen the reasons contain otherat most 200 characters

**fatca (block FATCA)**

FieldRequiredRules
us_person_statusyesboolean
ssn_itinwhen truea 9-digit SSN, ITIN or EIN in a valid structural shape
w9_document_idnothe W-9 document id; optional under reliance; PUT .../kyc only, omitted on POST /clients

**pep (block PEP)**

FieldRequiredRules
is_pepyesboolean
detailswhen is_pep is truea public function object (below)
has_associationsyesboolean
associationswhen truearray 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.

9.3 identityCheck

FieldRequiredRules
provideryesondato, sumsub, manual, other
referencenothe provider's reference, at most 200 characters
methodyesvideo, nfc, document, in_person
verifiedAtyesUnix ms, not in the future (5 minutes of skew tolerated), no older than 12 months (366 days)
documentTypenoat most 40 characters (passport, ...)
documentCountrynoISO alpha-2
documentExpirynoYYYY-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.

9.4 attestation

FieldRequiredRules
cddPerformedAtyesUnix ms
cddStandardyesthe 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.sanctionsClearyesmust be true (a client who did not pass sanctions screening cannot be onboarded)
screening.pepCheckedyesboolean
screening.adverseMediaCheckedyesboolean
screening.screenedAtyesUnix ms
screening.providernoat most 100 characters
riskRatingyesLOW, MEDIUM, HIGH (the partner's own rating)
attestedByyesthe attesting person or function, at most 200 characters
documentsAvailableOnRequestyesmust be true

9.5 Worked example

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.)

9.6 What happens on our side

  1. Gate: partner ACTIVE and reliance APPROVED (else 4037 / 4036), scope TRADE.
  2. Validation of every section as above; any error answers 400 with the map and creates nothing.
  3. The client row is created (NATURAL, RETAIL, the display name from the personal block, the residency, onboardingSource PARTNER_RELIANCE, your reference).
  4. The six MiCA blocks are written as SUBMITTED then APPROVED with the data as received, 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.
  5. The package (revision 1, status ACCEPTED) is stored.
  6. OUR sanctions and PEP screening runs synchronously. The MiCA grant then runs the same checks as a compliance approval (the screening gate first). On success: level MICA, lifecycle ACTIVE, a decision row with actor partner:<CODE> and reason "reliance onboarding", the webhook client.onboarded.
  7. On a screening HIT or POSSIBLE match awaiting a disposition (or any other refusal of the grant): the client stays 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}
  }
}

10. KYC maintenance and documents

10.1 PUT /partner/v1/clients/{clientId}/kyc

Scope 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.

10.2 GET /partner/v1/clients/{clientId}/kyc

The 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).

10.3 POST /partner/v1/clients/{clientId}/documents

Scope 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.

10.4 GET /partner/v1/clients/{clientId}/documents

Metadata 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.

11. MiFID appropriateness

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.

11.1 GET /partner/v1/appropriateness-test

The 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"
  }
}

11.2 POST /partner/v1/clients/{clientId}/appropriateness

Scope 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.

11.3 POST /partner/v1/clients/{clientId}/appropriateness/acknowledge

Scope 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.

11.4 PUT /partner/v1/clients/{clientId}/mifid

Scope 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"]}
  }
}

11.5 GET /partner/v1/clients/{clientId}/verification

The 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.

12. Trading

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.

12.1 Market data (READ scope, no client)

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.

12.2 Barrier contracts by RFQ

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:

FieldRequiredRules
symbolyesa barrier market, e.g. BTC-USD-KO
sideyesBUY (long, knock-out below the entry) or SELL (short, knock-out above); LONG / SHORT accepted
qtyyesdecimal string on the market's lot grid
premiumnothe premium the client typed when the ticket was premium-sized; the request's budget every quote is measured against
barrier.levelyesthe knock-out price, on the tick grid, at least 0.1 percent from the current price
barrier.expiryTsnoUnix 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.takeProfitnoa price on the profit side of the entry; omit for a knock-out-only contract
barrier.strictTakeProfitnodefault 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
expiresInMsnothe quoting window, default 60000, 1 s to 24 h
autoAcceptnodefault 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.

12.3 Perpetual and spot markets: margin, leverage, orders, positions

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:

FieldRequiredRules
symbolyesa PERP or SPOT market
sideyesBUY or SELL
typeyesLIMIT, MARKET, STOP_MARKET, STOP_LIMIT, TAKE_PROFIT_MARKET, TAKE_PROFIT_LIMIT (no trailing stops)
qtyyesdecimal string on the lot grid
priceLIMITon the tick grid
tifnoGTC (default), IOC, FOK, POST_ONLY (LIMIT only)
reduceOnlynothe order must reduce the position; not on spot
triggerconditional types`{source: LASTMARKINDEX, price, direction: AT_OR_ABOVEAT_OR_BELOW, limitPrice}`
attachnoLIMIT and MARKET parents: {takeProfit: {price, limitPrice?}, stopLoss: {price, limitPrice?}}
ocoGroupnoconditional reduce-only orders, 1 to 40 characters [A-Za-z0-9:_-]
leveragenothe 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).

Worked example: a perpetual round trip

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:

  1. GET .../leverage?symbol=BTC-USD-PERP answers {"symbol": "BTC-USD-PERP", "leverage": 10, "cap": 10, "categorization": "RETAIL", "chosen": null}.
  2. 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).
  3. 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".
  4. 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).
  5. 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.
  6. The mark moves to 61000. 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.

12.4 Staking swaps

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.

12.5 Account reads

**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.

13. Custody models

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 moneyWe 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 marginThe 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 clientPOST .../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 outPOST .../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, riskUnchanged.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 financeGET /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 behaviourA 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.
Webhooksdeposit.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.

14. Money under BROKER custody

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).

14.1 Deposit notices

**POST /clients/{clientId}/deposits** (TRADE, Idempotency-Key):

FieldRequiredRules
partnerRefyesyour 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
currencyyesa client currency (USDC, BTC, ETH, SOL, BNB, XRP, EUR, USD1)
amountyespositive decimal string within the currency's precision
methodnoFIAT (default) or CRYPTO
evidenceRefnoyour 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).

14.2 Withdrawal requests

**POST /clients/{clientId}/withdrawals** (TRADE, Idempotency-Key):

FieldRequiredRules
partnerRefyes1 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
currencyyesa client currency
amountyespositive decimal string
destinationnoa 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.

14.3 Settlement report (both custody models)

**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".

15. Collateral and settlement under PARTNER custody

Under PARTNER custody the partner is the custodian. Three things replace the deposit notices and withdrawal requests:

  1. Collateral reports (15.1, 15.2): the partner tells us, per client, every movement of the stablecoin collateral it holds for the client. We book each report at once, 1:1 in USDC, and the booked figure is the cash the risk engine margins.
  2. Settlement items (15.3, 15.4): every economic event on such a client on our side (a realised profit or loss, a commission, a funding payment, a barrier settlement, a swap flow, a manual correction) becomes exactly one settlement item. The partner credits or debits its client's stablecoin accordingly and reports each item back as SETTLED or FAILED.
  3. The interfirm balance (15.5, 15.6): the net of all settlement items is what we owe the partner or the partner owes us; our finance records the interfirm payments and both sides see the outstanding balance.

15.1 POST /clients/{clientId}/collateral

Scope TRADE, Idempotency-Key recommended. Reports one collateral movement:

FieldRequiredRules
partnerRefyesyour 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
directionyesDEPOSIT (the client's collateral with you increased) or WITHDRAWAL (it decreased)
amountyespositive decimal string, at most 6 places
currencynoUSDC, the only accepted value (the stablecoin is valued 1:1)
sourceAssetnothe stablecoin name for display, at most 32 characters; defaults to the partner's collateralAsset
txRefnoyour on-chain or internal transaction reference, at most 128 characters
occurredAtnoUnix 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:

RefusalAnswerReport row
The partner is under BROKER custody409 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 closed409 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.missingBlocksnone
partnerRef already filed for another client of yours409 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 sentenceREFUSED
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 marks409 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 margin409 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):

  1. {"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.
  2. {"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.
  3. {"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.

15.2 Collateral report lists

**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).

15.3 Settlement items

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:

typeWhenrefs
PNLa fill realised profit or loss on a perpetual{TradeId, ClientOrderId, Instrument}
COMMISSIONour 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
FUNDINGa funding settlement passed through to the client{symbol, settledAt, qty, fundingPerUnit, side}
BARRIER_SETTLEa barrier contract ended (knock-out, take profit, expiry, early close, tear-up){contractId, symbol, reason, settlePrice}
SWAP_PRINCIPAL, SWAP_SETTLE, SWAP_FEE_ADJUSTthe 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_WRITEOFFnegative balance protection wrote a shortfall off (a positive amount: the partner credits the client back to zero)the close-out episode
MANUALa manual ledger correction by our financethe 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.

15.4 Reporting settlement items

**POST /partner/v1/settlement/items/{itemId}/report** (TRADE, Idempotency-Key), body:

FieldRequiredRules
statusyesSETTLED or FAILED
partnerTxReffor SETTLEDyour transaction reference on the client's stablecoin account, 1 to 128 characters (optional on FAILED)
settledAtnoUnix ms when the client side was settled, not in the future; defaults to now on SETTLED, null on FAILED
notenoat 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):

**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.

15.5 GET /partner/v1/settlement/summary?from&to

The 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.

15.6 GET /partner/v1/settlement/interfirm?limit&before

The 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).

15.7 What happens on our side

16. Webhooks

16.1 Registration

**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.

16.2 Delivery

Each event is one POST to your URL with Content-Type: application/json and the headers

HeaderValue
X-Brokerage-Eventthe event name (deposit.confirmed)
X-Brokerage-Deliverythe delivery id (retries carry the same id)
X-Brokerage-TimestampUnix SECONDS at send time, taken for each delivery when it is sent (a retry carries a fresh timestamp and signature)
X-Brokerage-Signaturev1= + 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.

16.3 Retry policy

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).

16.4 Event catalogue

Explicit events (the data object):

EventWhendata
client.onboardedthe reliance onboarding granted level MICA (status ACTIVE){verificationLevel, lifecycleState, kycPackageId}
client.review_requiredour 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_changedthe MiFID grant moved the level{from: "MICA", to: "MICA_MIFID", appropriateness: {version, outcome, score, attempt}}
appropriateness.resultan assessment was scored{version, outcome, score, attempt, retakeAt, requiresAcknowledgement}
deposit.confirmedfinance confirmed a notice (or the sandbox POST){noticeId, partnerRef, currency, amount, status, ledgerTxId}
deposit.rejectedfinance rejected a notice{noticeId, partnerRef, currency, amount, status, reason}
withdrawal.statusevery withdrawal transition (REQUESTED, APPROVED, PAID, REJECTED, CANCELLED){requestId, partnerRef, currency, amount, status, decidedAt, paidAt, note}
webhook.testPOST /webhook/test{message, requestedAt}
kyc.documents_requestedour 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.rejectedour 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.bookeda 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.refuseda 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_createda settlement item was created, queued in the same transaction as the ledger posting (section 15.3); nothing fires on a settlement reportthe 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_changeda 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:

EventNotification kindAbout
swap.settledSWAP_SETTLEDa staking swap paid out (ref the swap)
swap.defaultedSWAP_DEFAULTEDa writer defaulted; a claim stands
barrier.near_take_profitBARRIER_NEAR_TPan open contract within 10 bps of its take profit
barrier.near_barrierBARRIER_NEAR_KOwithin 10 bps of its barrier
barrier.expiry_soonBARRIER_EXPIRY_SOON20 minutes before expiry
margin.restriction_setRESTRICTION_SETa restriction was engaged on the account (read GET .../verification and the trading status)
margin.restriction_liftedRESTRICTION_LIFTEDa restriction was lifted
client.verification_decisionVERIFICATION_DECISIONa verification decision was made on our side (a level change after a review, a rejection)
money.movementMONEY_MOVEMENTa money movement on our own rails reached a terminal state
statement.readySTATEMENT_READYa 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.

16.5 Signature verification

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.

17. Statements

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).

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.

18. Sandbox behaviour

On the development environment:

19. Changelog

RevisionDateChange
72026-09-03Documentation 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).
62026-09-03The 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.
52026-09-03The 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.
42026-09-03The 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.
32026-09-03The 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.
22026-09-03Partner 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.
12026-09-02First 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.