# Error Codes

> Handle Merchant API failures with stable machine-readable codes, field-level validation details, and documented recovery paths.

## Code catalogue

Stable machine-readable identifiers returned in `code` (and per-field in `errors[].code`). This is the **live set** — every code below is produced by at least one handler, validator, or middleware in the running API, not a theoretical superset. Existing codes never change semantics, and new codes are added as new scenarios arise. A code is renamed only in rare cases, and every rename is announced in the changelog.

Switch on `code` for programmatic handling. `detail` is English-only and intended for logs / fallback UI; localize on the client by `code`.

`401` and `403` are not two grades of the same failure. A `401` means the request never got past the credential check: the header is absent, malformed, signed with the wrong secret, or signed too long ago. Sending it again unchanged returns the same answer. The fix is on the credential side — the key, its configuration in the dashboard, or the clock you sign with.

A `403` means the credential was accepted and something further along refused. Two unrelated situations share that status:

- **Wrong key for this call** — `forbidden`, `payment_key_required`, `payout_key_required`. Integration configuration: sign with a key of the right type and environment, and the call goes through.
- **Right key, refused by account or project state** — `whitelist_required`, `merchant_suspended`, `widget_inactive`, `terminal_not_enabled`, `embed_not_enabled`, `not_sandbox`, `payment_channel_simulation_sandbox_only`. The credential is healthy and replacing it changes nothing. The common case is `whitelist_required`: the destination address hasn't been added to the withdrawal whitelist yet. Show that to whoever is waiting on the payout, rather than paging an engineer to rotate a key that was never broken.

`ip_allowlist_required` sits between the two. The key is the right type, but the merchant's withdrawal whitelist is off, and in that state a live Payout key pays only once it has an IP whitelist. The fix is in the key's settings in the dashboard: add your servers' IPs.

The HTTP status only says which layer refused. What to do about it is in `code` — not in the status, and not in whichever error class your SDK mapped it to.

### Field-level codes

Emitted by request validation. Each code below sets `field` to the offending field name in snake_case. When several fields fail at once, the top-level `code` is `validation_failed` and every per-field failure lands in `errors[]`.

