Errors
Every failure has the same shape, allowing you to branch on stable error types across versions.
Every failure has the same shape. Branch on type — it is stable across
versions, where the HTTP status and the prose are not.
{
"type": "RATE_DRIFT_EXCEEDED",
"status": 400,
"detail": "Refusing to send: quoted 3384.65 but you expected ~9999 (6615 bps of drift, limit 200). Nothing was sent.",
"resolution": "Nothing was sent. Re-quote, show the payer the new amount, and send again.",
"requestId": "req-1c",
}resolution says what to do, when there is a specific answer. It is not on
every error — treat it as optional and fall back to detail, which is always
present. (An earlier version of this page listed exactly which types omit it;
the list was incomplete, and an incomplete enumeration is worse than saying
"optional".)
requestId is in the body of every error, and on a SUCCESSFUL response it
is the x-request-id header rather than a body field. Log the header and
you have it for every request either way. (This previously claimed the body
carried it on success; it does not.)
Validation failures add errors, a list of the fields that failed. detail
stays a string in every case, so detail.toLowerCase() is always safe.
Request errors
type | Status | What happened | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | A field is missing or malformed | Fix the fields in errors and retry |
UNAUTHORIZED | 401 | Key missing, wrong, revoked, or used on an endpoint keys cannot reach | Check it was copied whole and is not revoked |
FORBIDDEN | 403 | Valid key, wrong organization | Check AVVIO_ORG_ID |
NOT_FOUND | 404 | No such payout or beneficiary | Check the id came from us |
RATE_LIMITED | 429 | Too many requests | Back off, then retry |
BAD_REQUEST | 400 | A request we understood but cannot carry out — an expired quote, an amount above the corridor maximum, a non-positive amount | Read detail; it names the specific condition. Never retryable unchanged |
PROVIDER_REJECTED | 400 | The payout network refused the request — most often an amount below that corridor's minimum. Nothing was submitted | Read detail; it carries the network's own wording, e.g. the minimum amount for this payment is $10 USD. Change the request. Retrying it unchanged fails identically |
FUNDING_TRANSACTION_INVALID | 400 | We read the chain and the transaction does not fund this payout — reverted, wrong token, wrong address, short, or from a wallet other than the registered one. Nothing was recorded | Read detail; it names which. The payout is still fundable, so send the correct transaction and confirm that |
FUNDING_NOT_YET_VERIFIABLE | 409 | We could not read the transaction yet — not mined, or we could not reach the chain. Nothing was recorded | Retry the same request once it is mined. If you already paid, your funds are unaffected |
FUNDING_TRANSACTION_ALREADY_USED | 409 | That transaction already funded a different payout. The deposit address is shared between payouts, so one transfer funds exactly one | Send a separate transfer for this payout. detail names the payout it already funded |
PAYOUT_NOT_FUNDABLE | 400 | The payout is cancelled or already finished, so it cannot be funded. Nothing was recorded | Do not retry. Read the payout; if you still owe the recipient, create a new one |
BENEFICIARY_EXTERNAL_ID_CONFLICT | 409 | That externalId already identifies a beneficiary with different account details. Nothing was changed | Use a new externalId for a different account. If this was a retry, read the existing beneficiary — an externalId identifies one account, and a second account is a second externalId |
CONFLICT | 409 | The payout's funding state changed under you — it is already funded with a different transaction, or a concurrent request won. Nothing was recorded | Re-read the payout before retrying. If it is already funded, you are done |
ACCOUNT_BLOCKED | 403 | This organization is suspended | Contact us; retrying will not help |
INSUFFICIENT_BALANCE | 400 | Your balance will not cover this payout. Nothing was sent | Top up, then retry. Branch on this type rather than parsing the message — it is the one condition a payroll run must handle |
ORDERS_TEMPORARILY_UNAVAILABLE | 503 | We could not read the full payout list, so we will not report a partial page as complete | Retry. If you passed a cursor we did not issue, that is the likeliest cause |
CORRIDOR_UNAVAILABLE | — | Raised by the Node client, not the API: the corridor you asked about is not offered on your routing | Read the corridors call and pick one it lists |
TIMEOUT | 504 | Raised by the Node client, not the API: no response within the client's timeout. The outcome is unknown — if this was a send, the payout may exist | Retry with the same Idempotency-Key; a replay returns the original. Never start over with a new key |
NETWORK_ERROR | 502 | Raised by the Node client, not the API: the request never got a response — DNS, TLS, a dropped connection. The outcome is unknown unless you know it never left | Retry with the same Idempotency-Key, then read the payout back |
INTERNAL | 500 | Ours | Retry with the same Idempotency-Key. Send us the requestId if it persists |
BAD_REQUEST is the catch-all for a 400 that is not a field-validation failure.
Because it covers several conditions, it is the one type where you should read
detail — it names the specific condition. None of them is retryable
unchanged.
There is no retryable field on the wire. An earlier version of this page told
you to branch on one; the Node client derives it for you, but over raw HTTP the
type is what you branch on.
Idempotency
type | Status | Meaning | What to do |
|---|---|---|---|
IDEMPOTENCY_KEY_REQUIRED | 400 | No header on a mutation | Add one, unique per operation |
IDEMPOTENCY_KEY_INVALID | 400 | Malformed | 1–255 chars of A-Z a-z 0-9 _ . : -. A UUID works |
IDEMPOTENCY_KEY_CONFLICT | 409 | Same key, different body | Do not retry. This is a bug on your side — a different request needs a different key |
IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS | 409 | An identical request is still running | Back off, retry the same key |
IDEMPOTENCY_UNAVAILABLE | 503 | We could not record it. Nothing executed | Retry the same key |
DUPLICATE_REQUEST_DETECTED | 409 | An identical request arrived under a different key seconds ago. Nothing executed | See below |
A 4xx releases the key — a request that failed validation committed
nothing, so you may correct the body and reuse it.
DUPLICATE_REQUEST_DETECTED
DUPLICATE_REQUEST_DETECTEDThe key protects you only if your retry sends the same key. Some HTTP clients
generate one per attempt, which defeats it silently: every retry looks like a new
request, and every retry pays. So we watch a second signal — same body, different
key, within 15 minutes — and refuse.
{
"type": "DUPLICATE_REQUEST_DETECTED",
"originalIdempotencyKey": "zz_advance_88213",
"originalPayoutId": "pay_01J…",
"detail": "An identical request was received in the last 15 minutes under a different Idempotency-Key…",
}This is not "already paid". Nothing was executed. Reading it as a success and
marking the wage settled is the one wrong move, and it leaves a worker unpaid
with your ledger saying otherwise.
Two ways forward, and you have to pick one — we will not guess:
Send it again with the value of originalIdempotencyKey as your Idempotency-Key header. That is the key the first attempt used, so this replays it: you get the original payout back and nothing is sent twice.
# the 409 gave you: "originalIdempotencyKey": "zz_advance_88213"
curl -s -X POST ".../payouts" \
-H "idempotency-key: zz_advance_88213" \ # <- that value, as the header
-H "content-type: application/json" \
-d '{ ...the same body... }'originalIdempotencyKey is a field we send to you, not one you send back. Putting it in the request body is rejected — request bodies reject unknown properties.
Add X-Allow-Duplicate: true and send again. This sends a second real payment. Only take this branch if you are certain the first one was intended too.
The window is 15 minutes, and that is a real boundary. It covers a crashed
job that requeues on a backoff — the realistic incident. It is deliberately not
the full 7-day retention: content matching cannot tell a retry from a genuine
repeat, and two advances of the same amount to the same worker in one week are
ordinary payroll. At 7 days every routine repeat would be refused and you would
end up sending X-Allow-Duplicate unconditionally, which removes the protection
while appearing to strengthen it.
Send a unique reference per logical payment and this can never false-positive
at any window length — a different reference is a different body. That, plus
persisting your own idempotency key, is the durable protection. This guard is a
net for the accidental case, not a substitute for either.
We refuse rather than silently returning the first payout, because both
readings are common. Two advances of the same amount to the same worker in one
week is ordinary payroll; replaying there would mean the second one never goes
out while your ledger records that it did. A 409 you have to answer is recoverable.
A payment that quietly evaporates is not.
How long a key is remembered
Seven days, and the window is about storage, not correctness. There is no
"the key expired, so we ran it again" path: while we hold the record it is
authoritative, and an old key retried against it replays rather than re-executes.
A TTL that quietly re-arms a key is a double payment on a timer.
Past seven days the record is deleted and the key is genuinely unknown to us — so
treat seven days as the outer bound on retrying, not on caring. If you are
reconciling something older, read the payout by id.
Signed requests
akid_* credentials authenticate by signature, not by presenting a bearer
secret. The complete key id identifies you; the signature proves you hold
the private half, which we never had and cannot leak.
There are two ways to do it, and the first needs no library and no
canonicalisation.
Ask us what to sign. Send the request with no signature. You get 202
with payloadToSign and requestId, and nothing has happened yet. Sign
payloadToSign with your private key — ECDSA P-256 over SHA-256 — and send the
same request again with X-Anzo-Signature and X-Anzo-Request-Id. That is the
whole integration. The challenge is single-use and bound to that exact request,
including its Idempotency-Key, so it cannot be spent on another one.
Or sign the request yourself with X-Anzo-Signature, X-Anzo-Timestamp and
X-Anzo-Nonce, which saves a round trip and is worth it for a payroll run of
thousands. @avvio/payments does this for you.
type | Status | Meaning |
|---|---|---|
SIGNATURE_CHALLENGE | 202 | Not an error. Sign payloadToSign, send the request again |
SIGNATURE_CHALLENGE_EXPIRED | 401 | Ask again — challenges are short-lived |
type | Status | Meaning |
|---|---|---|
SIGNATURE_REQUIRED | 401 | This key signs its requests. Send X-Anzo-Signature, X-Anzo-Timestamp and X-Anzo-Nonce |
SIGNATURE_INVALID | 401 | The signature does not match this request |
SIGNATURE_REPLAYED | 401 | This exact request was already received. Nothing ran twice |
SIGNATURE_TIMESTAMP_SKEW | 401 | Your clock is more than 5 minutes from ours |
SIGNATURE_TIMESTAMP_INVALID | 401 | X-Anzo-Timestamp is not a Unix timestamp in seconds |
PUBLIC_KEY_INVALID | 400 | Registration only: not a P-256 SPKI public key |
KEY_RETIRED | 401 | This legacy key is no longer accepted. Issue a signed replacement |
KEY_EXPIRED | 401 | Keys expire. Rotate before the deadline — the successor overlaps the predecessor |
KEY_IP_NOT_ALLOWED | 401 | This key is pinned to source addresses and this request came from another |
SIGNATURE_INVALID is deliberately one answer for several causes — a wrong
key, an unreadable key, a tampered request. Telling them apart only helps
somebody guessing. In practice it is almost always one of two things: the body
was re-serialized after signing (sign the exact bytes you send), or a proxy
rewrote the path, which is part of what you signed.
SIGNATURE_TIMESTAMP_SKEW names the clock because that is what it nearly
always is. Check NTP on the sending machine before suspecting the key.
SIGNATURE_REPLAYED means the nonce has been used. Every request needs its own
— a UUID per call is fine. This is the guard Idempotency-Key cannot provide,
because you choose that value and so could anyone who captured your request.
Sending
type | Status | Meaning |
|---|---|---|
DESTINATION_ACCOUNT_NOT_FOUND | 404 | No payout account with that id belongs to your organization. Nothing was sent |
RATE_DRIFT_EXCEEDED | 400 | The quote moved further from expectDestination than you allowed. Nothing was sent |
QUOTE_UNVERIFIABLE | 400 | We could not compare the quote to your expectation. Nothing was sent |
EXACT_OUTPUT_UNSUPPORTED | 400 | This routing cannot lock the receiving amount. Check capabilities.exactOutput on the corridors call |
PAYOUT_NOT_CANCELABLE | 400 | Only a payout still awaiting your funds can be cancelled. Do not retry |
INSUFFICIENT_SCOPE | 403 | This key is read-only. Issue one with the write scope to move money |
INDICATIVE_PRICING_UNAVAILABLE | 400 | This routing publishes no price without a beneficiary. Do not retry — check capabilities.indicativePricing and price against a real beneficiary |
DESTINATION_ACCOUNT_NOT_FOUND is the guard against paying an id you did not
get from us. A stale, typo'd, or copied-from-elsewhere account id is refused
before anything is priced — rather than being sent, settling, and reporting
completed to a payroll run where nobody received the money.
INDICATIVE_PRICING_UNAVAILABLE
INDICATIVE_PRICING_UNAVAILABLEGET /rates shows a price before a beneficiary exists — "you send $200, they get
3,410 MXN" while your user is still typing. Not every routing publishes one.
This is a permanent property of how your organization is routed, not an outage,
so retrying will never succeed. Read capabilities.indicativePricing on the
corridors call and, when it is false, skip straight to creating the beneficiary
and pricing against it.
It used to surface as a 501 typed INTERNAL advising "retry with the same
Idempotency-Key" — retry advice for a GET, on a condition that never changes.
Payout links
type | Status | Meaning | What to do |
|---|---|---|---|
PAYOUT_LINK_UNUSABLE | 400 | The link is expired, already spent, or failed at execution | Mint a new one. Links are single-use by design |
A link that was already spent successfully is not an error: a repeat submit
returns the original payout with status: "already_submitted", so a recipient
who double-taps gets what they already have.
A 404 on a link route covers expired, spent, forged and never-existed alike —
a stranger probing links learns nothing from the difference.
When a payout fails
A payout that was accepted and later failed is not an error response — it is
a payout whose status is failed, carrying a failureCode.
failureCode | What happened | Is the money back? | What to do |
|---|---|---|---|
returned_by_bank | It settled, then the receiving bank returned it | Yes | Tell your user. Reverse whatever you credited |
account_invalid | The account details are wrong | Yes | Ask for correct details, create a new beneficiary |
account_cannot_receive | The account cannot accept this payment | Yes | Try another account or corridor |
compliance_rejected | Refused by compliance screening | Not automatically | Contact us with the payoutId. Do not retry |
limit_exceeded | Above a corridor or account limit | Yes | Split it, or check limits on the corridors call |
quote_expired | Too long between quoting and sending | Yes | Re-quote and send again |
authorization_not_completed | An authorisation step was not finished | Yes | Start again |
execution_failed | It did not go through, cause not established | Check fundsReturned | Safe to retry with a new idempotency key |
unknown | We do not have a specific cause | Check fundsReturned | Contact us with the payoutId |
Read fundsReturned, not the code. It is the only field that answers "is
the money back in my balance?", and it is absent when we do not yet know — which
is deliberately not the same as false. Do not re-credit a user on a code alone.
The one case where it stays absent: compliance_rejected. Those funds are
held pending a human review, so there is no automatic answer to give and we will
not invent one. Absent here means "ask us", not "not yet" — contact us with the
payoutId. Your ledger can still settle the question without waiting: the
balance history shows the debit with no matching reversal entry, which is
the positive statement that the money did not come back.
New failure codes are added without a major version. Treat an unrecognised one
as execution_failed.
What the sandbox can and cannot produce
Only three failure codes are reachable in sandbox — account_invalid (0001),
compliance_rejected (0004) and returned_by_bank (0003). The rest
(limit_exceeded, account_cannot_receive, authorization_not_completed,
execution_failed, quote_expired, unknown) come from live rails only.
stage is likewise live-only and never appears on a sandbox payout.
So do not treat a sandbox run as proof your failure handling is complete.
Write the switch for every code in the table, and make the default branch behave
like execution_failed — you cannot test the others before go-live.
The one that surprises people
completed → failed with returned_by_bank happens after you were told the
payout succeeded, sometimes days later.
Keep processing webhooks for a payout after it completes, and do not write a
ledger that treats completed as immutable. Trigger it on demand in sandbox
with an account number ending 0003.
Updated about 2 hours ago
