# Customers — Swaps API

Verification state — status only, never documents.

**Jump to an operation:**

- [GET /customers](#list-this-businesss-customers-dark-flag)
- [POST /customers](#start-verification-for-a-new-customer-dark-flag)
- [GET /customers/{id}](#read-this-customers-verification-status-dark-flag)
- [POST /customers/{id}/verification_links](#mint-a-hosted-verification-link-dark-flag)
- [GET /customers/{id}/associated_persons](#list-this-business-customers-beneficial-owners-name-free-dark-flag)
- [PATCH /customers/{id}/compliance_profile](#submit-structured-compliance-details-v4-questionnaires-dark-flag)
- [GET /customers/{id}/events](#list-one-customers-verification-timeline-planned)

> Generated from the bundled OpenAPI contract. The HTML page and this Markdown twin share the same processed operation descriptions and status annotations.

<a id="list-this-businesss-customers-dark-flag"></a>
## `GET /customers` — List this business's customers — dark-flag

**Operation ID:** `customers.list`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.read` · **Test mode:** unavailable
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

A business key lists only its own customers; an agent never lists across holders. **L3-1**: today's model allows at most ONE customer per account owner (`docs/api/RESOURCE-MODEL.md` D-3), so this is always a one-element or empty list, never paginated in practice — `has_more` is always `false`. Empty (never `404`) before `customers.create` has ever been called for this account. **Fix-pack (independent review, P2)**: a customer record that exists but whose mapping to our verification partner is stale answers the SAME `409 customer_mapping_stale` `GET /v1/customers/{id}` answers for it — never a retry-forever `503`. **Dark-flag** — gated behind `api_v1.customers` (K6), the same flag `customers.get`/`.create` already use — no new flag.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |
| `VerificationRead` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | A page of customers. | `application/json` [CustomerList](/reference/~schemas.md#customerlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | `customer_mapping_stale` — a customer record exists for this account, but our verification partner answered "not found" for it. PERMANENT until an operator repairs the mapping; never retry this specific failure. | `application/json` [Error](/reference/~schemas.md#error) |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="start-verification-for-a-new-customer-dark-flag"></a>
## `POST /customers` — Start verification for a new customer — dark-flag

**Operation ID:** `customers.create`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.write` · **Test mode:** unavailable · **Money boundary:** per-transaction human confirmation required
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

Feeds the account-type choice (`onb-welcome`, R17 §1) into a new `not_started` customer. Never a public token — a customer is created only by the business key that owns it or by the agent acting for one holder, and it is ALWAYS that caller's own account holder (RESOURCE-MODEL D-3) — never a customer-of-a-customer. The request body never carries a name, email, date of birth, address, document or tax id, with ONE exception below; the server resolves the caller's account owner internally and reads its own email, and — for `customer_type: individual` — its own name, when a real first+last pair is already on file (never a merchant/brand name, or the email local part). **Round-2 architect ruling (2026-09-21)**: when no trustworthy name is on file, the SAME hosted verification flow starts WITHOUT one — our verification partner's hosted KYC reads the legal name off the identity document itself, never off this request. This removes the round-1 `409 customer_name_required` refusal, which had no `/v1` remedy and permanently blocked every API-only account (fix-pack, independent review, P1 — closed, not narrowed). For `customer_type: business`, the body may instead carry an OPTIONAL `legal_name` (1-200 chars, trimmed) — a registered company name, not personal data — which becomes that entity's own legal name on file; omitted, the business customer is created without one. `legal_name` on an `individual` request answers `400 invalid_request` (`param: legal_name`) — it is a business-only field. If the account already has a customer, `409 customer_already_exists` — our verification partner bills per terminal KYC status, so this never creates a second one; a concurrent or retried call that already claimed the account's onboarding slot for a DIFFERENT `customer_type` answers `409 customer_type_conflict` instead (A4-FIX-L3). **Owner only** — a bearer session must be the account's owner, never an admin (architect ruling, 2026-09-21): this always acts as the account's oldest owner regardless of which member calls it (an `admin`-minted business key still reaches this route — key minting is the gate there, `POST /v1/api_keys`, owner or admin). `country`, when given, is validated against the same market registry `POST /v1/accounts` validates it against (`400 invalid_request` for an unrecognised code) and never overwrites a country the account already declared. A test-mode key (`livemode: false`) answers `503 temporarily_unavailable` here — our verification partner's own sandbox toggle is process-wide, not per-caller, so a test-mode call would otherwise reach the SAME live, billed account (fix-pack, independent review, P2; matches `orders.create`/`quotes.create`). The `201` response carries NO hosted link — mint one with the existing `POST /v1/customers/{id}/verification_links` (`kind: tos` first, per `R17-verification.md`). **Dark-flag** — gated behind `api_v1.customers` (K6), the same flag every other `customers.*` operation already uses — no new flag.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [CustomerCreateRequest](/reference/~schemas.md#customercreaterequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | The new customer, `verification_state: not_started`. | `application/json` [Customer](/reference/~schemas.md#customer) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | `customer_already_exists` — this account already has a customer; our verification partner bills per terminal KYC status, so `customers.create` never mints a second one. Read `GET /v1/customers/{id}` instead. `customer_type_conflict` (A4-FIX-L3) — a concurrent or retried `customers.create` for this SAME account already claimed the onboarding slot for a DIFFERENT `customer_type`; resolve which type is intended and retry with a fresh `Idempotency-Key`, never the same one. `customer_mapping_stale` — a customer for this account was already created (or already held) upstream, but the local mapping to it is missing or stale; PERMANENT until an operator repairs it, never a retry with the same key (fix-pack round 2, P2). `conflict` — a generic pre-verification-call refusal raised defensively; not reachable for this always-authenticated, self-only route today (fix-pack round 2, P2). Also the SAME idempotency-conflict vocabulary every mutating `/v1` operation with an `Idempotency-Key` shares: `idempotency_error` (the key was reused with a different request body), `idempotency_in_progress` (a request with this key is already running), and `idempotency_failed` (the first attempt on this key answered a 5xx after a verification call may have taken effect — reconcile via `GET /v1/customers/{id}` before retrying with a NEW key, never the same one). | `application/json` [Error](/reference/~schemas.md#error) |
| `413` | — | `PayloadTooLarge` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="read-this-customers-verification-status-dark-flag"></a>
## `GET /customers/{id}` — Read this customer's verification status — dark-flag

**Operation ID:** `customers.get`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.read` · **Test mode:** unavailable
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

Self only — a customer reads its own record, and an agent reads the one holder it acts for. Status only: never a document, a submitted KYC form value, a bank detail or a beneficial owner's name. **Dark-flag** — gated behind `api_v1.customers` (K6). An email log/persist leak on the live status-read's unauthorized branch (D-11, SEC-5) is fixed in the same PR that wires this read (`swaps/index.ts`'s probe fingerprint no longer carries `email`). **BL-40**: `404 not_found` means no customer has ever been created for this account. A customer that exists but whose live verification-status read failed (a stale internal mapping, or a transient read failure) answers `503 temporarily_unavailable` instead — retry, never treat it as day-zero. `verification_checked_at` names when the status was checked with the verification provider; with `verification=snapshot` it comes from Swaps' stored check while that is no more than 24 hours old (no provider call), and a live read that cannot reach the provider answers `503`, never the older status.
`{id}` is the account's own `cus_…` id, from `customers.list` or `customers.create`. The literal `me` is a deprecated alias for it (see the parameter below). Before `POST /v1/customers` has ever been called for this account, every id answers this exact `404 not_found`; call `customers.create` first.

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias, accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`. |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `VerificationRead` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | The customer's current verification status. | `application/json` [Customer](/reference/~schemas.md#customer) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="mint-a-hosted-verification-link-dark-flag"></a>
## `POST /customers/{id}/verification_links` — Mint a hosted verification link — dark-flag

**Operation ID:** `customers.verification_links.create`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.write` · **Test mode:** unavailable
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

Accepts all five `kind` values, but only `tos` and `kyc` mint a real link today, through our verification partner's own hosted flow (K6). `business_questionnaire`, `business_ubo` and `remediation` have no backing implementation anywhere in this codebase yet — the partner's endorsement-scoped link shape must be confirmed to distinguish them before they ship for real, so each answers `409 capability_unavailable` rather than a fabricated link. `return_to` is validated against an origin allowlist and is the hand-off's return target (the partner sends the holder back to it). `rail` (optional, `kind: kyc` only) asks the partner for that payout rail's approval when an individual customer does not carry it yet and opens the link on its requirements; see the field. **Owner only** — a bearer session must be the account's owner, never an admin (architect ruling, 2026-09-21): this always acts as the account's oldest owner regardless of which member calls it (an `admin`-minted business key still reaches this route — key minting is the gate there, `POST /v1/api_keys`, owner or admin). **Dark-flag** — gated behind `api_v1.customers` (K6).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias, accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`. |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [CustomerVerificationLinkRequest](/reference/~schemas.md#customerverificationlinkrequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `201` | A one-time hosted link. | `application/json` [CustomerVerificationLink](/reference/~schemas.md#customerverificationlink) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | This customer is not in a state this `kind` of link can address (e.g. minting a `kyc` link before `tos` is accepted). | `application/json` [Error](/reference/~schemas.md#error) |
| `413` | — | `PayloadTooLarge` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="list-this-business-customers-beneficial-owners-name-free-dark-flag"></a>
## `GET /customers/{id}/associated_persons` — List this business customer's beneficial owners, name-free — dark-flag

**Operation ID:** `customers.associated_persons.list`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.read` · **Test mode:** unavailable
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

`{label}` plus an OPTIONAL `ownership_percent`, never a name — for every caller kind, in this K6 pass (stricter than D-49's own default, which would allow `name` for the holder's own delegated session; that widening is not implemented here). Our verification partner's raw per-person field names are not independently confirmed anywhere in this codebase (only a count is read today) — ownership is read defensively, never guessed at, and is OMITTED (never defaulted to `0`) when the raw payload carries no recognized ownership field, so a client must render the absence honestly rather than as an observed 0%. `status` is omitted (optional on the schema) rather than a fabricated placeholder — no confirmed per-person status field exists anywhere in this codebase yet; a follow-up derives it from the same endorsement `missing[]` object-scoped bundles the Verification Hub already reads, once a live upstream response confirms the shape. **Dark-flag** — gated behind `api_v1.customers` (K6).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias, accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`. |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | This customer's associated persons — name-free unless the caller is the holder's own delegated session. | `application/json` [AssociatedPersonList](/reference/~schemas.md#associatedpersonlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="submit-structured-compliance-details-v4-questionnaires-dark-flag"></a>
## `PATCH /customers/{id}/compliance_profile` — Submit structured compliance details (V4 — Questionnaires) — dark-flag

**Operation ID:** `customers.compliance_profile.update`

> **Status:** Dark-flag — live behind a cohort or flag · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.write` · **Test mode:** unavailable
>
> This operation is **dark-flag — live behind a cohort or flag**. While its resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable`; with the switch on, a closed product or launch flag, or a cohort the account is outside, answers `409 capability_unavailable`. Check its product page before relying on it.

Write-only structured update for our verification partner's own compliance-questionnaire fields — this route never re-implements the enum whitelist or the underlying call, it only resolves the caller's own customer id server-side (never accepted from the request body — R17 PII rule) and forwards the body verbatim. Every field is optional; only the fields present are sent — the server definedOnly-merges, exactly as the underlying action already does. Closes V-G5 (`R17-verification.md`), the same kind of gap K6 already closed for `tos`/`kyc` via `verification_links`. Responds `202` with `{accepted: true}` only — per V-G5/V-G15, a submitted value is NEVER echoed back on any read (there is no `GET` counterpart by design; `GET /customers/{id}` still carries only status). Test mode: full — this route does not read `caller.livemode`; a `sk_test_` caller's answers are written to the SAME live account a `sk_live_` caller would reach (the underlying action has no test/live branch today, matching its sibling `customers.verification_links.create`). **Dark-flag** — gated behind `api_v1.customers` (K6).

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias, accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`. |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `IdempotencyKey` | component | — | — | Reusable parameter component |

### Request body

Required: **yes**

| Media type | Schema |
| --- | --- |
| `application/json` | [ComplianceProfileRequest](/reference/~schemas.md#complianceprofilerequest) |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `202` | Intake confirmed — the fields were forwarded to our verification partner. Not a re-read of the stored profile. | `application/json` [ComplianceProfileResult](/reference/~schemas.md#complianceprofileresult) |
| `400` | No known field was present, or a field's value is not in the published enum for it. | `application/json` [Error](/reference/~schemas.md#error) |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `409` | — | `Conflict` |
| `413` | — | `PayloadTooLarge` |
| `429` | — | `TooManyRequests` |
| `500` | — | `InternalError` |
| `503` | — | `ServiceUnavailable` |

---

<a id="list-one-customers-verification-timeline-planned"></a>
## `GET /customers/{id}/events` — List one customer's verification timeline — planned

**Operation ID:** `customers.events.list`

> **Status:** Planned — not built yet · **Callers:** business key, agent (business key), dashboard session · **Scope:** `customers.read` · **Test mode:** sandbox
>
> This operation is **planned — not built yet**. It is documented for the contract it will carry, but it does not run today. Never call it expecting a live result.

`customer.verification_state_changed`, `.rejected`, `.requirements_updated` (RESOURCE-MODEL §3, D-55) — gives a dated ladder for the review-in-progress screens without re-deriving readiness client-side. **Proposed.**

### Parameters

| Name | In | Required | Schema | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | `string` | The customer id (`cus_…`). The literal `me` (the caller's own account holder) is a deprecated alias, accepted until the next `Swaps-Version` date: read the id from `customers.list` or `customers.create`. |
| `SwapsVersion` | component | — | — | Reusable parameter component |
| `SwapsAccount` | component | — | — | Reusable parameter component |
| `Limit` | component | — | — | Reusable parameter component |
| `Cursor` | component | — | — | Reusable parameter component |

### Responses

| Status | Description | Body |
| --- | --- | --- |
| `200` | This customer's event timeline, oldest first. | `application/json` [CustomerEventList](/reference/~schemas.md#customereventlist) |
| `400` | — | `BadRequest` |
| `401` | — | `Unauthorized` |
| `403` | — | `Forbidden` |
| `404` | — | `NotFound` |
| `429` | — | `TooManyRequests` |
| `503` | — | `ServiceUnavailable` |
