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 suffix0003before 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 asfalse.You reconcile from
GET /events, carryingnextSince, and dedupe onid.The request parameter is
since, notnextSince.nextSinceis the field we return; feed its value back assince: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 asequencecursor, 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 isupdatedAt, 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/eventsbecause the payout list accepted a bad cursor silently, which is now the reverse of the truth:/ordersrejects 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-signatureafter itsv1,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}/corridorsfor 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 thex-request-idheader and NOT in the body. Log the header and you have it either way — a logger readingres.body.requestIdrecordsundefinedfor 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.