# Conventions

The rules every `/v1` resource follows, stated once (RESOURCE-MODEL §0) and never re-argued per resource.

## Money

A money value is always an **object**, never a bare number:

```json
{ "amount": "500", "currency": "USD", "decimals": 2 }
```

`amount` is a minor-unit integer **as a string**; `decimals` states the scale so a client never re-derives it (USDC is `decimals: 6`). A float never appears on a money path. Basis points, percentages and block counts stay plain integers and are not `Money` objects.

One stated exception: `POST /v1/quotes`'s `from_amount`/`to_amount` are bare decimal strings in major units (`"1250.50"`, matching `bestQuoteRequestSchema`), not `Money` objects — deliberately. A quote request is pricing an amount, not yet stating a money fact with a settled scale; every OTHER creating request (`payouts.create`, `payment_links.create`, …) uses `Money`. If you learned the shape from one of those, do not send a `Money` object to `quotes.create` — it is a plain decimal string and a `Money` object there is `400`.

## Idempotency-Key

Every mutating request carries an `Idempotency-Key` header:

- Caller-generated, 16–128 characters of `[A-Za-z0-9_-]` (a UUID qualifies); the shape is enforced — anything else is `400 invalid_request/idempotency_key_invalid`.
- A replay of the same key within 24 hours returns the **stored response**, never a second money movement.
- A different request body under the same key is `idempotency_error` (409) — never a silent merge.
- The store is keyed `(key, account, route)`, so the same key on two different endpoints is not a collision.

## Swaps-Version

Behaviour within `/v1` is pinned by a dated header, `Swaps-Version: 2026-09-04`:

- A key carries a default version; a request header overrides it for that call.
- Every response echoes the version it was served under — literally: a date older than the oldest version this deployment still serves is never echoed verbatim, it comes back as that oldest version, because that is the behaviour you actually got.
- A new date is cut only for a genuine behaviour change — additive fields are never a version bump, and a client must ignore fields it doesn't recognise.
- A breaking change gets a new resource path or tool name instead; a published path or tool signature is never repurposed, and old and new overlap for at least six months.

## Swaps-Account

A credential that belongs to more than one account (a dashboard session shared across a team) selects which one a request acts on with an optional header, `Swaps-Account: acct_…`; omitted, it acts on the caller's default account. A business key is unaffected — a key already belongs to exactly one account and cannot be redirected by this header — and the parameter is published only on an operation a dashboard session can call: the server does not read it on the API-key path, so declaring it everywhere would offer a scoping control a business key silently ignores (A1-2 fixer round 1).

## Forward compatibility — enum members

Two rules, and they apply to different fields:

- A **response** field's enum may gain a new member within `/v1`, without a version bump, when the API-CANON marks it growth-prone (event types, lifecycle statuses, a partner's own vocabulary). Treat an unrecognized member as an opaque string — do not throw, do not treat it as `null`. The generated TypeScript SDK types these fields as `"known" | "values" | (string & {})` for exactly this reason: the literal union still gives you autocomplete, and the `string` half keeps a new member from failing to compile or to parse.
- A **request** field's enum never grows silently: sending a value this deployment does not recognise is `400 invalid_request`, always — including on `webhook_endpoints.create`/`.update`'s `event_types`, which draws from the very same catalogue `Event.type` publishes as open. Subscribing to a name this deployment cannot yet deliver cannot be honoured, so it is refused rather than silently accepted and never fired.

## Pagination

List endpoints take `limit` (default 25, max 100) and an opaque `cursor`, and return:

```json
{ "data": [ /* … */ ], "has_more": true, "next_cursor": "eyJjcmVhdGVkX2F0Ijoi…" }
```

`next_cursor` is always present — `null` when there is no next page, a cursor string when there is; never omitted. `limit` outside `1..100` is never rejected: `0`, a negative number or anything non-numeric falls back to the default (25); anything above 100 is capped to 100.

The cursor encodes `(created_at, id)` or the resource's own monotonic identity column. A cursor list never returns a total count — build a "how many" UI from a resource's own `/summary` endpoint where one exists (for example `/v1/activity/summary`), not by paging to the end. Capability and eligibility reads are unpaginated — they're small, bounded projections by design.

## Errors

One envelope for every `/v1` response and every MCP tool failure:

```json
{ "error": { "type": "invalid_request", "code": "payer_type_invalid", "message": "…", "doc_url": "https://docs.swaps.app/…", "request_id": "req_…" } }
```

`type` is the class an agent needs to pick a recovery; `code` is a stable, additive-only identifier — a code, once shipped, is never renamed or removed. `doc_url` resolves to the [full code list](/errors) (A1-1), anchored per code — every operation's `4xx` responses on the [reference](/reference) also carry a `422`/`409` where its handler can actually return one, and every `available`/`dark-flag` operation declares `500`.

Two optional fields carry the specifics: `param` names the offending request field where one can be named, and `details` is machine-readable context for the codes that have some — the values you would otherwise parse out of `message`. Read `details` by `code`, never generically: each code documents its own keys, and a code with nothing to add omits the object. `allowed_rails_invalid_for_currency` carries `currency`, `allowed_rails` and `offerable_rails`.

