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, https://www.swaps.app or https://staging.swaps.app; anything else is 400 invalid_request with param: return_to. It is the hosted page's return target: when the holder finishes the terms or verification page, the verification partner sends them back to your return_to. Swaps adds nothing to it, so keep personal data out of it. A kyc link that cannot be minted with your return_to fails instead of returning a link that would send the holder elsewhere.
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.
A customer whose status reads verification_attempts_exhausted: true has used every verification attempt Swaps starts: no further kyc link is minted for it. Do not offer a retry; send the holder to support.
A link for one payout rail
A kyc link takes an optional rail: ach, wire, sepa, faster_payments, pix, spei, bre_b or co_bank_transfer — the rail the holder wants to be paid on, for example the one picked for a sell, or the one a buy pays in on. For an individual customer, sepa, faster_payments, pix, spei and the two Colombian rails ask the verification partner for that rail's approval (its endorsement; cop for both Colombian rails) when the customer does not carry it yet, and the link opens on that approval's own requirements (Pix may ask for a CPF, for example). Once the individual's verification is approved, before anything is asked of the partner, their live approval for a buy on that rail is read (pix_onramp for Pix) — before that, the link starts identity verification and asks for the rail as before: when a hosted link cannot move it, nothing is requested and no link is minted — 403 endorsement_required with details.payment_rail, details.endorsement and details.next: accept_terms (mint a kind: tos link first), complete_profile (a questionnaire the hosted flow does not collect), provider_review (the partner is holding it — nothing to do) or not_available (the partner does not offer the rail to this customer). For Pix the payout side counts too: the link is minted when either direction (pix_onramp, or pix_offramp / pix) has a step the hosted flow takes or is already approved; otherwise the most actionable step of either is named. A status that could only be read from a cached copy does not decide that step: the link is requested as before (the residence rule below still runs, on the partner's live read). Beside rail, an optional purpose says what the link is for — buy (the holder pays in on the rail) or sell (they are paid out on it); with any other value, or without rail, it is 400 invalid_request, param: purpose. The customer's country of residence counts too, before anything is requested and whatever the verification state: bre_b / co_bank_transfer for residents of the EU/EEA are refused with next: not_available whatever purpose says (the partner does not offer COP to EU customers in either direction), and with purpose: buy so is pix outside Brazil (the partner opens Pix pay-ins to Brazilian residents only) — so the holder never starts a verification that cannot end in an approval. An approval they already hold is never refused. A residence the partner does not hold decides nothing until the individual's verification is approved; after that, a purpose: buy Pix link or a COP link with no residence on file is refused with next: provider_review. spei, faster_payments and sepa are never refused on residence. With purpose: buy the Pix payout side does not count either: only pix_onramp decides. A Pix link with purpose: sell or without purpose is decided as without the field — a payout on pix is open to residents of other countries. ach and wire ride the approval every customer is created with, so the link is the same as without rail; a business customer's link is not scoped by rail. With kind: tos or any other kind, rail is 400 invalid_request, param: rail. If the approval cannot be requested right now the answer is 503 temporarily_unavailable naming the rail — retry with a new Idempotency-Key. A link is not an approval: read endorsement_statuses[] for the rail before you offer it.
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. |
verification_attempts_exhausted | true when verifying is the next step (not_started, action_required or rejected) and the account has used every verification attempt Swaps starts (two rejections): no further kyc link is minted, and support is the next step. Absent otherwise — never on an approved, pending or under_review customer. The count itself is not published. |
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. |
verification_checked_at | When this status was checked with our verification partner: the moment of a live read, or the time of Swaps' stored check (see below). Show it next to the status. |
A stored check for display: verification=snapshot
By default every read asks our verification partner live. A screen that only displays status can pass verification=snapshot (on GET /v1/customers/{id}, GET /v1/customers, GET /v1/account/readiness, GET /v1/account/setup_guide, GET /v1/account?expand= and GET /v1/capabilities?product=…): the answer then comes from the copy Swaps stored the last time it received the customer's whole state from the partner, while that copy is no more than 24 hours old — no partner call. An older copy is read live. If that live read cannot reach the partner, the answer is 503 temporarily_unavailable «Could not read verification status right now» with Retry-After — never the older status in its place. verification_checked_at always says how old the answer is. Never decide a money action on a snapshot read: every money operation checks the partner live itself.
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.