Skip to content

8 steps, all in the sandbox

  1. 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-key on every request; there is nothing to sign. Production uses the same base URL, and the avvio_test_ prefix keeps these calls in your sandbox, where no real money moves. The Node tabs need npm i @avvio/payments, and the Python tabs need pip install requests.

    Each sample mints a new Idempotency-Key for its own call. Reuse a key only to retry that exact request. The same payout under a new key is refused with 409 DUPLICATE_REQUEST_DETECTED for 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_ prefix
    export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1
  2. Ask what Mexico needs

    A 200 proves 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's limits, and settlement, which is always null today (a payout carries expectedSettlementAt instead).

    GET /recipients/{orgId}/corridors
    curl -s "$AVVIO_BASE_URL/recipients/$AVVIO_ORG_ID/corridors" \
    -H "x-api-key: $AVVIO_API_KEY"
    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 }
    }
  3. 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 amount defaults to 10000.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 request
    curl -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"
    }'
    Response
    { "balance": "5000.00" }
  4. Register the recipient

    A recipient is stored once and reused. type, name and method are always required, and method.recipientDetails holds the fields the corridor named.

    Each tab saves method.destinationAccountId as DESTINATION_ACCOUNT_ID. You pay that id, not the recipient id. externalId is your id for the recipient: sending the same one again returns this recipient instead of a second one. endUserId names your customer, the person the payout is for, never the recipient. The response below is abridged: the real one also carries type, paymentMethods[] and more (Register recipients).

    POST /recipients/{orgId}
    IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact request
    curl -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.json
    export DESTINATION_ACCOUNT_ID=$(jq -r .method.destinationAccountId response.json)
    Response
    {
    "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"
    }
    }
  5. Show a price

    Show the price while someone is still choosing an amount. It needs no recipient and creates nothing; indicative: true marks 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. Keep destinationAmount.amount for 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"
    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" }
    }
  6. Send the payout

    One call prices and sends the payout and answers 200 with it. A 202 means your organization requires approval and no payout exists yet (Send a payout).

    Pass the amount you showed as expectDestination. If the price drifts more than maxDriftBps (default 200, or 2%) from it, the call fails with RATE_DRIFT_EXCEEDED and nothing is sent.

    A timeout is not a failure. After a timeout the payout may exist, so retry with the same Idempotency-Key to get it back. A new key is refused for 15 minutes (409 DUPLICATE_REQUEST_DETECTED) and pays twice after that.

    Its reference differs from the Quickstart's on purpose. An identical body sent under a new Idempotency-Key within 15 minutes is refused with 409 DUPLICATE_REQUEST_DETECTED and nothing is sent, so change reference (or the amount) before you run the same payout twice.

    The payout's rate is 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. Keep payoutId for the next step.

    POST /payments/organizations/{orgId}/payouts
    IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact request
    curl -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"
    }
    }'
    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
    }
  7. Watch it settle

    You send to /payouts and read from /orders. Set payoutId to 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 catch processing. A bank can still return a completed payout (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"
    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"
    }
  8. 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.

    • …0001 fails at the bank with failureCode: account_invalid, and the money comes back (fundsReturned: true).
    • …0003 completes, then about 30 seconds later (allow a minute) flips to failed with failureCode: returned_by_bank.

    A bank can return a completed payout days later, so a ledger that treats completed as 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?