| Code | When | What to do |
|------|------|------------|
| `validation_failed` | The top-level `code` when several fields fail at once. Each per-field failure carries its own `code` and `field` in `errors[]`. | Inspect `errors[]` and fix each listed `field`. |
| `field_required` | A required field is empty, null, or whitespace-only. | Provide a value for `field`. |
| `field_too_long` | Field exceeds the maximum allowed length. | Truncate to the limit documented for that endpoint. |
| `field_out_of_range` | Numeric value outside the allowed range. | Clamp the value to the documented range. |
| `field_invalid_enum` | Value is not one of the accepted enum members (e.g. unknown `currency` / `network`, a token that isn't enabled for the project, or a `network` the project has switched off on its Currencies tab). | Check the enum reference; for tokens, ensure the project has the token+network enabled. |
| `field_invalid_format` | Value doesn't match the shape the field expects — an unparseable id (e.g. `invoice_id` without the `inv_…` prefix), or an `amount` that isn't a plain decimal string such as `"10.00"`. | Send the field in its documented format; use ids returned by the API verbatim. |
| `entity_id_empty` | A supplied id is an all-zero / empty GUID (`00000000-0000-…`). `field` names the offending id — e.g. `invoice_id` or `withdrawal_id`. | Send the id returned by the API verbatim; never a zeroed or empty id. |
| `field_must_be_positive` | Decimal/integer must be greater than 0. | Send a positive value. |
| `field_percentage_out_of_range` | Percentage must be between 0 and 100 inclusive. | Clamp the value to `[0, 100]`. |
| `query_parameter_unknown` | A list request contains a query parameter that the endpoint doesn't support. | Remove the parameter or use its documented snake_case name. |
| `pagination_cursor_invalid` | A cursor is malformed, expired, tampered with, belongs to another resource, or is combined with different filters. | Restart pagination without `cursor`, then keep the same filters while following `next_cursor`. |
| `amount_too_many_decimals` | `amount` has more decimal places than it can carry. On a fiat-flow invoice the limit is the currency's minor unit (e.g. `23.345` for `USD`, `23.01` for `JPY`); on a withdrawal it is the token's precision on the chosen network (e.g. `100.0000004` for `USDT` on `TRC20`, which has six). The amount is refused, never rounded. | Round to the currency's minor unit, or to the token's precision on that network. |
| `amount_too_large` | `amount` on an invoice or withdrawal create is outside the supported range — above the 20-integer-digit ceiling the API stores. | Send an amount within the supported range. |

### Generic HTTP-status fallbacks

Returned when the failure has no specific business code attached. Each of these is a fallback for an HTTP status: the request failed, but no finer-grained code described the cause.

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `forbidden` | 403 | Authenticated, but this credential isn't allowed to perform the operation — its scope (capability) or environment doesn't permit it, for example a Sandbox key calling a Production-only endpoint. | Call with a credential whose capability and environment match the operation. Ownership is never a `403`: a resource that belongs to another merchant returns `not_found` (404), not `forbidden`. |
| `payment_key_required` | 403 | The endpoint needs a Payment (`pk_`) key, but the request used a Payout (`rk_`) key — for example, listing invoices. | Call it with a Payment key. |
| `payout_key_required` | 403 | The endpoint needs a Payout (`rk_`) key, but the request used a Payment (`pk_`) key. Every withdrawal route answers this way, and so does reading balances. | Call it with a Payout key. |
| `internal_error` | 500 | An unexpected error or bug on our side. | Retry once. If it keeps failing, contact support and include the time of the response. |
| `validation_failed` | 400 | The request failed validation, but no single field-level code applied. | Read the `detail` field — it says what was rejected. |

### Authentication

Returned only on `401 Unauthorized` responses from the HMAC authentication handler. Each code identifies a specific failure point so clients can show an actionable message instead of a generic "auth failed".

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `unauthorized` | 401 | Generic fallback when none of the specific codes below applied — typically a request to an auth-required endpoint with no `Authorization` header (or an unrecognized auth scheme). | Send a valid HMAC `Authorization` header; re-check the signing flow end-to-end. |
| `authorization_missing` | 401 | `Authorization` header value is empty (sent but blank). | Send the full `Authorization: HMAC-SHA256 {apiKeyId}:{base64signature}` header. |
| `authorization_malformed` | 401 | Header is present but malformed: scheme parses but the credentials part is missing the colon, the API key id is unparseable, or the signature is empty. | Re-build the header per [Authentication](/docs/authentication.md); a typo in the API key id is the most common cause. |
| `timestamp_missing` | 401 | `X-Request-Timestamp` is missing, or is not Unix seconds written as plain decimal digits — a sign, spaces or a leading zero are refused. | Send the current Unix time in seconds, digits only. |
| `timestamp_expired` | 401 | The timestamp is more than 5 minutes behind server time or more than 60 seconds ahead of it. | Sync your clock to NTP or measure the offset with [`GET /v1/time`](/docs/time.md); re-sign with a fresh timestamp. |
| `invalid_credentials` | 401 | The credential is unusable: unknown key id, revoked key, an environment/type prefix that doesn't match the stored key, or a signature that doesn't verify. Deliberately one code for all four — a distinguishable answer would confirm which key ids exist to a caller who holds no secret. | Check the API key id is correct and active, and that the signature is computed with the matching secret. Signing is the most common cause: verify your string-to-sign assembly (`{ts}\n{METHOD}\n{path}\n{query}\n{bodyHash}`) and that `bodyHash` is empty for empty bodies (don't hash an empty string). |
| `ip_not_allowed` | 401 | The Payout key has entries on its IP whitelist and your caller IP is not among them. A Payout key with an empty whitelist is callable from any IP, and so is every Payment key — though if the merchant's withdrawal whitelist is off, a payout from such a key gets past this check and is then refused with `403 ip_allowlist_required`. Verified after the signature passes, so a valid signature from the wrong network still fails. | Add the caller IP (or its CIDR block) to the Payout key's whitelist in the dashboard. |

### Rate limit

Returned on `429 Too Many Requests` when a request exceeds the per-merchant rate limit. The count is per merchant, in one-second windows, and your API keys share one budget rather than each holding its own. A separate limit sits in front of the API and applies per IP address — a different ceiling from the one this code reports.

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `rate_limited` | 429 | The per-merchant rate limit was exceeded. Two limits apply: a global cap on every endpoint (default 30 req/sec) and a stricter cap on invoice creation — `POST /v1/invoices` (default 5 req/sec). The response says which one tripped and at what limit. | Read the `Retry-After` header (the back-off in seconds) and retry after it elapses; most HTTP clients with retry middleware honor it automatically. |

### Transport / framework

Returned by the framework error handler when the request never reached a regular handler.

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `malformed_request` | 400 | Request body could not be parsed — malformed JSON. | Verify the body is valid JSON. |
| `payload_too_large` | 413 | Request body exceeds the 1 MiB limit. | Send a smaller payload; split large workloads across separate requests. |
| `unsupported_media_type` | 415 | `Content-Type` is not `application/json`. | Set `Content-Type: application/json`. |
| `method_not_allowed` | 405 | The path exists, but not for this HTTP method. | Use the method documented for the endpoint (e.g. `POST` to create). |
| `route_not_found` | 404 | No endpoint matches the request path. | Re-check the URL against the API reference. |

### Invoices

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `invoice_not_found` | 404 | Invoice id resolves to nothing, or to an invoice that belongs to a different merchant. | Use the id returned by `POST /v1/invoices` verbatim. |
| `project_not_found` | 404 | `project_id` on a create-invoice request resolves to nothing visible to the caller. | Use a project id from the dashboard (must be active and owned by the same merchant as the API key). |
| `invoice_terminal` | 410 | Confirm-payment was called on an invoice that's `expired` or `cancelled`. (A `paid` invoice returns `invoice_not_awaiting_client`, 409 — not this code.) | Refresh invoice status; nothing more to do. |
| `invoice_deadline_passed` | 410 | Confirm-payment was called after the invoice's deadline. | Create a new invoice. |
| `requested_amount_invalid` | 400 | The invoice `amount` is missing, malformed, outside the supported decimal range, or not positive. | Send `amount` as a positive decimal string with the selected `currency` and optional `network`. |
| `invoice_amount_below_minimum` | 400 | Computed USD value of the invoice is below the per-token / per-network minimum from the catalog. | Increase the amount above the minimum surfaced for the chosen token. |
| `deposit_amount_exceeds_limit` | 400 | Computed USD value of the expected payment exceeds the merchant's per-deposit `MaxDepositAmount` policy cap. Bounds the quoted invoice amount at confirm, not actual on-chain receipts. | Reduce the invoice amount, or contact support to raise the limit. |
| `currency_not_enabled_for_project` | 400 | The crypto currency requested isn't in the project's enabled-tokens list — or it is, but every network that carries it is switched off on the project's Currencies tab. | Enable the currency on the project and leave at least one of its networks on, or pick one from the project's `enabled_tokens`. |
| `tokens_required` | 409 | Invoice creation or payment confirmation on a project that has no enabled tokens at all. | Enable at least one token on the project in the dashboard. |
| `invoice_idempotency_conflict` | 409 | Invoice creation reused an `external_order_id` whose invoice was created for another amount, currency, network or environment. Nothing is created. | Send the original request unchanged to get that invoice back, or use a new `external_order_id` for a new order. |
| `acceptance_disabled` | 503 | The requested token is not being accepted right now. | Use a different token, or try again later. |
| `exchange_rate_unavailable` | 503 | Rate provider has no fresh quote for the token / fiat pair. | Retry; rates refresh on a short interval. |
| `network_unavailable` | 503 | Auto-confirmation could not use the chosen network. | Drop the auto-confirm; let the customer pick a token via the hosted page. |
| `payment_method_unavailable` | 503 | The chosen token / network cannot take a payment at the moment. Replaced `no_available_address`, `address_lease_failed` and `bridge_unavailable` on 2026-08-23. | Retry, or let the customer pick a different token or network. |
| `client_confirmation_failed` | 409 | Customer-side confirmation (token selection, auto-confirm) was rejected. The `detail` field contains the specific reason. | Inspect `detail`; common cases: invoice already past `awaiting_client`, token not allowed. |
| `invoice_not_awaiting_client` | 409 | Confirm-payment was called on an invoice that's no longer in `awaiting_client`. | Refresh invoice status; the customer already moved past selection. |
| `invoice_cannot_be_cancelled` | 409 | Invoice is no longer in `awaiting_client` — the customer already selected a token, or it is already paid or expired. Re-cancelling an already-cancelled invoice returns `200`, not this error. | Inspect current status; cancellation only works in `awaiting_client`. |
| `simulate_payment_failed` | 409 | Sandbox-only `simulate_payment` was rejected. | Inspect `detail`; usually wrong status or amount mismatch. |
| `not_sandbox` | 403 | `simulate-payment` (the sandbox simulator) was called on a production invoice. | Only sandbox invoices can be simulated — use a sandbox invoice, or drive a live invoice with a real on-chain payment. |

### Widget invoices

Returned by `POST /public/v1/sdk` (Low-Code SDK invoice creation).

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `widget_key_missing` | 401 | `X-Sdk-Key` header is absent or empty on an endpoint that requires widget auth. | Send the public SDK key in `X-Sdk-Key`. |
| `widget_key_invalid` | 401 | `X-Sdk-Key` is present but doesn't parse as a Paymos API key id. | Copy the key from the dashboard; check for stray whitespace, a wrong prefix, or accidental URL-encoding. |
| `widget_key_not_found` | 401 | Key parses but resolves to no active credential — revoked, deleted, or not a Payment-typed key (Payout keys can't drive the widget). | Use an Active Payment key from the dashboard. |
| `widget_inactive` | 403 | The target project's widget is switched off, or its widget was never initialized. | Turn **SDK enabled** back on in the **Integration** card on the project's **Low-Code** page. |
| `terminal_not_enabled` | 403 | Request carried `source=terminal`, but the project's integration method is not Terminal. | Create a Terminal project, or send `source=embed` from a Low-Code project. |
| `embed_not_enabled` | 403 | Request carried `source=embed`, but the project's integration method is not Low-Code. | Use the project's own integration method, or create a Low-Code project. |
| `origin_not_allowed` | 401 | The browser `Origin` doesn't match an allowed-origin policy. The check runs twice — the auth layer matches the origin against the union of every active-widget project on the key, the handler then matches it against the exact target project — and both answer with the same `401`, the same code and the same detail. That is deliberate: a different status from the second check would tell a caller holding the (public, scrapable) `pk_` key that the origin is registered on some *other* project of the same merchant, letting staging hosts and unreleased brands be enumerated one probe at a time. | Add the domain to the widget's allowed origins. Same-origin requests — e.g. a POS terminal served from the same host — skip the check. |

### Payment channels

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `payment_channels_disabled` | 503 | Payment channels are not enabled for your account in this environment. Every channel and deposit route answers this, including the sandbox simulator. | Contact support to be enabled. Retrying will not change the answer. |
| `payment_channel_not_found` | 404 | The `pc_` id resolves to nothing this credential may see — it does not exist, belongs to another merchant, lives in the other environment, or sits in a project outside the key's scope. All four are indistinguishable on purpose. | Use the id returned by `POST /v1/payment-channels` verbatim, signed with a key for the same environment and project. |
| `payment_channel_deposit_not_found` | 404 | The `pcd_` id resolves to nothing this credential may see. Same four causes as above. | Use the id from the webhook payload or the polling feed verbatim. |
| `payment_channel_external_id_invalid` | 400 | `external_id` is empty, blank, or longer than 128 characters — on creation or as a list filter. | Send a non-blank identifier of at most 128 characters. |
| `payment_channel_project_has_no_supported_tokens` | 409 | The project enables no token a payment channel can collect, so the channel would have no route at all. | Enable at least one supported token on the project, then create the channel. |
| `payment_channel_simulation_sandbox_only` | 403 | `simulate-deposit` was called on a production channel. | Simulation exists only for sandbox channels. Drive a production channel with a real transfer. |
| `payment_key_required` | 403 | The request was signed with a Payout (`rk_`) key. Channel access rides the Payment key, exactly as withdrawals ride the Payout key. | Sign with a `pk_` key of the same environment. |
| `pagination_cursor_invalid` | 400 | A list or feed cursor is malformed, older than 24 hours, or bound to different filters — for the feed, also to a different merchant, environment or project scope. | Restart from the first page. `pcd_` deduplication makes a full resynchronization harmless. |
| `currency_not_enabled_for_project` | 400 | The token passed to `simulate-deposit` is not in the project's enabled-tokens list. | Enable it on the project, or pick one the channel already returns in its `networks[].tokens`. |
| `acceptance_disabled` | 503 | Collection for the requested token is currently disabled platform-wide. | Use a different token, or wait until acceptance is re-enabled. |
| `amount_too_many_decimals` | 400 | The simulated amount has more decimals than the token supports. Merchant money is never silently rounded into a different number. | Round the amount to the token's precision before sending it. |

### Withdrawals

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `withdrawal_not_found` | 404 | Withdrawal id resolves to nothing, or to a withdrawal that belongs to a different merchant. | Use the id returned by `POST /v1/withdrawals` verbatim. |
| `insufficient_balance` | 409 | Merchant's available balance is below `amount + fee`. | Top up, or reduce the withdrawal amount. |
| `whitelist_required` | 403 | The destination address isn't whitelisted for the chosen network. (The network supports whitelisting; this specific address hasn't been added yet.) Returned while the merchant's whitelist is on, which is the default. | Add the address to your whitelist, then retry. |
| `ip_allowlist_required` | 403 | The merchant's withdrawal whitelist is off, and the live Payout key that signed the request has no IP whitelist. Without the address whitelist, a payout key may pay only from IPs listed on it. No withdrawal is created. | Add your servers' IPs to the Payout key's IP whitelist in the dashboard, then retry. |
| `destination_address_invalid` | 400 | Address fails the network's format / checksum validation. | Recheck the address against the network's spec. |
| `withdrawal_amount_below_minimum` | 400 | Amount is below the policy minimum for the chosen token. | Increase the amount above the minimum (returned in `detail`). |
| `amount_too_many_decimals` | 400 | `amount` has more decimal places than the token has on the chosen network — `100.0000004` for `USDT` on `TRC20`, which has six. Nothing is held or created, and the amount is never rounded to fit. | Round the amount to the token's precision on that network and send it again. |
| `withdrawal_quota_exceeded` | 409 | Active-withdrawals count reached the merchant's policy limit. | Wait for in-flight withdrawals to settle, or contact support to raise the limit. |
| `withdrawal_amount_exceeds_limit` | 400 | The USD-equivalent of the withdrawal principal exceeds the merchant's per-transaction `MaxWithdrawalAmount` policy cap. | Reduce the amount, or contact support to raise the limit. |
| `exchange_rate_unavailable` | 503 | The merchant has a `MaxWithdrawalAmount` cap set, but no fresh token/USD quote was available to check the principal against it — the limit can't be verified, so the request fails closed. | Retry; rates refresh on a short interval. |
| `withdrawal_not_enabled` | 400 | Paymos doesn't pay out the token/network pair you asked for. `detail` names the pair — e.g. `Withdrawals are not available for USDT on TRC20.` Which pairs are payable is our configuration, not yours. | Withdraw the token on a network Paymos pays out on, or contact support to ask about the pair you need. |
| `withdrawal_cannot_be_cancelled` | 409 | Withdrawal is past the state where it can be cancelled. | Inspect the current status. The window closes when execution starts — still inside `created`, before anything is signed. |
| `withdrawal_simulation_failed` | 409 | Sandbox-only `simulate_completion` was rejected. | Inspect `detail`. |
| `merchant_suspended` | 403 | The merchant account is suspended for outbound flows, so no withdrawal can be created. | Contact support to lift the suspension. |
| `outbound_frozen` | 503 | Outbound transfers are frozen — either platform-wide, or for the specific network and token you're withdrawing in. | Wait for the freeze to clear, try a different network/token, or contact support. |
| `withdrawal_network_unavailable` | 503 | Payouts of this token on this network can't be settled right now. | Try a different destination network, or retry later. |

### Project state

Returned by invoice creation when the project can't issue invoices in its current state. A payment confirmation made with your API key on an invoice in such a project gets `404` instead, since an API key can confirm only in projects that are active.

| Code | HTTP | When | What to do |
|------|------|------|------------|
| `project_state_invalid` | 409 | The project isn't `Active` — it's `Suspended` or `Archived`. The response doesn't say which. | Check the project's status in the dashboard. An archived project has a **Restore project** action on its own page; a suspended one is lifted by Paymos, so contact support. Or send the request to a project that's already active. |
