#Errors
Every error, on every endpoint, is the same JSON shape. There is no second format to handle.
#The envelope
{
"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.
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. 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.
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 externaluserid 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: insufficientbalance (the sell amount would overdraw the user's balance), idempotencyconflict (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.