Quickstart
From an API key to a completed payout in one page, without touching a real payment network.
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 signing credential from Dashboard → Developers, and your
organization id. The public id remains visible; the private key is shown once.
Set all three values:
export AVVIO_API_KEY=akid_test_…
export AVVIO_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----…'
export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefix
export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1The key does not authorize anything — your signature does
We never receive your private key, so we cannot leak it, and a copy of
everything we store is not enough to move your money. Requests are authorized by
signing them, and there are two ways to do that.
Ask us what to sign. Send the request with no signature and you get 202
with payloadToSign and requestId — nothing has happened yet. Sign that blob
(ECDSA P-256 over SHA-256) and send the same request again with
X-Anzo-Signature and X-Anzo-Request-Id. There is no canonical string to
build, no ordering to get wrong, and no library to find:
# 1. ask
CHALLENGE=$(curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
-H "x-api-key: $AVVIO_API_KEY")
PAYLOAD=$(echo "$CHALLENGE" | jq -r .payloadToSign)
REQUEST_ID=$(echo "$CHALLENGE" | jq -r .requestId)
# 2. sign it
SIG=$(printf %s "$PAYLOAD" | openssl dgst -sha256 -sign avvio-signing.key | base64)
# 3. send the same request again
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/balance" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "x-anzo-signature: $SIG" \
-H "x-anzo-request-id: $REQUEST_ID"Or sign the request yourself, which saves the extra round trip and is worth
it for a payroll run of thousands — @avvio/payments does this for you, and the
CLI and Node examples below are already signing. The full form is in
ERRORS.md.
The curl examples that follow show the headers only. Each one needs the
challenge dance above, or the SDK, which is why every tab except curl is a
one-liner.
Every step below is shown as a CLI command, as curl, and as Node — pick a tab
and it stays picked for the rest of the site. Nothing here depends on a
language. The SDK and CLI sign requests automatically. The curl samples show
the request shape; use the challenge flow in Errors
to attach a signature. If you use Node, npx -y @avvio/payments doctor does step 1 and tells
you which credential is wrong.
1. Check the key works
avvio-payments corridorscurl -s "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \
-H "x-api-key: $AVVIO_API_KEY"const { corridors, capabilities } = await avvio.corridors();You get the currencies you can pay out to and the fields each one needs.
Good to knowRead 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.
avvio-payments fund --amount 5000.00curl -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"
}'await avvio.fund('5000.00'); // test keys onlyIt 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
avvio-payments quote --amount 200.00 --to MXNcurl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/rates?from=USD&to=MXN&amount=200.00" \
-H "x-api-key: $AVVIO_API_KEY"const quote = await avvio.quote({
amount: '200.00',
to: 'MXN',
});
// → { sourceAmount, destinationAmount, fee, rate, limits, indicative: true }{
"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
avvio-payments beneficiary create \
--name "María González" --email [email protected] \
--currency MXN --country MX \
--end-user employee_42 --external-id emp42_maria \
--field clabeNumber=012345678901234567curl -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": "María González",
"email": "[email protected]",
"country": "MX",
"endUserId": "employee_42",
"method": {
"kind": "fiat",
"currency": "MXN",
"recipientDetails": {
"clabeNumber": "012345678901234567"
}
}
}'const beneficiary = await avvio.createBeneficiary({
name: 'María González',
email: '[email protected]',
country: 'MX',
currency: 'MXN',
endUserId: 'employee_42', // YOUR id for the person sending
externalId: 'emp42_maria', // makes a repeat create safe
details: { 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
avvio-payments pay --amount 200.00 \
--to <destinationAccountId> \
--expect 3384.65 \
--end-user employee_42 --reference ZZ-2026-0042curl -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": "<destinationAccountId>",
"amountLeg": "source",
"expectDestination": "3384.65",
"maxDriftBps": 200,
"endUser": {
"id": "employee_42"
}
}'const payout = await avvio.payout({
amount: '200.00',
destinationAccountId: acct,
expectDestination: quote.destinationAmount.amount, // 3384.65
endUser: { id: 'employee_42' },
reference: 'ZZ-2026-0042',
});{ "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
avvio-payments status <payoutId>curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/orders/$payoutId" \
-H "x-api-key: $AVVIO_API_KEY"const payout = await avvio.getPayout(payoutId);
// pending → processing → completed, and sometimes back to failedpending → 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:
avvio-payments cancel <payoutId>curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts/$payoutId/cancel" \
-H "x-api-key: $AVVIO_API_KEY" \
-H "idempotency-key: $(uuidgen)"await avvio.cancelPayout(payoutId); // only before it is fundedIt 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": "012180000000070003"
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 | fails at the rail 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":"https://example.ngrok-free.app/hooks"}'{
"id": "…",
"url": "…",
"secret": "whsec_…",
"warning": "Store this secret now — it is not retrievable.",
}When testing against the hosted API, use a publicly reachable HTTPS receiver;
a development tunnel is fine. http://localhost is accepted by the sandbox
route only when the Avvio backend itself runs locally. A hosted backend resolves
localhost to itself, not to your laptop. Live endpoints 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 ./avvioimport 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
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.
Updated about 1 hour ago
