Customers & verification
Start and track verification for your own account. Every verified flow — payment links, payouts, payroll, bank rails — begins here. This surface carries status only: the person being verified completes verification themselves, on a hosted page run by our verification partner.
Dark-flag
Every customers.* operation is gated behind the api_v1.customers flag. Until it is enabled for your account the routes answer 503 temporarily_unavailable. Test mode is not available: a sk_test_ key cannot create a customer (POST /v1/customers answers 503 temporarily_unavailable), because verification on the partner side is a live, billed step.
Concept
A customer is your own account's holder — a singleton, never a customer-of-a-customer. An account has at most one customer, and you cannot verify a third party through this API. Its id (cus_…) comes back from POST /v1/customers and from GET /v1/customers; every {id} path below takes it. The literal me is a deprecated alias for that id: it is accepted until the next Swaps-Version date, then removed — do not build on it (see Conventions). Any other id — someone else's, a mistyped one, or yours before the customer exists — answers 404 not_found, never a 403, so the API never confirms that another account's customer exists.
GET /v1/customers exists for list-shaped clients. It always returns zero or one item, and it returns an empty list, not a 404, before POST /v1/customers has been called.
Resources
| Operation | Method & path | Scope |
|---|---|---|
customers.create | POST /v1/customers | customers.write |
customers.get | GET /v1/customers/{id} | customers.read |
customers.list | GET /v1/customers | customers.read |
customers.verification_links.create | POST /v1/customers/{id}/verification_links | customers.write |
customers.compliance_profile.update | PATCH /v1/customers/{id}/compliance_profile | customers.write |
customers.associated_persons.list | GET /v1/customers/{id}/associated_persons | customers.read |
create and create_verification_link act as the account's oldest owner. A bearer session must be an owner. A business key reaches both routes, because minting the key was already an owner or admin decision. See Authentication.
Minimal flow
POST /v1/customerswith{"customer_type": "individual"}→201,verification_state: not_started. The response carries no link.POST /v1/customers/{id}/verification_linkswith{"kind": "tos", "return_to": "https://swaps.app/"}→201 { url, expires_at }. The holder opensurland accepts the terms.- The same call with
"kind": "kyc"→ the hosted identity (or business) verification link. The holder completes it themselves. GET /v1/customers/{id}— or subscribe tocustomer.verification_state_changed,customer.requirements_updatedandcustomer.rejected(see Events & webhooks) — untilverification_stateisapproved.
Creating the customer: no personal data in the body
The body takes three fields and nothing else. An unknown field is 400.
| Field | Required | What it is |
|---|---|---|
customer_type | yes | individual or business. A day-zero seed: once verification resolves, the verified type is authoritative. |
country | no | ISO 3166-1 alpha-2, validated against the same market registry POST /v1/accounts uses. Stored only if the account has no country yet — never overwrites one. |
legal_name | no, business only | The registered company name, 1–200 characters. A company's registered name is not personal data. On an individual request it answers 400 invalid_request with param: legal_name. |
The body never carries a natural person's name or email. Swaps resolves the account owner server-side and uses the owner's own email, plus, for an individual, the owner's name when a real first and last name is already on file. When no name is on file, verification still starts: the hosted flow reads the legal name off the identity document itself.
A second POST /v1/customers for the same account answers 409 customer_already_exists — read GET /v1/customers/{id} instead. A concurrent or retried create for a different customer_type answers 409 customer_type_conflict: decide which type you meant, then retry with a fresh Idempotency-Key.
Minting a verification link
kind accepts five values, but only tos (terms of service) and kyc (identity or business verification) mint a real link today. Today business_questionnaire, business_ubo and remediation answer 409 capability_unavailable — an honest refusal instead of a fabricated link.
return_to is required and must be on https://swaps.app or https://www.swaps.app; anything else is 400 invalid_request with param: return_to. It is validated but not yet threaded into the hosted hand-off: after the hosted page the holder lands on the fixed Swaps page the hand-off uses today, not on your return_to. Do not build a flow that depends on the redirect yet.
expires_at is a conservative 15-minute placeholder, not a value the verification partner publishes. Mint a fresh link each time the holder is about to open one, instead of storing links. A link call first reads the customer's status, so it can answer any of the 404/409/503 below before it looks at the body.
Reading status
Everything in the read shape is status. The fields you route on use Swaps' own vocabularies, so no provider-specific value appears in them:
| Field | Meaning |
|---|---|
verification_state | The canonical state: not_started, pending, under_review, approved, rejected, stale_mapping, action_required. An open enum — treat an unknown value as opaque, never as an error. |
requirements_due[] | What blocks progress. Route on resolution (form, hosted_kyc, tos, info) and show display_label. Never show the raw slug to a user — it is an identifier, not copy, and not yet a closed list. |
endorsements[] | The rails the customer is cleared for: base (the stablecoin rail every customer needs first), sepa, spei, pix, faster_payments, cop. Open list — ignore a code you do not know. Per-rail detail is in endorsement_statuses[], each item keyed by its own endorsement, never paired by index. |
tos_status / kyc_status | Supporting detail, not yet normalized into a closed enum. Treat anything other than approved as "not done yet". verification_state already folds kyc_status in — route on that. |
rejection_outcome / rejection_guidance[] | For rejected: an outcome code and bounded, safe copy — never the partner's raw reason. |
degraded / stale | degraded: true means a cached status (the live read was not reachable). stale: true means onboarding must restart. Neither is ever collapsed into another state. |
A form requirement is answered with PATCH /v1/customers/{id}/compliance_profile (owner or admin). It is write-only: it answers 202 {accepted: true} and never echoes the values it receives.
Two failures you must not conflate
A customer that exists but whose live status cannot be read answers one of two errors. They need opposite handling:
| Answer | Class | What to do |
|---|---|---|
409 conflict / customer_mapping_stale | Permanent. Our record of this customer no longer matches our verification partner's, and an operator has to repair it. There is no Retry-After. | Stop retrying and surface it — contact support. Retrying, even with a fresh key, will not fix it. |
503 temporarily_unavailable / customer_status_unavailable | Transient. The status read failed this time. Carries Retry-After. | Retry after the stated delay. Never treat it as "no customer yet" — that is only ever 404 not_found. |
GET /v1/customers/{id}, GET /v1/customers and the link mint all give the same answer for the same account. POST /v1/customers can also answer the 503 right after a successful start, when the new record is not visible yet. Then read GET /v1/customers/{id} rather than creating again: a repeat create answers 409 customer_already_exists once the record is visible, and never starts a second verification.
What never crosses this surface
No document, name, date of birth, address or tax id is ever accepted or returned here — not in request bodies, responses, events, URLs or MCP tool arguments. A business's registered legal_name on create is the one exception, and it is not personal data. Beneficial owners appear as a count (associated_persons_count), never by name. The associated-persons list (GET /v1/customers/{id}/associated_persons) carries a label and an optional ownership_percent per person, never a name.
What an agent can and cannot do here
An agent can start verification for the one holder it acts for (MCP tool start_verification) and read status (get_verification_status). Minting a hosted link has no MCP tool yet — it is REST only. An agent can never submit identity data, upload documents or complete verification on the holder's behalf — the person does that on the hosted page.
Next: the reference for the full Customer schema · Errors for every code above.