Onboarding
Contract §1. Section numbers are contract-wide and never change; the contract overview maps every section to its page.
Conventions — authentication, the response envelope, idempotency, retry safety, pagination, rate limits — are defined in the Partner Integration Guide and apply to every endpoint here. Endpoints marked 🔑 require an
Idempotency-Keyheader.
Onboarding a client, end to end
An end client is a company that gets its own collateral pool and its own cards. Onboarding is
a review process, not a call — it runs for hours or days, most of the work happens at the
issuer, and your integration's job is to start it, feed it what it asks for, and watch
status. Cards cannot be issued until it reaches APPROVED.
Five steps. Step 1 is one call; steps 2 to 4 repeat as the review asks for things.
Step 1 — create the application
POST /clients — a company application: your own reference for the client, the company
details, the person opening the account, and whichever representatives and beneficial owners
you already know about. The full body is in
the endpoint reference below. You get 201 and a client at PENDING.
Two fields are effectively permanent, so get them right on the first call:
externalRefis your id for this client and can never be reused — not after a denial, not after a closure. Reusing one answers409 EXTERNAL_REF_TAKEN.initialUser.walletAddressbecomes an admin of the client's collateral contract, and there is no API to add another admin later. If that key is lost the collateral is stranded. See collateral & withdrawal.
Step 2 — send the client to the verification flow
Read GET /clients/:id and take verificationUrl. It is a live field — null on the
create response, and null on every row of the list — so only the single-client read produces
one.
The end user completes identity and terms there. Until they do the application does not move
on its own: NOT_STARTED and TOS_NOT_ACCEPTED both mean waiting for the human, and
polling them changes nothing.
Step 3 — watch status, and act only on the states that ask you to
Transitions arrive by webhook (company.updated, company.approved, company.rejected) and
are readable by polling GET /clients/:id. Of the eleven states, only four ask anything of
you — the status table below is the full list, and the shape of it is:
| The state says | You |
|---|---|
NOT_STARTED · TOS_NOT_ACCEPTED | send the user to verificationUrl (step 2) |
NEEDS_INFORMATION | correct and resubmit — PATCH /clients/:id, or fix the UBO that is blocking |
NEEDS_VERIFICATION | upload the outstanding documents (step 4) |
PENDING · IN_REVIEW | wait; something is running or a human holds it |
APPROVED · EXEMPT | proceed — cards may be issued |
DENIED · CANCELED | stop; terminal, and the externalRef is spent |
LOCKED | post-approval freeze, not a rejection; contact the programme |
Do not treat "not APPROVED" as "pending". NOT_STARTED waits on your user, not on the
issuer, and an integration that polls it forever never onboards anybody.
Step 4 — upload what the review asks for
Two separate document surfaces, and a corporate application usually needs both:
- Company records —
PUT /clients/:id/document. Today onlyINCORPORATION_CERTandSHAREHOLDER_REGISTRYclear compliance; another accepted type still answers204without moving the application forward. - Per-UBO identity —
PUT /clients/:id/ubos/:uboId/document. Without these a corporate application can sit inNEEDS_VERIFICATIONindefinitely, which is the most common way an onboarding stalls with nothing visibly wrong.
UBOs themselves are added with POST /clients/:id/ubos — but only while the application is
still open. Once the issuer has adjudicated it, an add answers 422 UBO_REJECTED, so submit
every beneficial owner before or during review, not after.
Both upload routes are multipart and share one trap: every text part must be sent before the
file part. Parts after the file are not read, so a type sent last answers
422 INVALID_DOCUMENT_TYPE on an otherwise valid request.
Step 5 — approved
At APPROVED the client's collateral contract exists — it deploys on approval, no call
required — and cards may be issued. From here:
- fund the pool and read it: collateral & withdrawal
- add cardholders and issue cards: cardholders & cards
Endpoint reference
The client object
{
"id": "cl_01M0…",
"kind": "COMPANY",
"externalRef": "your-crm-id-123",
"name": null,
"address": null,
"status": "PENDING",
"verificationUrl": null,
"externalVerificationUrl": null,
"ultimateBeneficialOwners": [],
"createdAt": "…",
"updatedAt": "…"
}
verificationUrl and externalVerificationUrl are two separately signed sessions, not a
value and its fallback. Use the one you asked for; both are secret, and both are null when
the programme supplies no corresponding flow.
ultimateBeneficialOwners[] carries id, firstName, lastName, email, status and
verificationUrl per UBO — it is how you find which person is blocking a
NEEDS_INFORMATION application. Populated on GET /clients/:id, always [] on the list
route, which is served from the service's own rows rather than a live read.
name and address are optional passthrough. They are null when the issuer's application
read omits them (the current sandbox shape), and relayed if the issuer adds them.
KYB retention: the exact POST /clients body — dates of birth, identity-document numbers
— is retained encrypted at rest (envelope encryption, KMS-held keys) in the permanent
operation record, so a submission the programme later disputes can be reconstructed. It is
never served through any API and never appears in logs. Everything else the service keeps about your
client is identifiers and status mirrors.
Client status
status has eleven values, and the difference between "wait" and "act" is why this table
is worth reading rather than treating everything not APPROVED as pending:
| Status | What you do |
|---|---|
NOT_STARTED | Send the client to verificationUrl. Nothing is running; waiting will not move it |
PENDING | Nothing — automated checks are running |
NEEDS_INFORMATION | Correct and resubmit via PATCH /clients/:id, or read ultimateBeneficialOwners[] to find who is blocking |
NEEDS_VERIFICATION | Upload the outstanding documents, or open the hosted flow |
IN_REVIEW | Wait — a human at the programme holds it |
TOS_NOT_ACCEPTED | Send the client to verificationUrl. Identity passed; the end user must still accept the programme's terms |
APPROVED | Cards may be issued |
EXEMPT | Terminal approval granted manually by the programme. No action |
DENIED | Terminal. An externalRef is never reusable |
CANCELED | Terminal |
LOCKED | Post-approval freeze, not a rejection |
NOT_STARTED, TOS_NOT_ACCEPTED and EXEMPT were added 2026-08-22. Treat this list as a
floor: switch on the values you handle and fall through to a "needs a human" branch rather
than throwing, because the programme can introduce a state on its own timetable.
Transitions arrive by webhook (company.updated, company.approved, company.rejected)
and by polling.
POST /clients 🔑
Answers 201 with the client at status PENDING.
externalRef (required, unique per partner) is your stable id for this client and the
create's dedup scope, independent of the idempotency key. ⚠ It is stored long-term in
plaintext for reconciliation — it must be an opaque identifier from your systems, never
personal data (no names, emails, tax ids) and never a secret. Dedup semantics:
- second create with the same
externalRefwhile the first is still processing →409 OPERATION_IN_FLIGHT— wait, then retry the original request with its own key; - create reusing an
externalRefalready used by any of your clients (even a denied or closed one) →409 EXTERNAL_REF_TAKEN. AnexternalRefis never reusable.
kind: "COMPANY" body:
{ "kind": "COMPANY", "externalRef": "your-crm-id-123",
"entity": { "name": "…", "registrationNumber": "…", "taxId": "…",
"industry": "541511", // 6-digit NAICS code
"description": "…", "website": "…",
"type": "GmbH", // legal form (optional)
"address": { "line1": "…", "line2": "…", "city": "…", "region": "…",
"postalCode": "…", "countryCode": "DE" } },
"initialUser": { …person…, "role": "DIRECTOR", // role optional, initialUser only
"walletAddress": "0x71C7…976F", // the client's own custody wallet
"ipAddress": "203.0.113.7",
"isTermsOfServiceAccepted": true },
"representatives": [ { …person… } ], // 1–20
"ultimateBeneficialOwners": [ { …person… } ] } // 0–20
…person… (account owner, each representative, each UBO) = firstName, lastName, email, birthDate (YYYY-MM-DD), nationalId, countryOfIssue (alpha-2 of the ISSUING country), phoneCountryCode? (digits, no +), phoneNumber? (digits), address{…}. nationalId is the
9-digit SSN for a US country of issue, otherwise the number on the identity document held
for this person. There is no inline document object — identity is verified through the
hosted verification flow (verificationUrl) and, where the programme requests documents,
through the upload endpoints: company records via PUT /clients/:id/document, per-UBO
identity documents via PUT /clients/:id/ubos/:uboId/document. Without the UBO uploads a
corporate application can remain in NEEDS_VERIFICATION indefinitely.
entity.registrationNumber / taxId / industry / description / website
are all required; entity.type is optional. Optional top-level sourceKey (≤24 chars, the
programme's origin tag) and entity.expectedSpend are relayed when present.
externalRef is ≤191 chars. initialUser.walletAddress (26–128 chars) is the
client's own custody wallet — collateral is provider-managed and settles against it.
kind: "INDIVIDUAL" — 🔜 not callable yet. Published for design only: sending it today
answers 501 CLIENT_KIND_UNSUPPORTED. Your integration manager announces availability
before it ships. The shape, for planning:
{ "kind": "INDIVIDUAL", "externalRef": …, "person": { …person…, "walletAddress": …, "ipAddress": …, "termsAccepted": true } }
— an individual is provisioned as a single-person program account.
isTermsOfServiceAccepted: true is required on the initial user — you attest your client accepted the program terms.
GET /clients · GET /clients/:id
The LIST is offset-paginated with a real pagination block (totalObject, totalPages),
because it is served from the service's own records. _limit defaults to 20, caps at 100; _page
starts at 1. Two consequences:
statuson the list is the mirrored status, kept current by webhooks, so it can lag a transition by one delivery.GET /clients/:idreads through to the issuer; the list does not, because one issuer call per row would make paging the thing that rate-limits you. If that read fails, the single-client route falls back to the mirror rather than erroring — treat it as freshest-available, and re-read before acting on a status you must be sure of.verificationUrlis null on every row of the list — it is a live field. Read the client itself to collect one.
PUT /clients/:id/document
multipart/form-data — the one exception to this API's JSON rule. One file per call, max
20 MB.
| Part | Req | Value |
|---|---|---|
document | ✔ | the file itself |
type | ✔ | one of the 12 values below |
name | a label for the document | |
side | FRONT or BACK | |
countryCode | alpha-2 |
⚠ Send every text part BEFORE the file part. Parts that arrive after the file are not read, so a
typesent last answers422 INVALID_DOCUMENT_TYPEon a request that is otherwise perfectly valid. The same ordering rule governsPUT /clients/:id/ubos/:uboId/document.
The 12 accepted type values:
INCORPORATION_CERT SHAREHOLDER_REGISTRY DIRECTORS_REGISTRY
STATE_REGISTRY INCUMBENCY_CERT INCORPORATION_ARTICLES
GOOD_STANDING_CERT TRUST_AGREEMENT INFORMATION_STATEMENT
POWER_OF_ATTORNEY PROOF_OF_ADDRESS OTHER
No Idempotency-Key: uploads are safe to repeat — the application is reviewed against the
document set as a whole. Acceptance/rejection is reflected in client status; there is no
per-document receipt or body, and no document id, because the service stores nothing. The bytes
are relayed to the programme and never written down. A successful request is an empty 204.
The programme parses what you send. Its verification vendor's documented rejection tags are returned in
error.params.tags and map to 422 DOCUMENT_QUALITY_REJECTED (retake the photo),
DOCUMENT_REJECTED (send a different document), or DOCUMENT_INCOMPLETE (send the missing
page or side). An unrecognised tag safely maps to DOCUMENT_REJECTED. Request validation still
uses INVALID_DOCUMENT_TYPE; oversized files answer 413 DOCUMENT_TOO_LARGE.
All 12 type values are relayed because issuer requirements can change. Today only
INCORPORATION_CERT and SHAREHOLDER_REGISTRY clear company compliance; another accepted
upload can still return 204 without moving the application toward approval.
UBO write endpoints
POST /clients/:id/ubos adds a UBO and returns the refreshed client; its body is the §1 person
shape except lastName is optional (single-name persons are accepted). PATCH /clients/:id/ubos/:uboId updates one UBO, and PUT /clients/:id/ubos/:uboId/document relays a
person identity document, 204. The uboId is the issuer's identifier, checked against the
requested client before any write.
UBO writes are accepted only while the application is still open — once the issuer has
adjudicated it (APPROVED/DENIED) an add answers 422 UBO_REJECTED ("not in a valid state to
add a beneficiary", measured live 2026-08-26), so submit UBOs before or during review. A
repeated POST adds another UBO, so it takes no Idempotency-Key: after an ambiguous
response, read the client before retrying.
The 19 accepted UBO document types — a separate enum from the company one above:
PASSPORT ID_CARD DRIVERS
RESIDENCE_PERMIT UTILITY_BILL SELFIE
VIDEO_SELFIE PROFILE_IMAGE ID_DOC_PHOTO
AGREEMENT CONTRACT DRIVERS_TRANSLATION
INVESTOR_DOC INCOME_SOURCE PAYMENT_METHOD
BANK_CARD VEHICLE_REGISTRATION_CERTIFICATE
COVID_VACCINATION_FORM OTHER
For PASSPORT, omit side — the side selector applies only to cards, driver's licences and
residence permits.
PATCH /clients/:id
Amends the company application — the answer to NEEDS_INFORMATION. Only the issuer-supported
entity fields and a complete address object may be sent; externalRef is immutable.
The issuer restarts verification after an amendment, so the status you had is stale the moment the call returns: read the client again before resuming any polling loop.