Verification & Endorsements
One permanent applicant per customer, endorsements as the single source of readiness, and every ask a bare documented code.
The model
Every customer has exactly one permanent applicant in our verification
system, created with the customer and reused for every review. Additional
documents, retries, and re-checks all land on the same applicant. Each
review attempt is recorded as a verification round (ver_...), so you
get clean history without ever duplicating an identity.
You share documents over the API: you collect them in your UI, we relay them, and the decision engine decides. The intake rides the customer object itself. See Customers.
The endorsement is the one public voice of KYC. You request an
endorsement by name (endo_usd for USD services), and everything, from
progress to asks to approval to adverse decisions, surfaces on that
endorsement's status and requirements. There is no second channel to
reconcile.
The four calls
Submission is explicit. Documents sent on create or PATCH are archived and staged; nothing enters review until you submit.
curl -s $BASE/api/customers/cus_.../verifications/submit \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{}'
If required inputs are still outstanding, the call returns
422 verification_incomplete and names them, rather than failing opaquely
further down. Individuals owe an identity document, a selfie, and a proof
of address. Businesses owe a registration document.
Submitting again is safe: it opens a new round on the same applicant, never a second identity.
The one channel: requirement buckets
Every ask is a bare snake_case code in exactly one of five buckets.
| Bucket | Meaning |
|---|---|
complete | Done, nothing owed |
uploaded | We hold it, but nothing is reviewing it yet, because the intake is incomplete. Do not re-send |
pending | Submitted and genuinely under review |
missing | Never supplied, you owe it |
issues | Received but not usable, or a verdict blocking the endorsement. Fix and resubmit |
uploaded exists so a partial intake reads honestly. With a passport in
hand and a proof of address still owed, calling the passport "pending"
would claim a review that is not running.
The endorsement's own status reads incomplete, then pending, then
approved, with issues (fixable) and rejected (final) as adverse
states. approved never demotes.
Codes you will act on most
| Code | Satisfied by |
|---|---|
terms_of_service | The customer's ToS acceptance |
identity | A government ID in identifying_information |
selfie | A {"purpose": "selfie"} document |
proof_of_address | A {"purpose": "proof_of_address"} document |
registration_document | Businesses: a {"purpose": "registration_document"} document |
tax_id, tax_residence_country | economic_profile.tin and .tax_residence_country, required up front to request endo_usd |
email, phone | The corresponding customer fields |
aml | Nothing you can send. Screening runs on our side: it can raise an issue but is never due from you |
verification, compliance_review | Our own reviews. Informational: they move on their own |
Field-level codes (date_of_birth, address_state, id_document_number,
nationality, and the rest of the address parts) appear only when a review
asks for them. Each is documented, and each is fixed by a PATCH supplying
the field.
Two codes cover an ask a review could not pin to one field:
profile_incomplete (something is missing) and profile_invalid (a value
was not accepted).
Four are verdicts nothing you send will fix: region_unsupported,
customer_kind_unsupported, declined, and contact_support. A fifth,
unavailable, means the review could not complete and will run again on
the next update.
You will never see a sentence written by someone else, a field path, or an undocumented value.
Decisions arrive on the endorsement
| Event | Meaning | Endorsement becomes |
|---|---|---|
endorsement.approved | Everything passed | approved, and the currency's features open |
endorsement.request_for_information | Retryable, something needs fixing | issues, with the asks in the buckets |
endorsement.rejected | Final adverse decision | rejected |
Each event carries the full merged endorsement object, the same shape
GET /api/customers/{id} embeds. There are no verification.* webhooks.
Verification rounds
Each attempt is a round with its own lifecycle: submitted,
under_review, then approved, request_for_information, or rejected,
with expired for a round that aged out.
{
"id": "ver_033y1FhTOmoW9W2jAuSvQx",
"object": "verification",
"customer_id": "cus_033y1FhTOmoW9W2jAuSvQx",
"intake_method": "direct",
"purpose": "resubmission",
"requested_info": null,
"url": null,
"status": "request_for_information",
"rejection_reasons": [{"requirement": "selfie", "retryable": true}],
"created_at": "2026-08-26T10:02:11+00:00"
}
requirement maps into the vocabulary above, and retryable tells you
whether resupplying fixes it. What to ask your user for:
requirement | Typical fix |
|---|---|
identity | A clear, uncropped photo of the valid physical document, both sides, no screenshots |
selfie | A new selfie taken live, matching the submitted document |
proof_of_address | A recent utility bill or bank statement showing the registered address |
registration_document | A current official company registration extract |
aml | Not fixable by documents. Usually final: route to your support path |
Whether an outcome is retryable is decided per decision, on each reason plus the round's status. Never assume it from the requirement alone.
Rounds are deliberately lean: no internal names, no review-level names, and no extracted identity data. The endorsement carries readiness, and your own records carry the dossier.
Fixing an ask
The fix is a PATCH, then a submit. There is no special endpoint.
curl -s -X PATCH $BASE/api/customers/cus_... \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"documents": [{"purpose": "selfie", "file": "<base64>"}]}'
curl -s $BASE/api/customers/cus_.../verifications/submit \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{}'
Guarantees worth relying on:
- Approved requirements are never reset by later rounds. Only a decision naming them again can move them.
- An approved endorsement is never demoted by a scoped re-check or a retryable ask. Money keeps flowing while the re-check runs.
- Replays are free. Re-sending the same file is deduplicated by content hash, so send a genuinely new image when a document was rejected.
Scoped rounds
Pass requested_info to open a round for specific items, for example after
your compliance team asks for a fresh proof of address:
curl -s $BASE/api/customers/cus_.../verifications/submit \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"requested_info": ["proof_of_address"]}'
A scoped round is exempt from the completeness gate, because
requested_info declares what is being asked for rather than claiming to
have it. GET /api/customers/{id}/verifications lists every round, newest
first, each with its purpose (full, resubmission, or rfi).
What to render, per state
Endorsement status | Buckets | Render |
|---|---|---|
incomplete | missing non-empty | Your document-collection flow for the listed codes |
pending | any | A verification-in-progress waiting state |
issues | issues or missing non-empty | A fix-it screen for the listed codes |
approved | all clear | Done. Open the currency's features |
approved | asks present | Keep features open and supply the listed codes. A scoped re-check is running |
rejected | any | A terminal state with your support path |
Launch scope. Document sharing over the API is the supported intake today. Hosted verification links and reusable-KYC token sharing return later, and the data model already accommodates them.
In sandbox you make the decisions, including
"decision": "request_for_information" to rehearse the fix-and-resubmit
loop. See Sandbox Testing.