Core concepts
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:
{ "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 maxPriceMicros. 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 |
WALLET_FROZEN | 403 | the wallet is frozen | unfreeze it (POST /v1/wallets/:id/unfreeze) |
ACCOUNT_FROZEN | 403 | Zanora has frozen the account the wallet or key belongs to. Reads still work | don't retry. Tell your user; the console says why |
ACCOUNT_CLOSED | 403 | the account was closed | stop. A closed account can't be reopened |
WALLET_KEY_COOLING_DOWN | 409 | the wallet's signing key was replaced less than 24 hours ago. The message says when it can pay | wait. If nobody on your side replaced it, revoke your API keys and replace it again |
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 |
CHALLENGE_EXPIRED | 410 | the signed price quote expired before payment | call again. The SDKs do this for you |
INVALID_PAYMENT_PROOF | 400 | the payment proof was malformed, its signature didn't verify against the wallet's registered key, it didn't match the challenge's terms, or the challenge's nonce was unknown or expired | get a fresh challenge and sign again. If it keeps failing, check that the private key matches the wallet's public key |
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 |
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 |
MONEY_UNIT_CHANGED | 400 | an amount named the way it was before micro-dollars (priceMinor, amountMinor, …, which were cents). details.use names the …Micros field to send. Upgrade the Zanora package that sent it |
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, or a preview rail (ach, bank_transfer) it hasn't turned on. Ask GET /v1/rail/providers first. Also returned when you try to buy a package capability (payable: false), which can't be paid for yet; nothing was charged |
INSECURE_TRANSPORT | 403 | the request reached the gateway over plain HTTP. Use https:// |
RATE_LIMITED | 429 | too many requests for this key, or too many failed authentications from your address (the message says which). Wait for the retry-after header; for the second, fix the key first |
Paying with a generic x402 client
What a seller who accepts any x402 client answers a buyer paying in USDC on Base. The middleware sends these; a seller's own code never has to.
| Code | HTTP | Means | Do this |
|---|---|---|---|
X402_PAYMENT_INVALID | 402 | the facilitator refused the payment: a bad signature, the wrong amount or payee, an expired or reused authorization, or not enough USDC. Nothing moved | sign a fresh payment against the new offer |
X402_SETTLEMENT_FAILED | 402 | the payment verified, but the facilitator refused to settle it after the call ran. The answer was withheld and nothing was charged | sign a fresh payment against the new offer |
X402_SETTLEMENT_PENDING | 503 | the payment was submitted on-chain and isn't confirmed yet, so the answer was withheld. It may still settle | don't sign a new payment for this call: that could pay twice. No new offer comes with it |
X402_NOT_ACCEPTED | 403 | this seller doesn't take x402 payments | pay with a Zanora wallet |
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 |
LIMIT_EXCEEDED | 429 | a money limit said no: the largest single top-up or payout, the amount per 24 hours, or how many top-ups you can start per hour. The message names the limit and, for a 24-hour cap, how much is left |
PAYOUT_HELD | 409 | the payout asks for earnings held because a buyer who paid them is disputing their card payment. They're released when the dispute is decided in the platform's favour |
FUNDS_CLEARING | 409 | the payout asks for earnings that are still in the clearing period. They become available on their own |