# Errors

Every error, on every endpoint, is the same JSON shape. There is no second format to
handle.

## The envelope

```json
{
  "error": "insufficient_balance",
  "message": "optional human-readable detail",
  "details": [{ "path": "amount", "message": "Invalid" }]
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `error` | `string` | **Always present.** A stable, machine-readable code. Branch on this. |
| `message` | `string` | Optional prose for a human. May change without notice — never parse it. |
| `details` | `array` | Present on `validation_error`: one `{ path, message }` per failing field. |

`error` is the contract. `message` is a courtesy. Code that switches on `message` text will
break the first time someone improves the wording.

## What each status class means for a retry

The status code answers one question — *should I send this again?* — and the answer is
different for each class.

**`400` — the request is wrong.** Body or query failed validation, or a domain rule was
violated: `validation_error`, `unknown_asset`, `asset_disabled`, `invalid_amount`,
`bad_amount`, `invalid_address`, `invalid_pair`, `no_route`; on withdrawals also
`amount_below_minimum` (a zero or negative amount — payouts have no other floor),
`self_transfer` (the recipient is the user's own deposit address — nothing would move but
the network fee) and `invalid_request`.
**Never retry unchanged.** Nothing was
created and nothing was charged. Fix the request, or surface the problem to whoever typed
it. On `validation_error`, `details[]` names the exact field.

**`401 unauthorized` — the key is missing, malformed or unknown.** Never retry; no amount
of backoff produces a valid key. See [Authentication](authentication).

**`403` — the key is real, but your account, your login or the feature may not do this.**
`email_unverified` (the owner login has not verified its email), `account_paused` (an
operator hold) and `account_suspended` answer every call on the key until the state
changes — see [Authentication](authentication). `session_required` and `forbidden_role`
mean the action exists only on the dashboard, with a second factor and the right role;
`own_account_only` is a dashboard session trying to move a customer's balance, which only
your integration may do. None of these is a bug in your request, and retrying does not help; the change that
lifts them is ours, and the dashboard shows the state.

**`400 unknown_asset`** — an asset id we do not support. Take ids from `GET /v1/assets`.

**`404` — not found, or not yours.** An unknown id and another merchant's id are
deliberately **indistinguishable**, so you cannot enumerate anyone else's records. Also
returned by the idempotency-key lookups when no record was created for that key — which is
a *meaningful* answer there, not an error. See [Idempotency](idempotency).

**`409` — a conflict with existing state.** The request was well-formed but the world
disagreed: `insufficient_balance`, `idempotency_conflict`, `card_state`,
`kyc_not_approved`, `provider_rejected`, `not_cancellable`, `too_many_endpoints`,
`no_own_account` (your own balance has not been opened yet) and `not_pending` (a
parked payout someone already decided). Do not blind-retry. Read the current state,
decide, and act — most of these change only when something else changes first (a deposit
lands, KYC is approved, a card is unfrozen).

**Two card errors are retired** (20 September 2026) and will never be raised again:
`409 card_close_underfunded` (a card had to hold the issuer's cancellation charge before it
could be closed) and `409 card_refund_wallet_empty` (a close waited on the wallet that pays
refunds). A close is now always accepted, and where the money is reads as
`close_refund.state` on `GET /v1/cards/{id}`. Delete any handling of the two codes.

`409 provider_rejected` is worth calling out: it is a **definitive** failure. The provider
looked and refused, and any charge is already reversed. `details.order_id` names the order.

**`500 internal_error`** — something broke on our side. The body carries no `message`. Safe
to retry with backoff **and the same idempotency key**; whether the operation landed is
indeterminate, so resolve it with the key lookup rather than assuming either way.

**`503` — a dependency is unavailable, and your request was fine.** `chain_unavailable`
(no adapter configured for that asset's chain), `cards_disabled`, `swap_disabled`,
`swap_unavailable`, `exchange_error`. **Retry later, unchanged.** Do not adjust the request to work around it,
and do not queue a modified version of it on your side.

## Retry decision table

| Status | Retry? | With the same idempotency key? |
| --- | --- | --- |
| `400`, `401`, `403` | No | — |
| `404` | No (it is an answer) | — |
| `409 idempotency_conflict` | No — fix key generation | — |
| `409` others | Only after state changes | Yes |
| `429` | Yes, after backing off | Yes |
| `500`, `502`, `504` | Yes, with backoff | **Yes — always** |
| `503` | Yes, later, unchanged | Yes |

The one rule that never bends: **when you retry a mutation, reuse the original
`idempotency_key`.** A fresh key on a retry is how a customer gets paid twice.

The same instinct applies everywhere: before you refund, re-charge, or tell a customer
something final, reconcile against the resource read. Webhooks are hints; the poll is truth.

## Catalogue

Generated from the `components.responses` block of the OpenAPI contract
(v1.0.0) — this is every named error response and the `error` codes it can
carry.

| Response | HTTP | `error` codes | What it means |
| --- | --- | --- | --- |
| `Unauthorized` | 401 | `unauthorized` | Missing, malformed or unknown API key. |
| `AccountNotLive` | — | `email_unverified`, `account_paused`, `account_suspended` | The key is valid but the account is not live, so every request answers this until it is. |
| `ValidationError` | 400 | `validation_error`, `unknown_asset`, `asset_disabled`, `invalid_amount`, `amount_below_minimum`, `bad_amount`, `invalid_address`, `invalid_request` | Body/params failed validation, or a domain rule was violated. |
| `UserNotFound` | 404 | `user_not_found` | Unknown external_user_id for this tenant. |
| `NotFound` | 404 | `not_found` | Record not found (or belongs to another tenant). |
| `InsufficientBalance` | — | `insufficient_balance` | The hold would overdraw the user's balance. |
| `ChainUnavailable` | 503 | `chain_unavailable` | No chain adapter configured for the asset's chain. |
| `CardBadRequest` | 400 | `validation_error`, `invalid_request`, `invalid_amount`, `card_core_required`, `card_provider_required`, `unknown_provider`, `not_supported_by_provider` | Request validation failed or a domain rule was violated. |
| `CardHolderNotFound` | 404 | `holder_not_found`, `user_not_found` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `CardNotFound` | 404 | `card_not_found`, `user_not_found` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `CardConflict` | 409 | `kyc_not_approved`, `card_state`, `card_issuing`, `provider_rejected`, `email_taken`, `idempotency_conflict`, `insufficient_balance`, `no_own_account` | A conflicting card state. |
| `CardUnavailable` | 503 | `provider_unavailable`, `card_rail_unavailable`, `cards_disabled`, `rail_unfunded`, `answered` | Temporarily unable to serve this card call — your request was valid. |
| `ExchangeBadRequest` | 400 | `validation_error`, `invalid_amount`, `invalid_pair`, `no_route` | The request was refused before anything was charged. |
| `SwapBadRequest` | 400 | `validation_error`, `invalid_amount`, `invalid_pair`, `no_route` | The request was refused before anything was charged. |
| `SwapConflict` | 409 | `insufficient_balance`, `idempotency_conflict` | `error` is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this `idempotency_key` was already used with a DIFFERENT payload — your replay is not the request you think it is). |
| `SwapOrderNotFound` | 404 | `order_not_found` | Unknown order, or one belonging to another merchant — the two are indistinguishable by design. |
| `SwapDisabled` | 403 | `swap_disabled` | Swaps and exchanges are turned off, or the exchange integration is not configured. |
| `ExchangeError` | 502 | `exchange_error` | The exchange failed or was unreachable (your request was valid). |
| `ExchangeUnavailable` | 503 | `swap_unavailable` | Our side is not ready for this chain. |

## Debugging an integration

`GET /v1/developer/logs` returns the method, route, status and duration of your recent
calls, kept for 14 days, with no bodies and no query strings. When a call seems to have
vanished, check there first: it tells you whether the request reached the platform at all,
and what status it left with.

### Daily limits

Calls that cost a third party money per request are capped per account per day:
starting a new identity verification (`POST /v1/kyc/start`, resuming one is free) and
uploading card KYC documents (`POST /v1/cards/kyc/upload`). Past the cap the answer is
`429 daily_limit_reached` until the next UTC day. If your volume genuinely needs more,
ask us to raise it.
