Send your first payout to Mexico
Set up your sandbox
Create a test key in Dashboard → Developers and note your organization id. The key is shown once, so copy it then.
Send the key as
x-api-keyon every request; there is nothing to sign. Production uses the same base URL, and theavvio_test_prefix keeps these calls in your sandbox, where no real money moves. The Node tabs neednpm i @avvio/payments, and the Python tabs needpip install requests.Each sample mints a new
Idempotency-Keyfor its own call. Reuse a key only to retry that exact request. The same payout under a new key is refused with409 DUPLICATE_REQUEST_DETECTEDfor 15 minutes, and pays twice after that (Idempotency).Setup export AVVIO_API_KEY=avvio_test_…export AVVIO_ORG_ID=cmsx… # a cuid, not an org_ prefixexport AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1Ask what Mexico needs
A
200proves the key works. Each destination needs different account details, so build your form from this call instead of hardcoding one: the fields follow your routing (Register recipients).Mexico needs one field,
clabeNumber: 18 digits with a check digit. The response also carries the corridor'slimits, andsettlement, which is alwaysnulltoday (a payout carriesexpectedSettlementAtinstead).GET /recipients/{orgId}/corridors curl -s "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \-H "x-api-key: $AVVIO_API_KEY"GET /recipients/{orgId}/corridors import { PayoutsClient } from '@avvio/payments';// Reads AVVIO_API_KEY, AVVIO_ORG_ID and AVVIO_BASE_URL from the environment.const avvio = new PayoutsClient();const { corridors, capabilities } = await avvio.corridors();GET /recipients/{orgId}/corridors import osimport requestsres = requests.get(f"{os.environ['AVVIO_BASE_URL']}/recipients/{os.environ['AVVIO_ORG_ID']}/corridors",headers={"x-api-key": os.environ["AVVIO_API_KEY"],},)print(res.status_code, res.json())Response {"corridors": [{"currency": "MXN","fields": [{ "id": "clabeNumber", "title": "CLABE", "type": "string","pattern": "^[0-9]{18}$", "checksum": "clabe", "required": true }],"limits": { "min": "1.00", "max": "5000.00" },"settlement": null}],"capabilities": { "exactOutput": true, "indicativePricing": true }}Fund your sandbox balance
Payouts draw on your USD balance. In production you fund it by wire or USDC (Fund your balance); in the sandbox this call does it, and
amountdefaults to10000.00. Skip it once to see the underfunded path, which your integration has to handle.POST /payments/organizations/{orgId}/sandbox/fund IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/fund" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H "content-type: application/json" \-d '{"amount": "5000.00"}'POST /payments/organizations/{orgId}/sandbox/fund await avvio.fund('5000.00'); // test keys onlyPOST /payments/organizations/{orgId}/sandbox/fund import osimport uuidimport requestsidempotency_key = str(uuid.uuid4()) # new key per call; reuse it only to retry this exact requestres = requests.post(f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/sandbox/fund",headers={"x-api-key": os.environ["AVVIO_API_KEY"],"Idempotency-Key": idempotency_key,},json={"amount": "5000.00"},)print(res.status_code, res.json())Response { "balance": "5000.00" }Register the recipient
A recipient is stored once and reused.
type,nameandmethodare always required, andmethod.recipientDetailsholds the fields the corridor named.Each tab saves
method.destinationAccountIdasDESTINATION_ACCOUNT_ID. You pay that id, not the recipientid.externalIdis your id for the recipient: sending the same one again returns this recipient instead of a second one.endUserIdnames your customer, the person the payout is for, never the recipient. The response below is abridged: the real one also carriestype,paymentMethods[]and more (Register recipients).POST /recipients/{orgId} IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H "content-type: application/json" \-d '{"type": "individual","name": "María González","country": "MX","externalId": "cust42_maria","endUserId": "customer_42","method": {"kind": "fiat","currency": "MXN","recipientDetails": {"clabeNumber": "012180000080004471"}}}' | tee response.jsonexport DESTINATION_ACCOUNT_ID=$(jq -r .method.destinationAccountId response.json)POST /recipients/{orgId} const beneficiary = await avvio.createBeneficiary({name: 'María González',country: 'MX',currency: 'MXN',endUserId: 'customer_42', // your id for the person sendingexternalId: 'cust42_maria', // makes a repeat create safedetails: { clabeNumber: '012180000080004471' },});const destinationAccountId = beneficiary.method.destinationAccountId; // you pay thisPOST /recipients/{orgId} import osimport uuidimport requestsidempotency_key = str(uuid.uuid4()) # new key per call; reuse it only to retry this exact requestres = requests.post(f"{os.environ['AVVIO_BASE_URL']}/recipients/{os.environ['AVVIO_ORG_ID']}",headers={"x-api-key": os.environ["AVVIO_API_KEY"],"Idempotency-Key": idempotency_key,},json={"type": "individual","name": "María González","country": "MX","externalId": "cust42_maria","endUserId": "customer_42","method": {"kind": "fiat","currency": "MXN","recipientDetails": {"clabeNumber": "012180000080004471"}}},)print(res.status_code, res.json())os.environ["DESTINATION_ACCOUNT_ID"] = res.json()["method"]["destinationAccountId"] # a later step reads itResponse {"id": "cmf3k2xa10004q8b7r5t8u1vw","name": "María González","country": "MX","externalId": "cust42_maria","endUserId": "customer_42","method": {"kind": "fiat","currency": "MXN","last4": "4471","status": "active","destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5"}}Show a price
Show the price while someone is still choosing an amount. It needs no recipient and creates nothing;
indicative: truemarks it as an estimate, and the binding price comes back on the payout.The fee comes out of what you send:
destinationAmount = (sourceAmount − fee) × rate. KeepdestinationAmount.amountfor the next step.GET /payments/organizations/{orgId}/rates curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/rates?from=USD&to=MXN&amount=200.00" \-H "x-api-key: $AVVIO_API_KEY"GET /payments/organizations/{orgId}/rates const quote = await avvio.quote({amount: '200.00',to: 'MXN',});// → { sourceAmount, destinationAmount, fee, rate, limits, indicative: true }GET /payments/organizations/{orgId}/rates import osimport requestsres = requests.get(f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/rates",headers={"x-api-key": os.environ["AVVIO_API_KEY"],},params={"from": "USD","to": "MXN","amount": "200.00"},)print(res.status_code, res.json())Response {"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","limits": { "min": "1.00", "max": "5000.00" }}Send the payout
One call prices and sends the payout and answers
200with it. A202means your organization requires approval and no payout exists yet (Send a payout).Pass the amount you showed as
expectDestination. If the price drifts more thanmaxDriftBps(default 200, or 2%) from it, the call fails withRATE_DRIFT_EXCEEDEDand nothing is sent.A timeout is not a failure. After a timeout the payout may exist, so retry with the same
Idempotency-Keyto get it back. A new key is refused for 15 minutes (409 DUPLICATE_REQUEST_DETECTED) and pays twice after that.Its
referencediffers from the Quickstart's on purpose. An identical body sent under a newIdempotency-Keywithin 15 minutes is refused with409 DUPLICATE_REQUEST_DETECTEDand nothing is sent, so changereference(or the amount) before you run the same payout twice.The payout's
rateis the effective rate after the fee, 3384.65 MXN for 200.00 USD, so it is lower than the quote's 17.01, which applies to the 198.98 left once the 1.02 fee is taken. KeeppayoutIdfor the next step.POST /payments/organizations/{orgId}/payouts IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H "content-type: application/json" \-d '{"amount": "200.00","destinationAccountId": "'"$DESTINATION_ACCOUNT_ID"'","amountLeg": "source","expectDestination": "3384.65","maxDriftBps": 200,"reference": "MX-RECIPE-0001","endUser": {"id": "customer_42"}}'POST /payments/organizations/{orgId}/payouts const idempotencyKey = crypto.randomUUID(); // persist it with the order so a retry reuses itconst payout = await avvio.payout({amount: '200.00',destinationAccountId,expectDestination: quote.destinationAmount.amount, // 3384.65endUser: { id: 'customer_42' },reference: 'MX-RECIPE-0001',idempotencyKey,});POST /payments/organizations/{orgId}/payouts import osimport uuidimport requestsidempotency_key = str(uuid.uuid4()) # new key per call; reuse it only to retry this exact requestres = requests.post(f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/payouts",headers={"x-api-key": os.environ["AVVIO_API_KEY"],"Idempotency-Key": idempotency_key,},json={"amount": "200.00","destinationAccountId": os.environ["DESTINATION_ACCOUNT_ID"],"amountLeg": "source","expectDestination": "3384.65","maxDriftBps": 200,"reference": "MX-RECIPE-0001","endUser": {"id": "customer_42"}},)print(res.status_code, res.json())Response {"payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11","status": "pending","sourceAmount": { "currency": "USD", "amount": "200.00" },"destinationAmount": { "currency": "MXN", "amount": "3384.65" },"destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5","fee": { "currency": "USD", "amount": "1.02" },"rate": "16.923250","reference": "MX-RECIPE-0001","endUser": { "id": "customer_42" },"createdAt": "2026-08-20T14:03:11.000Z","updatedAt": "2026-08-20T14:03:11.000Z","completedAt": null}Watch it settle
You send to
/payoutsand read from/orders. SetpayoutIdto the id from the previous step (payoutId=sbx_pay_…in a shell). The read is authoritative even when a webhook went missing.A payout moves
pending → processing → completed. In the sandbox it completes 10 to 20 seconds after you create it, and a poll may never catchprocessing. A bank can still return acompletedpayout (Track status & failures).Next, receive webhooks and reconcile from
GET /events.GET /payments/organizations/{orgId}/orders/{payoutId} curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/orders/$payoutId" \-H "x-api-key: $AVVIO_API_KEY"GET /payments/organizations/{orgId}/orders/{payoutId} const order = await avvio.getPayout(payout.payoutId);// pending → processing → completed, and sometimes back to failedGET /payments/organizations/{orgId}/orders/{payoutId} import osimport requestspayoutId = "<payoutId>"res = requests.get(f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/orders/{payoutId}",headers={"x-api-key": os.environ["AVVIO_API_KEY"],},)print(res.status_code, res.json())Response {"payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11","status": "completed","sourceAmount": { "currency": "USD", "amount": "200.00" },"destinationAmount": { "currency": "MXN", "amount": "3384.65" },"destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5","fee": { "currency": "USD", "amount": "1.02" },"rate": "16.923250","reference": "MX-RECIPE-0001","endUser": { "id": "customer_42" },"createdAt": "2026-08-20T14:03:11.000Z","updatedAt": "2026-08-20T14:03:26.000Z","completedAt": "2026-08-20T14:03:26.000Z"}Make it fail on purpose
In the sandbox the account number's last four digits choose the outcome. Register a recipient with each CLABE, as before, and pay it.
…0001fails at the bank withfailureCode: account_invalid, and the money comes back (fundsReturned: true).…0003completes, then about 30 seconds later (allow a minute) flips tofailedwithfailureCode: returned_by_bank.
A bank can return a completed payout days later, so a ledger that treats
completedas final breaks here. Run this before you go live. Environments & sandbox lists the other suffixes, and Go live is the same code with a live key.Fails at the bank: account_invalid "recipientDetails": { "clabeNumber": "012180000030000001" }Completes, then is returned by the bank "recipientDetails": { "clabeNumber": "012180000000070003" }Response: GET …/orders/{payoutId}, a minute later { "status": "failed", "failureCode": "returned_by_bank", "fundsReturned": true }
Was this page helpful?