Avvio Payouts API reference llms.txt

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.

Did my money move?
nothing movedthe request did not execute — nothing was sent, recorded or changed
read it backwe cannot promise nothing executed — read the payout back before you act
Can I retry?
same requestsend it again unchanged, with the same idempotency key
change firstfix something first — the same request fails identically
do not retryretrying cannot succeed

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#

VALIDATION_ERROR400nothing movedchange first#
What happened
A field is missing or malformed
What to do
Fix the fields in errors and retry
UNAUTHORIZED401nothing movedchange first#
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
FORBIDDEN403nothing movedchange first#
What happened
Valid key, wrong organization
What to do
Check AVVIO_ORG_ID
NOT_FOUND404nothing movedchange first#
What happened
No such payout or beneficiary
What to do
Check the id came from us
RATE_LIMITED429nothing movedsame request#
What happened
Too many requests
What to do
Back off, then retry
BAD_REQUEST400nothing movedchange first#
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
PROVIDER_REJECTED400nothing movedchange first#
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
FUNDING_TRANSACTION_INVALID400nothing movedchange first#
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
FUNDING_NOT_YET_VERIFIABLE409nothing movedsame request#
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
FUNDING_TRANSACTION_ALREADY_USED409nothing movedchange first#
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. detail names the payout it already funded
PAYOUT_NOT_FUNDABLE400nothing moveddo not retry#
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
BENEFICIARY_EXTERNAL_ID_CONFLICT409nothing movedchange first#
What happened
That externalId already identifies a beneficiary with different account details. Nothing was changed
What to do
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
CONFLICT409read it backchange first#
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
ACCOUNT_BLOCKED403nothing moveddo not retry#
What happened
This organization is suspended
What to do
Contact us; retrying will not help
INSUFFICIENT_BALANCE400nothing movedchange first#
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
ORDERS_TEMPORARILY_UNAVAILABLE503nothing movedsame request#
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 cursor we did not issue, that is the likeliest cause
CORRIDOR_UNAVAILABLEnothing movedchange first#
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
TIMEOUT504read it backsame request#
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
NETWORK_ERROR502read it backsame request#
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
INTERNAL500read it backsame request#
What happened
Ours
What to do
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#

IDEMPOTENCY_KEY_REQUIRED400nothing movedchange first#
Meaning
No header on a mutation
What to do
Add one, unique per operation
IDEMPOTENCY_KEY_INVALID400nothing movedchange first#
Meaning
Malformed
What to do
1–255 chars of A-Z a-z 0-9 _ . : -. A UUID works
IDEMPOTENCY_KEY_CONFLICT409nothing moveddo not retry#
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
IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS409read it backsame request#
Meaning
An identical request is still running
What to do
Back off, retry the same key
IDEMPOTENCY_UNAVAILABLE503nothing movedsame request#
Meaning
We could not record it. Nothing executed
What to do
Retry the same key
DUPLICATE_REQUEST_DETECTED409nothing movedchange first#
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:

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#

DESTINATION_ACCOUNT_NOT_FOUND404nothing movedchange first#
Meaning
No payout account with that id belongs to your organization. Nothing was sent
RATE_DRIFT_EXCEEDED400nothing movedchange first#
Meaning
The quote moved further from expectDestination than you allowed. Nothing was sent
QUOTE_UNVERIFIABLE400nothing movedchange first#
Meaning
We could not compare the quote to your expectation. Nothing was sent
EXACT_OUTPUT_UNSUPPORTED400nothing movedchange first#
Meaning
This routing cannot lock the receiving amount. Check capabilities.exactOutput on the corridors call
PAYOUT_NOT_CANCELABLE400nothing moveddo not retry#
Meaning
Only a payout still awaiting your funds can be cancelled. Do not retry
INSUFFICIENT_SCOPE403nothing movedchange first#
Meaning
This key is read-only. Issue one with the write scope to move money
INDICATIVE_PRICING_UNAVAILABLE400nothing moveddo not retry#
Meaning
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#

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.

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 limits on 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.