Verification (KYB)
A business is verified before it can move money: you send its company and
people data, our pre-check applies its exclusion rules, and our banking
partner's compliance team reviews and approves it. Until then the account's verification status is pending, and
in production that blocks payouts and conversions.
Where it sits
| Account status | Means | Payouts and FX execute |
|---|---|---|
pending | Signed up; not yet approved | Production: refused, 403 account_not_verified, before anything is sent to the bank. Sandbox: allowed — send Cxc-Simulate: account_not_verified to see the refusal there. |
active | Approved | Allowed |
suspended | Suspended or rejected by CashXChain. A business our partner rejects stays pending. | Refused everywhere, 403 account_not_verified with next_action: contact_support |
The refusal reads "account is not KYB/KYC-verified (active); funds movement
is blocked until verification completes". Of our own routes, only
POST /v1/banking/payouts and POST /v1/banking/fx/execute check it.
In production our banking partner adds its own gate, and it is wider.
Until it approves the business, it refuses to act for it at all: opening or
listing accounts, registering beneficiaries, quoting and paying all fail —
with 503 service_unavailable, or, before any referral exists, 400 bad_request "customer has no corporate reference at the banking partner —
onboard via KYB referral first". So in production nothing on the money API
works for a business before approval. Build and test the flow in the sandbox.
On approval our partner opens EUR, GBP, USD and CAD accounts for the
business. GET /v1/banking/accounts then lists the ones in currencies you hold
— at first, the local currency for the country the business is registered in;
see Accounts.
Reading the status. GET /v1/accounts/me returns kyb_status and
can_move_money, but only to a dashboard session or a master key. A secret key
cannot read its own account's verification status; with one, the signal is the
referral's status.
The calls, in order
Every KYB route is on the money API, so it takes a secret key, a master key or
a dashboard session. None takes an Idempotency-Key.
1 POST /v1/banking/kyb/precheck/preview optional — will this be blocked?
2 POST /v1/banking/kyb/referrals pre-check, then send to the partner
3 POST /v1/banking/kyb/referrals/{external_id}/persons more directors, owners, contacts
POST /v1/banking/kyb/referrals/{external_id}/parent-entities only for corporate owners
4 POST /v1/banking/kyb/referrals/{external_id}/documents/slots company documents
POST /v1/banking/kyb/referrals/{external_id}/persons/{person_external_id}/documents/slots
POST /v1/banking/kyb/referrals/{external_id}/parent-entities/{parent_entity_external_id}/documents/slots
5 POST /v1/banking/kyb/referrals/{external_id}/finalise
6 GET /v1/banking/kyb/referrals/{external_id} poll until approved
1. Preview — optional, records nothing
Answers "will this application be blocked?" while the form is still open. It screens the jurisdiction and industry only, writes no record and calls no one.
curl -s -X POST "$CXC_BASE/v1/banking/kyb/precheck/preview" \
-H "Authorization: Bearer $CXC_KEY" \
-H "Content-Type: application/json" \
-d '{"legal_name": "Example Casino Ltd", "country_code": "IR", "industry_code": "92.00"}'
{
"blocking_findings": [
{"rule": "jurisdiction_blocklist", "severity": "block", "detail": "country IR is on the blocked-jurisdictions list"},
{"rule": "industry_blocklist", "severity": "block", "detail": "industry code 92.00 matches blocked prefix 92"}
]
}
An empty list does not mean the application will pass — it means nothing blocks it outright. The full pre-check runs inside the referral.
POST /v1/banking/kyb/precheck runs the full pre-check without sending
anything to the partner, and records the result: the jurisdiction and industry
rules, and a sanctions-list screen where one is configured. It does not look
the business up in a company register — existence comes back
verified: false — which is why nearly every pre-check ends in review. Its
history is at GET /v1/banking/kyb/precheck-records.
2. Referral
POST /v1/banking/kyb/referrals takes the whole application. The people
and the company facts are needed up front — the application is checked for
completeness before anything is sent, and an incomplete one is refused with
400 bad_request naming every gap:
| Field | Required | Notes |
|---|---|---|
legal_name | Yes | At most 50 characters on the production rail |
country_code | Yes | ISO 3166-1 alpha-2 |
contact_email | Yes | |
registration_number | Yes | At most 50 characters on the production rail |
incorporation_date | Yes | YYYY-MM-DD |
entity_category | Yes | The legal form, e.g. GmbH, Ltd |
registered_address | Yes | {line1, line2?, city, postal_code?, region?, country_code}. The production rail also requires city and postal_code. |
persons | Yes | At least one person who controls the business — role director, ubo, sole_trader or partner — and one primary_contact. A sole_trader covers both. |
industry_code | Production, unless business_profile.nature_of_business is sent | The nature of business. The pre-check screens industry_code. |
business_profile | Production | The partner's business questionnaire — see below |
compliance | Production | has_existing_bank_account, has_dispute, has_bankruptcy, is_regulated — each true or false, with the matching …_details text when true |
trading_address, website, business_description, expected_monthly_volume, operating_countries | business_description here feeds the pre-check only; the partner reads the one in business_profile |
"Production" means the production rail, which the sandbox also uses when it
refers to our partner's test environment (see below).
On it, business_profile must answer: business_description (60 to 500
characters), payment_currencies, intend_payment_countries,
receive_payment_countries, source_of_funds, years_of_trading,
annual_revenue, expected_transaction_value,
expected_transaction_volume, outgoing_value_per_year,
anticipate_receiving_from, purpose_of_receiving, anticipate_sending_to
and purpose_of_sending. Several take values from the partner's own lists; a
value it does not accept is refused with 400 bad_request and the partner's
reason. None of these answers is ever filled in for you.
A person has first_name, last_name, and roles — one or more of
director, ubo, primary_contact, legal_representative, sole_trader,
partner — plus date_of_birth, nationality, email, phone, address
and the PEP flags. A ubo needs ownership_percentage. id_type is one of
passport, identity_card, drivers_license, residence_permit.
The answer depends on the pre-check:
| Status | outcome | Means |
|---|---|---|
201 | referred | Sent to the partner. referral.external_id is the application id for every later call. |
202 | manual_review | The pre-check raised something a person at CashXChain must clear before the application goes to the partner. Nothing was sent yet. |
200 | already_referred | This account already has an application at the partner. The existing external_id is returned and nothing is created — safe to retry. |
422 | kyb_precheck_blocked | Blocked by the pre-check; the message names the findings. |
400 | bad_request | The application is incomplete or invalid; the message lists every gap. Nothing was sent. |
{
"outcome": "referred",
"referral": {
"external_id": "corp_1830042517",
"status": "submitted",
"raw": {}
},
"precheck": {
"precheck_record_id": "0b6f…",
"decision": "pass",
"findings": [],
"existence": [],
"sanctions_hits": [],
"missing_onboarding_fields": []
}
}
In the sandbox the 201 already reads status: "approved".
referral.raw is the partner's own onboarding payload, passed through; its
shape is the partner's and may change.
3–5. Complete the application
-
More people:
POST …/{external_id}/personswith one person;201returnsperson_external_id. -
Corporate owners: if the business is owned by another company,
POST …/{external_id}/parent-entitieswith that company (role,entity_category,legal_name,registration_id,date_of_incorporation,email,address) and at least one of its ownowners.201returnsparent_entity_external_idand the owners' ids. Most applications never call it. -
Documents are uploaded straight to the partner's storage, never through CashXChain. Ask for a slot, then upload into it:
Route Body Response …/documents/slotsdocument_type(Certificate of Incorporation,Articles of Association,Due Diligence Bundle,Otherswithother_document_type),content_type(application/pdf,pdf,image,doc,docx){"urls": [{"url": "…", "fields": {…}}]}…/persons/{person_external_id}/documents/slotsThe identity document — id_type,id_number,issued_country,id_expiry_dateif it has one,id_content_type— and the proof of address:address_type(Utility Bill,Regulated Bank Statement,Government Issued Id,Government Issued Document,Otherwithdocument_name),address_content_typepresignedResponse: an object with one upload target per slot. Its keys depend onid_type— a passport has one ID side, a driving licence two.…/parent-entities/{parent_entity_external_id}/documents/slotsdocument_type(Certificate of Incorporation,Articles of Association,List of Directors,Structure Chart),content_type(application/pdf,pdf,image)As for company documents Each upload target is a
urlplus formfields: post thefieldsasmultipart/form-datawith the file last, within about ten minutes. Files under 5,120 bytes are rejected by the storage policy. If an upload fails or the slot expires, ask for a new slot — do not start a new application. -
Finalise:
POST …/{external_id}/finalisesubmits the application for review.200 {"external_id", "status": "submitted"}; after it the application can no longer be amended. The partner refuses it until every document is in: the company's documents, an identity document and a proof of address for every person, and the documents of every parent entity. The partner does not say in advance which parent-entity documents it requires — send all four.
These calls answer 404 for an external_id that is not yours.
6. Wait for the decision
GET /v1/banking/kyb/referrals/{external_id}
This asks the partner, every time. In production the partner reports one
thing: whether the business is approved. status is approved, or
in_review for everything else — still in review, waiting on documents, or
rejected. A rejection is not reported through the API — the application
goes on reading in_review. The other values in the
vocabulary (precheck_passed, submitted, info_requested, rejected) are
not returned by this poll today. Poll every few minutes; decisions take days.
When the partner approves, your account becomes active and its bank
accounts appear in GET /v1/banking/accounts.
The kyb.updated event is not sent for a business — not
when our partner approves, not on rejection or suspension, in either
environment. Poll the referral.
Sandbox and production
| Sandbox | Production | |
|---|---|---|
| Where the application goes | Our partner's test environment | Our partner |
Pre-check result review — nearly every application, since there is no register lookup | Sent to the partner anyway | Sent to the partner, or held for a person at CashXChain (202 manual_review), depending on how review is configured. Handle both answers. |
| Approval | Immediate, on our side: the 201 already reads status: "approved", and the account becomes active | When the partner approves, after its review — days |
| The status poll | Asks the partner's test environment, which reviews by hand and can go on answering in_review after the sandbox has approved you. Trust the 201. | The partner's answer: approved or in_review |
Payouts while pending | Allowed | Refused |
The sandbox does not wait for anyone, and it approves more than referrals: an
account that is pending or suspended is made active the next time it
reads GET /v1/accounts/me or GET /v1/accounts/me/balance. In the sandbox,
verification is never what blocks you. Treat sandbox approval as a
fast-forward, not as evidence that a production application would pass —
only the partner's review in production decides that.