Quickstart
From an API key to a completed payout, in one page. Nothing here touches a real payment network, and no real money can move.
You need a test key (ak_test_…) from Dashboard → Developers, and your organization id. Set both:
export AVVIO_API_KEY=ak_test_…
export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
export AVVIO_BASE_URL=https://api.avvio.xyz/api/v1
Every example below is curl, so nothing depends on a language. If you use Node, npx -y @avvio/payouts doctor does step 1 and tells you which credential is wrong.
1. Check the key works
curl -s "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \
-H "x-api-key: $AVVIO_API_KEY"
You get the currencies you can pay out to and the fields each one needs.
Read this rather than hardcoding a form. Both the corridor list and the field *names* depend on how your organization is routed, and we may re-route you. Mexico is one field; India is two.
2. Give yourself a balance
Sandbox only. Real balances are funded by wire.
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/fund" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"amount":"5000.00"}'
It is an explicit call rather than a balance we hand you, so you can also test the underfunded path deliberately — 400 at quote time is a case your integration has to handle.
3. Show a price before anyone commits
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/rates?from=USD&to=MXN&amount=200" \
-H "x-api-key: $AVVIO_API_KEY"
{
"indicative": true,
"sourceAmount": { "currency": "USD", "amount": "200.00" },
"destinationAmount": { "currency": "MXN", "amount": "3384.65" },
"fee": { "currency": "USD", "amount": "1.02" },
"totalDebit": { "currency": "USD", "amount": "200.00" },
"rate": "17.010001005126146", # full precision — round it yourself for display
"limits": { "min": "5.00", "max": "5000.00" }
}
No beneficiary needed — this is what you show while someone is still typing an amount. The fee comes out of the send, so destinationAmount = (sourceAmount − fee) × rate.
On a payout the fee is already inside the two amounts, so the identity there is simply destinationAmount = sourceAmount × rate. rate on a payout always means destination units per one source unit, whichever network carried it — it is derived from the payout's own amounts rather than passed through, because the networks state it in different directions and on different bases.
4. Create the beneficiary
curl -s -X POST "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"type": "individual",
"name": "Maria Gonzalez",
"email": "maria@example.com",
"country": "MX",
"endUserId": "employee_42",
"externalId": "emp42_maria",
"method": {
"kind": "fiat",
"currency": "MXN",
"recipientDetails": { "clabeNumber": "012345678901234567" }
}
}'
Two ids, two jobs. endUserId is your id for the person *sending* — it scopes the beneficiary so one of your users never sees another's saved accounts. externalId is your id for the beneficiary, and makes a repeat create return the existing one instead of registering a second bank account.
An externalId identifies one account. Re-sending it with the SAME account replays and returns the existing beneficiary. Re-sending it with a DIFFERENT account is refused with BENEFICIARY_EXTERNAL_ID_CONFLICT — a second account needs a second externalId. Nothing is silently substituted.
type, name, email and method are always required, whatever the corridor. The corridors call describes the fields inside recipientDetails; these four sit outside it and apply everywhere.
Keep paymentMethods[0].destinationAccountId from the response.
5. Send it
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"amount": "200.00",
"destinationAccountId": "acct_…",
"expectDestination": "3384.65",
"reference": "ZZ-2026-0042",
"endUser": { "id": "employee_42", "name": "Ana Lopez" }
}'
{ "payoutId": "…", "status": "pending", "reference": "ZZ-2026-0042", … }
expectDestination is the one field not to skip. Between pricing and sending, a rate can move. Pass the number you showed your user and we refuse to send if it has drifted more than 2% — nothing goes, and you re-quote. Without it, you send at whatever the quote says.
6. Watch it settle
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/orders/$PAYOUT_ID" \
-H "x-api-key: $AVVIO_API_KEY"
pending → processing → completed. This endpoint is always live and is authoritative — more so than a webhook you may have missed.
6b. Money in, money out, and stopping one
Three endpoints you will need on day one.
Your balance, and why it is that number:
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
-H "x-api-key: $AVVIO_API_KEY"
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance/history" \
-H "x-api-key: $AVVIO_API_KEY"
balance/history is the ledger behind the number: every funding, every payout debit, and every reversal when money comes back. Read it whenever the balance is not what you expect — it answers the question directly rather than making you infer it from payouts.
Stopping a payout that has not been funded yet:
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts/$PAYOUT_ID/cancel" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)"
It reports canceled, the money returns to your balance with a reversal row, and the payout can no longer be funded. Once a payout is funded it cannot be cancelled — you get PAYOUT_NOT_CANCELABLE, and that is the honest answer rather than a cancellation that does not happen. So cancel is for "created by mistake", not for "stop one already in flight".
A trap worth knowing now: there are two organization ids
The id you authenticate with — the one in every URL — is not the organizationId that comes back inside response bodies. That second id is an internal one, and using it in a URL gives:
{ "type": "FORBIDDEN", "detail": "This API key cannot access that organization" }
which reads as a credentials problem when it is not. Keep using the org id you were issued. Ignore organizationId in response bodies.
7. The one to run before you go live
Create a second beneficiary whose account number ends 0003:
"clabeNumber": "012345678901230003"
Pay it, then keep polling past completed. It flips:
{ "status": "failed", "failureCode": "returned_by_bank", "fundsReturned": true }
A completed payout is not always final. A receiving bank can return one days later. If your ledger treats completed as immutable, this is the case that breaks it — and it is the reason this trigger exists rather than being described in a paragraph you would skim.
Other triggers, and exactly what each does:
| account ends | what happens |
|---|---|
0001 |
settles, then fails with failureCode: account_invalid, funds returned |
0002 |
settles slowly — useful for testing a poll loop |
0003 |
completes, then flips to failed / returned_by_bank (above) |
0004 |
compliance_rejected, and the money does not come back |
0005 |
the quote expires — the payout is refused at create with 400 BAD_REQUEST, so no payout exists to poll |
0006 |
waits for you to fund it from your own wallet |
| anything else | completes |
0006 is worth running too. Some routings do not settle on acceptance — they price the payout and wait for your funds. It returns requiresFunding: true and does not move until you confirm.
Read the deposit instructions from GET /payments/organizations/{orgId}/payouts/{payoutId}/funding — the address, the amount, the network and an expiry — then send the funds and report the transaction:
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts/$PAYOUT_ID/funding/confirm" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"transactionHash":"0x…"}'
We read the chain before recording it. In production a hash matching no transfer, the wrong token, the wrong address, the wrong amount or a wallet other than the one registered is refused — nothing is recorded, and the payout stays fundable. One transfer funds exactly one payout.
The sandbox has no chain to read, so the last four digits of the hash choose the outcome, the same way the account number does:
| hash ends | you get |
|---|---|
0001 |
FUNDING_TRANSACTION_INVALID — 400, we read the chain and it does not fund this payout |
0002 |
FUNDING_NOT_YET_VERIFIABLE — 409, not mined yet; retry the same request |
| anything else | accepted, and the payout settles |
| a hash already used | FUNDING_TRANSACTION_ALREADY_USED — 409, naming the payout it funded |
Testing that branch here is the alternative to discovering it in production.
8. Webhooks, before you need them
Poll-only integrations survive the sandbox and struggle in production. Issue a sandbox secret and prove your handler works now:
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"url":"http://localhost:4000/hooks"}'
{ "id": "…", "url": "…", "secret": "whsec_…",
"warning": "Store this secret now — it is not retrievable." }
http://localhost works in sandbox only, so your first receiver can be a script rather than a tunnel. Live endpoints are https and are created from the dashboard by a human, because a credential able to repoint its own webhook URL could redirect every payout notification.
Then check what we actually sent:
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints/$ID/deliveries" \
-H "x-api-key: $AVVIO_API_KEY"
Not on Node?
Every example here is curl, and the OpenAPI spec generates a working client. This is tested, not asserted — a generated Python client runs the full flow above in our CI, which is how we found (and fixed) four places where the spec disagreed with the server.
npx @openapitools/openapi-generator-cli generate \
-i partner-payouts.openapi.yaml -g python -o ./avvio --package-name avvio_payouts
pip install -e ./avvio
import avvio_payouts
from avvio_payouts.api.payouts_api import PayoutsApi
cfg = avvio_payouts.Configuration(host=BASE_URL)
cfg.api_key["ApiKeyAuth"] = API_KEY
with avvio_payouts.ApiClient(cfg) as api:
payout = PayoutsApi(api).create_payout(
ORG_ID, str(uuid.uuid4()), # the Idempotency-Key
create_payout_request={
"amount": "200.00",
"destinationAccountId": acct,
"expectDestination": quote.destination_amount.amount,
})
Swap -g python for java, go, ruby, csharp, php — the generator supports all of them off the same file.
One shape to watch: on the REST surface sourceAmount is a Money object ({currency, amount}); inside a webhook it is a flat decimal string beside a separate sourceCurrency. They are two schemas in the spec — Payout and WebhookPayout — because they are genuinely different.
Then
- Errors — every code, and what to do about each
- Webhooks — verification and delivery
- Going live — the checklist
Two rules worth internalising now
A timeout is an unknown outcome, not a failure. If a send times out, the payout may exist. Retry with the *same* Idempotency-Key — a replay returns the original. Calling again without it is a second payment.
Webhooks are the fast path, not the guarantee. Reconcile against the payout read; treat a webhook as the nudge to look.
Endpoint reference
23 routes, generated from the OpenAPI spec (v2026-08-16). Every path is prefixed with your API base URL, and :org is your organization id.
| Method | Path | Purpose |
|---|---|---|
| GET | /recipients/:org/corridors | Currencies you can pay out to, and the fields each needs |
| GET | /payments/organizations/:org/rates | Price a corridor before a beneficiary exists |
| GET | /recipients/:org | Beneficiaries, optionally for one of your end users |
| POST | /recipients/:org | Register who is being paid |
| POST | /recipients/:org/{recipientId}/methods | Add another way to pay an existing beneficiary |
| POST | /payments/organizations/:org/quotes/offramp | Lock a price against a real beneficiary |
| POST | /payments/organizations/:org/quotes/accept | Send the money |
| POST | /payments/organizations/:org/payouts | Price and send in one call |
| POST | /payments/organizations/:org/payout-links | Mint a one-time link for the person being paid |
| GET | /payout-links/{token} | What the recipient's page renders |
| POST | /payout-links/{token}/submit | Spend the link and send the money |
| GET | /payments/organizations/:org/balance/history | Why the balance is what it is |
| POST | /payments/organizations/:org/payouts/{payoutId}/cancel | Stop a payout that has not moved yet |
| GET | /payments/organizations/:org/payouts/{payoutId}/funding | How to fund a payout that needs it |
| POST | /payments/organizations/:org/payouts/{payoutId}/funding/confirm | Tell us you have sent the funds |
| GET | /payments/organizations/:org/events | Everything that has happened to your payouts, in order |
| GET | /payments/organizations/:org/balance | What you can currently send |
| POST | /payments/organizations/:org/sandbox/fund | Credit a sandbox balance |
| POST | /payments/organizations/:org/sandbox/webhook-endpoints | Register a sandbox webhook endpoint and get its signing secret |
| GET | /payments/organizations/:org/sandbox/webhook-endpoints/{endpointId}/deliveries | What we sent, what came back, and what we retried |
| GET | /payments/organizations/:org/orders | Recent payouts |
| GET | /payments/organizations/:org/orders/{payoutId} | One payout, always live |
| GET | /payments/organizations/:org/payin-accounts | Where to wire money to top up your balance |
Errors
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 |
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
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
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.
``bash # 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.
- 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
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
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
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.
Going live
The sandbox proves the flow. This page is what changes when the money is real, and what to have working before it is.
Swap the credential, not the code
A live key addresses the same organization id and the same endpoints. Only the prefix changes: ak_test_… → ak_live_…. If anything else in your integration has to change, that is a bug on our side — tell us.
export AVVIO_API_KEY=ak_live_…
Keys are bearer credentials for your money. Server-side only; our CORS policy does not allow the header, so a browser cannot send one even by accident.
What genuinely differs in production
| Sandbox | Live | |
|---|---|---|
| Settlement | Seconds | Hours to days, per corridor |
| Rates | Fixed | Real, and they move between quoting and sending |
| Balance | sandbox/fund |
Funded by wire — see payin-accounts |
| Failure triggers | Account-number suffix | Whatever actually happens |
| Corridors | A handful | What your routing supports — read the corridors call |
The last row is the one that surprises people. The corridor list and the field *names* within it depend on how your organization is routed, and we may re-route you. A form built against hardcoded field names breaks on a routing change; a form built from GET /recipients/{orgId}/corridors does not.
The checklist
Before your first live payout
- [ ] You persist your own
Idempotency-Keybefore you send, and reuse it on
every retry. This is the single thing that prevents a double payment. Generating one per attempt defeats it entirely.
- [ ] A timeout is treated as an unknown outcome, not a failure. Retry with
the same key; a replay returns the original payout.
- [ ] Your ledger does not treat
completedas final. A bank can return a
settled payment days later. Exercise it in sandbox with account suffix 0003 before you go live, not after.
- [ ] You read
fundsReturnedrather than inferring fromfailureCode. It is
absent when we do not know, which is deliberately not the same as false.
- [ ] You reconcile from
GET /events, carryingnextSince, and dedupe onid.
The request parameter is since, not nextSince. nextSince is the field we return; feed its value back as since:
``bash curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/events?since=624" \ -H "x-api-key: $AVVIO_API_KEY" ``
Sending ?nextSince= instead replays your whole history on every poll, because unknown query parameters are ignored rather than rejected. That is the single easiest way to build a reconciler that silently reprocesses everything forever.
Use /events. It gives one row per transition with a sequence cursor, and a sequence never changes once assigned.
GET /orders?updatedSince= also returns changed payouts oldest-changed first, and it is fine for a human-facing list — but its sort key is updatedAt, which moves whenever a payout does. Page it while payouts are settling and rows can land behind a cursor you have already passed. Use it to look at recent activity, not as the thing your ledger depends on.
(Two earlier versions of this paragraph were wrong in opposite directions: one said the payout list could not show a change to something you had already read — false with updatedSince — and one recommended /events because the payout list accepted a bad cursor silently, which is now the reverse of the truth: /orders rejects an unknown cursor with a 400.)
- [ ] Your webhook receiver verifies signatures over the raw bytes, and you
have tested that a wrong secret is rejected.
The scheme is Standard Webhooks, so any Svix-compatible verifier works. If you are writing it yourself — and the quickstart explicitly courts non-Node shops — this is the whole algorithm:
`` signed = "{svix-id}.{svix-timestamp}.{raw request body}" key = base64_decode(secret without its "whsec_" prefix) expect = base64(HMAC_SHA256(key, signed)) ``
Compare against each space-separated entry in svix-signature after its v1, prefix, in constant time. Verify over the RAW bytes, before parsing. Reject timestamps outside ±5 minutes.
It was previously documented only inside the OpenAPI file, which is the one place a non-Node integrator following this checklist would not look.
- [ ] You treat webhooks as the nudge and the API as the truth.
Operationally
- [ ] You know which corridors you actually need and have confirmed each one
appears in GET /recipients/{orgId}/corridors for your organization.
- [ ] You have a funded balance. Live balances are funded by wire; there is no
live equivalent of sandbox/fund.
- [ ] You store
requestId. On an ERROR it is a body field; on a SUCCESS it is
the x-request-id header and NOT in the body. Log the header and you have it either way — a logger reading res.body.requestId records undefined for every successful call. It is what lets us find your exact request.
- [ ] You have somewhere for a human to look at a
compliance_rejectedpayout.
Those funds do not come back automatically.
Rate limits
Per credential, plus a per-source ceiling. The response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset — read them rather than guessing, and back off on 429 (which is retryable).
If your volume needs a higher ceiling, tell us before you go live rather than discovering it in a payroll run.
Tell us before you scale
We would rather hear "we are about to send 5,000 payouts on Friday" than find out from a graph. Corridor limits, balance headroom and rate ceilings are all things we can raise, and none of them can be raised retroactively.