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

typeStatusWhat happenedWhat to do
VALIDATION_ERROR400A field is missing or malformedFix the fields in errors and retry
UNAUTHORIZED401Key missing, wrong, revoked, or used on an endpoint keys cannot reachCheck it was copied whole and is not revoked
FORBIDDEN403Valid key, wrong organizationCheck AVVIO_ORG_ID
NOT_FOUND404No such payout or beneficiaryCheck the id came from us
RATE_LIMITED429Too many requestsBack off, then retry
BAD_REQUEST400A request we understood but cannot carry out — an expired quote, an amount above the corridor maximum, a non-positive amountRead detail; it names the specific condition. Never retryable unchanged
PROVIDER_REJECTED400The payout network refused the request — most often an amount below that corridor's minimum. Nothing was submittedRead 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_INVALID400We 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 recordedRead detail; it names which. The payout is still fundable, so send the correct transaction and confirm that
FUNDING_NOT_YET_VERIFIABLE409We could not read the transaction yet — not mined, or we could not reach the chain. Nothing was recordedRetry the same request once it is mined. If you already paid, your funds are unaffected
FUNDING_TRANSACTION_ALREADY_USED409That transaction already funded a different payout. The deposit address is shared between payouts, so one transfer funds exactly oneSend a separate transfer for this payout. detail names the payout it already funded
PAYOUT_NOT_FUNDABLE400The payout is cancelled or already finished, so it cannot be funded. Nothing was recordedDo not retry. Read the payout; if you still owe the recipient, create a new one
BENEFICIARY_EXTERNAL_ID_CONFLICT409That externalId already identifies a beneficiary with different account details. Nothing was changedUse 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
CONFLICT409The payout's funding state changed under you — it is already funded with a different transaction, or a concurrent request won. Nothing was recordedRe-read the payout before retrying. If it is already funded, you are done
ACCOUNT_BLOCKED403This organization is suspendedContact us; retrying will not help
INSUFFICIENT_BALANCE400Your balance will not cover this payout. Nothing was sentTop up, then retry. Branch on this type rather than parsing the message — it is the one condition a payroll run must handle
ORDERS_TEMPORARILY_UNAVAILABLE503We could not read the full payout list, so we will not report a partial page as completeRetry. If you passed a cursor we did not issue, that is the likeliest cause
CORRIDOR_UNAVAILABLERaised by the Node client, not the API: the corridor you asked about is not offered on your routingRead the corridors call and pick one it lists
TIMEOUT504Raised 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 existRetry with the same Idempotency-Key; a replay returns the original. Never start over with a new key
NETWORK_ERROR502Raised 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 leftRetry with the same Idempotency-Key, then read the payout back
INTERNAL500OursRetry 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

typeStatusMeaningWhat to do
IDEMPOTENCY_KEY_REQUIRED400No header on a mutationAdd one, unique per operation
IDEMPOTENCY_KEY_INVALID400Malformed1–255 chars of A-Z a-z 0-9 _ . : -. A UUID works
IDEMPOTENCY_KEY_CONFLICT409Same key, different bodyDo not retry. This is a bug on your side — a different request needs a different key
IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS409An identical request is still runningBack off, retry the same key
IDEMPOTENCY_UNAVAILABLE503We could not record it. Nothing executedRetry the same key
DUPLICATE_REQUEST_DETECTED409An identical request arrived under a different key seconds ago. Nothing executedSee 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

The 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.

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.

typeStatusMeaning
SIGNATURE_CHALLENGE202Not an error. Sign payloadToSign, send the request again
SIGNATURE_CHALLENGE_EXPIRED401Ask again — challenges are short-lived
typeStatusMeaning
SIGNATURE_REQUIRED401This key signs its requests. Send X-Anzo-Signature, X-Anzo-Timestamp and X-Anzo-Nonce
SIGNATURE_INVALID401The signature does not match this request
SIGNATURE_REPLAYED401This exact request was already received. Nothing ran twice
SIGNATURE_TIMESTAMP_SKEW401Your clock is more than 5 minutes from ours
SIGNATURE_TIMESTAMP_INVALID401X-Anzo-Timestamp is not a Unix timestamp in seconds
PUBLIC_KEY_INVALID400Registration only: not a P-256 SPKI public key
KEY_RETIRED401This legacy key is no longer accepted. Issue a signed replacement
KEY_EXPIRED401Keys expire. Rotate before the deadline — the successor overlaps the predecessor
KEY_IP_NOT_ALLOWED401This 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

typeStatusMeaning
DESTINATION_ACCOUNT_NOT_FOUND404No payout account with that id belongs to your organization. Nothing was sent
RATE_DRIFT_EXCEEDED400The quote moved further from expectDestination than you allowed. Nothing was sent
QUOTE_UNVERIFIABLE400We could not compare the quote to your expectation. Nothing was sent
EXACT_OUTPUT_UNSUPPORTED400This routing cannot lock the receiving amount. Check capabilities.exactOutput on the corridors call
PAYOUT_NOT_CANCELABLE400Only a payout still awaiting your funds can be cancelled. Do not retry
INSUFFICIENT_SCOPE403This key is read-only. Issue one with the write scope to move money
INDICATIVE_PRICING_UNAVAILABLE400This 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

GET /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

typeStatusMeaningWhat to do
PAYOUT_LINK_UNUSABLE400The link is expired, already spent, or failed at executionMint 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.

failureCodeWhat happenedIs the money back?What to do
returned_by_bankIt settled, then the receiving bank returned itYesTell your user. Reverse whatever you credited
account_invalidThe account details are wrongYesAsk for correct details, create a new beneficiary
account_cannot_receiveThe account cannot accept this paymentYesTry another account or corridor
compliance_rejectedRefused by compliance screeningNot automaticallyContact us with the payoutId. Do not retry
limit_exceededAbove a corridor or account limitYesSplit it, or check limits on the corridors call
quote_expiredToo long between quoting and sendingYesRe-quote and send again
authorization_not_completedAn authorisation step was not finishedYesStart again
execution_failedIt did not go through, cause not establishedCheck fundsReturnedSafe to retry with a new idempotency key
unknownWe do not have a specific causeCheck fundsReturnedContact 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.


Did this page help you?