### The type table

| `type` | HTTP | Agent recovery |
|---|---|---|
| `invalid_request` | 400 / 422 | Fix the argument and retry. |
| `authentication_error` | 401 | Re-authorize; do not retry as-is. |
| `permission_error` | 403 | Wrong actor or scope — call as the right one. |
| `not_found` | 404 | The id is not visible to this caller — also the cross-account answer. |
| `conflict` | 409 | State moved — re-read, then decide. |
| `idempotency_error` | 409 | Same key, different body. |
| `rate_limit_error` | 429 | Back off; `Retry-After` is set. |
| `capability_unavailable` | 409 | Do **not** retry — this corridor or product cannot serve it. |
| `temporarily_unavailable` | 503 | A kill switch is thrown, or a dependency is out; retry after `Retry-After`. |
| `quota_exhausted` | 402 | An allowance is spent — backing off does not restore it; upgrade or buy credits. |
| `provider_error` | 502 / 503 | Upstream failed; retry only where stated. |
| `api_error` | 500 | Ours — open a support ticket with the request id. |

> **capability_unavailable vs temporarily_unavailable**
>
> This split is load-bearing. `capability_unavailable` means this corridor or product cannot serve the request — waiting does not change the answer. `temporarily_unavailable` means a kill switch is thrown or a dependency is degraded — it can be un-thrown without a deploy, so the honest recovery is "retry later". Never conflate them, and never return a bare 403, a 500 or an empty success where one of these two is the truth.

A cross-account id is always `not_found`, never `permission_error` — the alternative would tell an attacker that an id they can't reach nonetheless exists.

One code carries one meaning, but the same meaning can be reachable two ways, and then the `type` differs while the code does not. `allowed_rails_invalid_for_currency` is the worked example: `POST /v1/payment_links` and `PATCH /v1/payment_links/{id}` answer `400 invalid_request` when the rails you are writing would leave the link with nothing a payer could pick — the offending value is in your request, so fix it and retry. `POST /v1/payment_links/{id}/activate` answers `409 conflict` for the same defect — there the offending value is the stored draft, so the recovery is to patch the draft (`allowed_rails: null` clears the restriction) and activate again. Match on `code` for what went wrong, on `type` for what to do about it.

## `livemode`

Every object carries `livemode: boolean`. For resources with a dedicated `livemode` column it is set once, at creation, from the calling key's mode (`sk_live_…`/`sk_test_…`) and never toggled after the fact; resources with no column yet currently hardcode `true` on every read regardless of caller — see [Test mode](/test-mode) for which is which.

## Naming

Every operation id is `<resource>.<verb>`, and it is the method name in the SDK, so these names do not change within `/v1`:

- The last part is a verb: `payouts.fund`, `payouts.beneficiary.set`, `wallet.transactions.list_recent`.
- Something under a parent is part of the resource name: `payouts.events.list`, `payroll_runs.items.get`, `screenings.reports.create`, `payouts.receipt.get`.
- Writing a single sub-resource under a parent is `.set` (or `.update` for a `PATCH`): `payouts.beneficiary.set`, `customers.compliance_profile.update`.
- Five read-only views that the server computes from their parent keep the form `get_<view>`: `get_readiness`, `get_funding_instructions`, `get_setup_guide`, `get_usage`, `get_summary`.

Scopes are `<resource>.<read|write>`. Screenings, traces and transaction lookups use `screenings.read` / `screenings.write`. A key created with the old `screening.*` names keeps working until the next `Swaps-Version` date. Quotes are the one exception: they share `orders.read` / `orders.write`, because a quote exists only to become an order.

Your own account is `/account`. It is a real singleton, and a session that belongs to several accounts picks one with `Swaps-Account`. Every other resource is addressed by its id. On `/customers/{id}`, the literal `me` is a **deprecated** alias for your customer id. It works until the next `Swaps-Version` date. Use the `cus_…` id returned by `GET /v1/customers` or `POST /v1/customers` instead.

UK Faster Payments is always `faster_payments`, never `fps`.

## Status vocabulary

Every operation in the [reference](/reference) is tagged with exactly one of three values (`x-swaps-status`) — never a fourth:

- **available** — a live backend exists and `/v1` is a thin router in front of it.
- **dark-flag** — live, but gated behind a cohort or feature flag. While the operation's resource switch (`api_v1.<resource>`) is off, calling it answers `503 temporarily_unavailable` — an availability lever, never an authorization one. With that switch on, a closed product or launch flag — for example `payment_links_crypto_only` when creating or resuming a subscription, or Receive by bank not launched when creating a virtual account — or a cohort the account is outside answers `409 capability_unavailable` from the operation itself.
- **proposed** — does not exist yet. Documented for the contract it will carry, never presented as callable, never hidden.

Next: the [full error code list](/errors) · [Errors and events](/events-webhooks) · the [error type table lives inline in the reference](/reference) on every operation too.
