Errors
Three questions sit behind every failure on this page: did the request execute, is retrying safe, and what do I do now. The first two are marked on every row below, because at 3am they are the only ones that matter and prose is the wrong medium for them.
No error type on this page means "the payout was sent and then failed". That is not an error response at all — it is a payout whose status is failed, carrying a failureCode. Those are further down.
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#
- What happened
- A field is missing or malformed
- What to do
- Fix the fields in
errorsand retry
- What happened
- Key missing, wrong, revoked, or used on an endpoint keys cannot reach
- What to do
- Check it was copied whole and is not revoked
- What happened
- Valid key, wrong organization
- What to do
- Check
AVVIO_ORG_ID
- What happened
- No such payout or beneficiary
- What to do
- Check the id came from us
- What happened
- Too many requests
- What to do
- Back off, then retry
- What happened
- A request we understood but cannot carry out — an expired quote, an amount above the corridor maximum, a non-positive amount
- What to do
- Read
detail; it names the specific condition. Never retryable unchanged
- What happened
- The payout network refused the request — most often an amount below that corridor's minimum. Nothing was submitted
- What to do
- 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
- What happened
- 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
- What to do
- Read
detail; it names which. The payout is still fundable, so send the correct transaction and confirm that
- What happened
- We could not read the transaction yet — not mined, or we could not reach the chain. Nothing was recorded
- What to do
- Retry the same request once it is mined. If you already paid, your funds are unaffected
- What happened
- That transaction already funded a different payout. The deposit address is shared between payouts, so one transfer funds exactly one
- What to do
- Send a separate transfer for this payout.
detailnames the payout it already funded
- What happened
- The payout is cancelled or already finished, so it cannot be funded. Nothing was recorded
- What to do
- Do not retry. Read the payout; if you still owe the recipient, create a new one
- What happened
- That
externalIdalready identifies a beneficiary with different account details. Nothing was changed - What to do
- Use a new
externalIdfor a different account. If this was a retry, read the existing beneficiary — anexternalIdidentifies one account, and a second account is a secondexternalId
- What happened
- The payout's funding state changed under you — it is already funded with a different transaction, or a concurrent request won. Nothing was recorded
- What to do
- Re-read the payout before retrying. If it is already funded, you are done
- What happened
- This organization is suspended
- What to do
- Contact us; retrying will not help
- What happened
- Your balance will not cover this payout. Nothing was sent
- What to do
- Top up, then retry. Branch on this type rather than parsing the message — it is the one condition a payroll run must handle
- What happened
- We could not read the full payout list, so we will not report a partial page as complete
- What to do
- Retry. If you passed a
cursorwe did not issue, that is the likeliest cause
- What happened
- Raised by the Node client, not the API: the corridor you asked about is not offered on your routing
- What to do
- Read the corridors call and pick one it lists
- What happened
- 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
- What to do
- Retry with the same
Idempotency-Key; a replay returns the original. Never start over with a new key
- What happened
- 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
- What to do
- Retry with the same
Idempotency-Key, then read the payout back
- What happened
- Ours
- What to do
- Retry with the same
Idempotency-Key. Send us therequestIdif 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#
- Meaning
- No header on a mutation
- What to do
- Add one, unique per operation
- Meaning
- Malformed
- What to do
- 1–255 chars of
A-Z a-z 0-9 _ . : -. A UUID works
- Meaning
- Same key, different body
- What to do
- Do not retry. This is a bug on your side — a different request needs a different key
- Meaning
- An identical request is still running
- What to do
- Back off, retry the same key
- Meaning
- We could not record it. Nothing executed
- What to do
- Retry the same key
- Meaning
- An identical request arrived under a different key seconds ago. Nothing executed
- What to do
- 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#
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:
It was a retry. Send it again with the value of
originalIdempotencyKeyas yourIdempotency-Keyheader. 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... }'originalIdempotencyKeyis a field we send to you, not one you send back. Putting it in the request body is rejected — request bodies reject unknown properties.
You meant two payments. Add
X-Allow-Duplicate: trueand 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.
Sending#
- Meaning
- No payout account with that id belongs to your organization. Nothing was sent
- Meaning
- The quote moved further from
expectDestinationthan you allowed. Nothing was sent
- Meaning
- We could not compare the quote to your expectation. Nothing was sent
- Meaning
- This routing cannot lock the receiving amount. Check
capabilities.exactOutputon the corridors call
- Meaning
- Only a payout still awaiting your funds can be cancelled. Do not retry
- Meaning
- This key is read-only. Issue one with the
writescope to move money
- Meaning
- This routing publishes no price without a beneficiary. Do not retry — check
capabilities.indicativePricingand 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#
- Meaning
- The link is expired, already spent, or failed at execution
- What to do
- 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.
returned_by_bank#- What happened
- It settled, then the receiving bank returned it
- Is the money back?
- Yes
- What to do
- Tell your user. Reverse whatever you credited
account_invalid#- What happened
- The account details are wrong
- Is the money back?
- Yes
- What to do
- Ask for correct details, create a new beneficiary
account_cannot_receive#- What happened
- The account cannot accept this payment
- Is the money back?
- Yes
- What to do
- Try another account or corridor
compliance_rejected#- What happened
- Refused by compliance screening
- Is the money back?
- Not automatically
- What to do
- Contact us with the
payoutId. Do not retry
limit_exceeded#- What happened
- Above a corridor or account limit
- Is the money back?
- Yes
- What to do
- Split it, or check
limitson the corridors call
quote_expired#- What happened
- Too long between quoting and sending
- Is the money back?
- Yes
- What to do
- Re-quote and send again
authorization_not_completed#- What happened
- An authorisation step was not finished
- Is the money back?
- Yes
- What to do
- Start again
execution_failed#- What happened
- It did not go through, cause not established
- Is the money back?
- Check
fundsReturned - What to do
- Safe to retry with a new idempotency key
unknown#- What happened
- We do not have a specific cause
- Is the money back?
- Check
fundsReturned - What to do
- 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.