# Customers & verification

**Status:** Dark-flag — live behind a cohort or flag

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](/conventions#naming)). 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](/authentication).

## Minimal flow

1. `POST /v1/customers` with `{"customer_type": "individual"}` → `201`, `verification_state: not_started`. The response carries no link.
2. `POST /v1/customers/{id}/verification_links` with `{"kind": "tos", "return_to": "https://swaps.app/"}` → `201 { url, expires_at }`. The holder opens `url` and accepts the terms.
3. The same call with `"kind": "kyc"` → the hosted identity (or business) verification link. The holder completes it themselves.
4. `GET /v1/customers/{id}` — or subscribe to `customer.verification_state_changed`, `customer.requirements_updated` and `customer.rejected` (see [Events & webhooks](/events-webhooks)) — until `verification_state` is `approved`.

## 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](/reference) for the full `Customer` schema · [Errors](/errors) for every code above.
