Customers & onboarding
This guide walks the first week of an integration: the customer record, what its status means, how identity (KYC) or business (KYB) verification is started and followed, and the two traps that catch most new integrations — a read that changes state, and a list that does not.
All paths below are on the v1 surface (/api/v1/partner/..., snake_case bodies, the
{success, status_code, message, data} envelope). Reference: Customer API v1.
1. The customer record
A customer is a person (user_type: Personal, verified by KYC) or a company
(user_type: Business, verified by KYB). Every other resource — fiat account, wallet, withdrawal,
conversion — belongs to exactly one customer, and every customer-scoped route takes its id in the
path as customerId.
Your first call should be GET /api/v1/partner/me (no scope needed beyond a valid key): a
customer-bound key learns its own customer_id there. A tenant-wide key acts for every customer on
the account and lists them with GET /api/v1/partner/customers (onboarding:read).
The record you get back:
{
"id": "0198f3a0-1234-7000-8000-0000000000c1",
"external_reference": "jane.doe@example.com",
"display_name": "Jane Doe",
"user_type": "Personal",
"jurisdiction": "GB",
"status": "Onboarding",
"record_type": "Customer",
"created_at": "2026-09-01T09:30:00+00:00",
"updated_at": "2026-09-02T10:15:30+00:00"
}
external_reference is the customer's email as supplied at creation. It is unique within your
account and is the key the email-keyed onboarding routes resolve on.
Creating a customer
The customer record itself is normally created before your API key is issued — in the KwiikPay
dashboard or by your account manager — which is why the v1 reference lists no creation call. A
POST customers route does exist on this surface, but it belongs to the end-customer /
whitelabel programme (creating and verifying your customers under KwiikPay's registration),
which must be enabled for your account first. If you need it, ask your account manager; until it
is enabled, a key bound to your customer account answers 403 sub_customer_onboarding_disabled (a
tenant-wide partner key is not gated on the programme flag — it creates direct customers, as it
always has). When usable it is a
find-or-create keyed on email: posting an email that already exists returns that customer
(and the message still says "created"), a new one starts as Draft, jurisdiction is required and
never defaulted, password is refused with 422 password_not_supported (no identity account
exists, so a credential would be discarded), and email_verified and send_otp are accepted and
ignored.
2. Lifecycle status
status on the customer is the lifecycle status. It is the value every money-movement route
checks, and only one value passes:
status |
Meaning | Money movement |
|---|---|---|
Draft |
Record exists, verification never started | Blocked |
Onboarding |
A verification session has been opened | Blocked (all) |
PendingCompliance |
Submitted, awaiting a decision or a manual review | Blocked |
Active |
Verified | Allowed |
Rejected |
Verification refused | Blocked |
Suspended / Blocked |
Set by you (PATCH) or by compliance | Blocked |
Closed |
Set by you (PATCH) or by compliance | Blocked |
Two rules to design around:
- Only verification reaches
Active. There is no API call that sets it. Suspended,BlockedandClosedare one-way on this API.PATCH /api/v1/partner/customers/{customerId}accepts exactly those three values forstatus(anything else is422 unsupported_status), and no route on the surface moves the customer back — not even a later approved verification, which deliberately refuses to re-activate a customer that carries one of those stamps. Treat the PATCH as permanent and contact KwiikPay support to reverse it.
The same PATCH updates name, jurisdiction (unrecognised values are refused, never coerced) and
email/external_reference (one field, external_reference wins; a value another customer already
holds is 409 external_reference_in_use). Every field is optional and omitted fields are unchanged.
3. Starting verification
Verification runs in Sumsub. Your job is to open a session, hand it to the customer, and poll.
For a person:
curl -X POST https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/onboarding/kyc/initiate \
-H "X-Api-Key: kp_live_..." -H "Idempotency-Key: 4f7c1c2e-..."
For a company use POST /api/v1/partner/customers/{customerId}/onboarding/kyb/initiate. Both need
onboarding:write. Calling the KYC route for a business (or KYB for a person) is
422 invalid_onboarding_type.
{
"success": true, "status_code": 200, "message": "KYC verification session created",
"data": {
"verification_link": "https://in.sumsub.com/websdk/p/...",
"token": "_act-sbx-jwt-...",
"applicant_id": "66b3f2a1c0ffee0001a1b2c3",
"onboarding_case_id": "0198f3a0-1234-7000-8000-0000000000aa",
"status": "pending"
}
}
Things the reference states that are easy to miss:
/initiateand/tokenare the same operation.POST .../onboarding/kyc/tokenand.../kyb/tokenare a second path onto the same handler, kept for older integrations. Call either; there is no separate "token" capability.- The session is keyed on the customer, not on your
Idempotency-Key. Calling again returns the same case and mints a fresh token. No header value forces a new session. - Starting verification blocks money movement. A customer that is not yet
Activemoves toOnboardingwith all money movement blocked until the decision is approved. - Give the customer either the link or the token, not both.
verification_linkis the hosted Sumsub flow for a browser.tokenis a Sumsub Web SDK access token for embedding verification in your own UI.
The four token states
The 200 response has four shapes, and two of them carry a null token on purpose:
- First call — a real token and link. Launch the SDK or send the link.
- Replay — a re-minted token for the same case. Identical handling.
- Already verified —
tokenandverification_linkare bothnullandstatusisapproved. Do not launch the SDK; the customer is done. - Held for review — both
nullbecause the case was parked for manual review before Sumsub was called. Nothing for the customer to do yet; poll the status route.
Never launch the Sumsub SDK on a null token. If your integration only handles state 1, states 3
and 4 look like a broken response.
Token lifetime: 14 days
A Web SDK token is valid for 14 days from minting. Expiry does not surface as an API error — the failure happens inside the Sumsub SDK when it initialises with the stale token. Mint a fresh token (call the initiate route again) whenever the customer returns after a gap; do not cache one across sessions.
4. Following verification
Poll GET /api/v1/partner/customers/{customerId}/onboarding/status (onboarding:read):
{
"current_step": "kyc",
"steps": { "kyc": "in_progress", "kyb": "not_applicable" },
"kyc_status": "in_progress",
"kyb_status": null,
"can_proceed_to_kyb": false,
"customer_status": "Onboarding"
}
Three vocabularies sit on this one response — read the right one:
| Field | Vocabulary | Values |
|---|---|---|
kyc_status, kyb_status, steps.* |
verification | pending, in_progress, approved, rejected, blocked, closed (+ not_applicable under steps) |
current_step |
step | kyc, kyb, completed |
customer_status |
lifecycle | Draft, Onboarding, PendingCompliance, Active, Rejected, Suspended, Blocked, Closed |
The verification vocabulary is a mapping of the lifecycle status (Draft→pending,
Onboarding/PendingCompliance→in_progress, Active→approved, Rejected→rejected,
Suspended/Blocked→blocked, Closed→closed); an unmapped lifecycle value passes through raw,
so treat anything you do not recognise as not-yet-approved rather than failing. Exactly one of
kyc_status/kyb_status is non-null, decided by user_type. can_proceed_to_kyb is true only for
an Active person — it is always false for a business, so it cannot gate KYB.
The single-kind routes GET .../onboarding/kyc/status and GET .../onboarding/kyb/status return
the same mapped value with customer_status; asking the KYC route about a business is a 200 with
kyc_status: null, not an error.
"Approved" for money purposes means customer_status: Active (equivalently kyc_status/kyb_status: approved). Until then, fiat account creation, withdrawals and wallet provisioning refuse the customer.
5. The read that refreshes — and the list that does not
Activation is not pushed to your record by Sumsub's decision alone. It happens when one of these is called for the customer:
GET /api/v1/partner/customers/{customerId}GET /api/v1/partner/customers/{customerId}/onboarding/status,.../onboarding/kyc/status,.../onboarding/kyb/status- the money routes that gate on verification (creating a fiat account, creating a withdrawal)
Each of these re-checks the latest Sumsub case first and, if Sumsub has approved it, writes
status: Active and lifts the money-movement block during that call. If Sumsub cannot be reached
the read still succeeds with the stored, possibly stale, status.
GET /api/v1/partner/customers does not do this. So:
- A customer approved since your last read still shows
Onboardingin the list until you read them by id. Poll by id (or the status route), never the list, to detect activation. - A
status=Activefilter on the list can miss customers who are approved but not yet read. - The list and the by-id read can disagree for the same customer. The by-id read is right.
A customer that is Suspended, Blocked, Closed or Rejected is never re-activated by a read.
6. Manual submission (API-supplied data)
Instead of a hosted session, you can file the applicant yourself from data and base64 documents you
already hold. Start with GET /api/v1/partner/onboarding/requirements (onboarding:read, no
customer id): pass level, or let it resolve — type=kyb for the business level, anything else for
the individual level.
{
"level": "individual-onboarding",
"applicant_type": "individual",
"required_fields": ["person.first_name", "person.last_name", "person.dob", "person.email", "person.phone"],
"required_documents": ["identity (PASSPORT/ID_CARD/RESIDENCE_PERMIT/DRIVERS, FRONT_SIDE+BACK_SIDE)", "SELFIE", "UTILITY_BILL"],
"questionnaire_required": true,
"business_sectors": null
}
required_fields are the exact JSON paths on the submit body. required_documents is prose for
building an upload form, not a machine list. For the business level, business_sectors is the
banking provider's sector vocabulary that the KYB submit validates against.
The submit routes themselves — POST onboarding/kyc and POST onboarding/kyb under the v1 prefix,
with onboarding:write and an Idempotency-Key — are, like customer creation, part of the
end-customer programme: they resolve the subject by email and create the customer if the email
is new, so they answer 403 sub_customer_onboarding_disabled until that is enabled for your account.
When enabled: email (or person.email / company.email) and jurisdiction (or
person.country / company.country) are required; the response carries the customer_id,
applicant_id, onboarding_case_id and a first status in a third, PascalCase vocabulary
(Pending, Approved, ManualReview, ...) — poll the status route for the ongoing state.
One wrinkle to handle: a 422 missing_required_fields names the missing fields in an internal
camelCase spelling (person.firstName, person.dateOfBirth for dob, company.address.postCode).
Map them back to the snake_case paths from the requirements route.
7. End customers (summary)
If end-customer onboarding is enabled for your account, a customer of yours can create end
customers under it — POST customers/{customerId}/end-customers with subject_type Personal or
Business, an external_reference (a stable business reference: posting it again for the same parent
and subject type returns the same record; an HTTP Idempotency-Key is also required),
country_code, and for a company legal_name and registration_number. Verification is started with
POST .../end-customers/{endCustomerId}/onboarding (end-customers:write; contact_email is required
for everyone, business_profile for a company) and returns the same session shape as section 3,
with status narrowed to verified or pending. The end customer's verification documents can be
listed and downloaded (end-customers:read): each document has a category of IDENTITY,
SELFIE, COMPANY_DOC, FILE_ATTACHMENT or UNKNOWN, and the download streams the provider's
raw bytes with its own content type — not JSON. These routes appear under Sub-customers in the
API reference. See Sub-customers & parent access for
eligibility, parent authentication, account setup, beneficiaries and fiat withdrawals.
8. Two utility routes, and what they are not
GET /api/v1/partner/testconfirms your key and returns yourtenant_id. It is the only v1 200 that is not wrapped in the envelope — the body is a bare{success, message, tenant_id}. Do not point your envelope parser at it.GET /api/v1/verification/callbackis a no-op: it returnsstatus: receivedand echoes your own query string back asreceived_parameters(values whose key containstoken,secret,signatureorkeycome back as[redacted]). Nothing about any verification arrives there. It exists as a landing target for a hosted return URL; an integration never needs to call it.
9. Checklist
GET /api/v1/partner/meto learn yourcustomer_id.POST .../onboarding/kyc/initiate(or/kyb/initiate); handle all four token states.- Hand the customer the link or the token; mint a fresh token after 14 days.
- Poll
GET .../onboarding/statusby id — never the list — untilcustomer_statusisActive. - Only then create accounts and move money.
- Treat
PATCH statusas permanent.