# Errors and refusals

> The error envelope, what each error code means, whether any money moved, and what to do next. The page to keep open while you integrate.

## The envelope

Every error from the gateway has the same shape:

```json
{ "error": { "code": "POLICY_DENIED", "message": "denied by rule: price > 20", "details": { "matchedRule": "price > 20" } } }
```

Handle errors by `code`. The `message` is written for a person and can change between releases. An unexpected server error returns only `"internal error"` and an `errorId`. Quote the `errorId` when you ask for help.

## Refusals while buying

These are the platform answering a purchase, not faults. **In every row except the last, no money moved.**

| Code | HTTP | Means | Do this |
|---|---|---|---|
| `POLICY_DENIED` | 403 | a workspace rule, the daily budget, or a missing *allow* rule said no | **don't retry.** Pick another capability, or change the policy |
| `APPROVAL_REQUIRED` | 403 | a rule needs a person; the response has an `approvalId` | stop and report the id. A person approves in the console, then retry **once** |
| `APPROVAL_DENIED` | — | a person refused this spend (MCP tools) | don't retry, and don't ask again |
| `PRICE_ABOVE_CLIENT_LIMIT` | — | the price is above your own `maxPriceMinor`. Your client refused before signing | raise your ceiling, or pick something cheaper |
| `INSUFFICIENT_FUNDS` | 402 | the wallet's available balance is below the price | stop, and [fund the wallet](https://docs.zanora.dev/buyers/funding.md) |
| `WALLET_FROZEN` | 403 | the wallet is frozen | unfreeze it (`POST /v1/wallets/:id/unfreeze`) |
| `WALLET_PROVIDER_PAUSED` | 429 | three seller failures in a row (5xx, thrown handler, MCP `isError` or timeout) paused them for this wallet. 4xx responses don't count | wait, or [lift the pause](https://docs.zanora.dev/api/wallets.md#post-v1-wallets-id-resume-provider) |
| `CHALLENGE_EXPIRED` | 410 | the signed price quote expired before payment | call again. The SDKs do this for you |
| `REPLAY_DETECTED` | 409 | this payment proof was already used | nothing: the first use counted. Don't resend proofs |
| `CANCELLED` | — | you aborted before the payment proof was sent | retry freely. Nothing was charged, and the SDK can only promise that for this code |
| a `failed` receipt | 200 | you paid, the seller's handler failed, and you were **refunded** | the refund is a new ledger entry. Consider rating the seller |

> **Note — Cancelling after payment:**
>
> Once the payment proof has been sent, cancelling no longer stops the purchase. The seller is already running, and dropping the connection would only throw away an answer you paid for. The call completes and appears in your purchase history.

## Errors while integrating

| Code | HTTP | Usually |
|---|---|---|
| `VALIDATION_FAILED` | 400 | an amount sent as a number instead of a string, a missing required field, or an unknown enum value. The message names the field |
| `UNAUTHORIZED` | 401 | a missing, expired, revoked or mistyped key. Run `GET /v1/whoami` |
| `FORBIDDEN` | 403 | the key is missing a scope (the message names it), or the record isn't yours |
| `NOT_FOUND`, `CAPABILITY_NOT_FOUND`, `PROVIDER_NOT_FOUND`, `WALLET_NOT_FOUND` | 404 | a wrong id, or one owned by another tenant |
| `NOT_SUPPORTED` | 501 | this deployment has no rail for that request, e.g. card top-ups without a card processor. Ask `GET /v1/rail/providers` first |
| `INSECURE_TRANSPORT` | 403 | the request reached the gateway over plain HTTP. Use `https://` |
| `RATE_LIMITED` | 429 | too many requests for this key. Wait for the `retry-after` header |

## Errors about money moving in or out

| Code | HTTP | Means |
|---|---|---|
| `DEPOSIT_HELD` | 409 | a USDC deposit arrived while USDC was outside ±0.5% of $1. It's held, not credited at a guessed rate, and is retried automatically |
| `DESTINATION_COOLING_DOWN` | 409 | the payout destination changed less than 24 hours ago. Payouts resume on their own after that |
| `SANCTIONED_ADDRESS` | 403 | sanctions screening blocked the payout destination |
| `TREASURY_UNAVAILABLE` | 503 | a pricing or screening service is down, so the payout is paused rather than guessed. Retry later |
| `COVERAGE_SHORTFALL` | 503 | payouts are paused while the platform reconciles its holdings. Retry later |
| `DUPLICATE_EXTERNAL_REF` | 409 | this deposit was already credited. Duplicates are ignored |
