---
updatedAt: 2026-09-30T15:54:20.000Z
---

Fetch the complete documentation index at: https://docs.avvio.xyz/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Send a payout

A payout moves USD from your balance to a recipient's bank account in their
currency. One call prices and sends it.

> [!NOTE]
> **Before you start**
> You need a funded balance ([Fund your balance](/funding/)) and a
> recipient's `destinationAccountId` ([Register recipients](/recipients/)).

1. Optionally show a price with `GET /payments/organizations/{orgId}/rates`.
2. Send with `POST …/payouts`.
3. Read the outcome from `GET …/orders/{payoutId}` or the `payout.*` webhooks.

To lock a rate before money moves, you can
[quote, then accept](#quote-then-accept) instead of step 2. That path is closed
once your organization has caps or approvals; use `POST /payouts` and
[approvals](#approvals-202) for review.

## 1. Show a price

CLI:

```bash
avvio-payments quote --amount 200.00 --to MXN
```

curl:

```bash
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"
```

Node:

```js
const quote = await avvio.quote({
  amount: '200.00',
  to: 'MXN',
});
// → { sourceAmount, destinationAmount, fee, rate, limits, indicative: true }
```

Python:

```python
import os
import requests

res = 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())
```

The price needs no recipient and creates nothing. Keep
`destinationAmount.amount` as the next step's `expectDestination`.

## 2. Send the payout

```http
POST /payments/organizations/{orgId}/payouts
x-api-key: avvio_live_…
Idempotency-Key: c94b21fa-36e2-411a-9f5b-91f81d11234a
Content-Type: application/json

{
  "amount": "200.00",
  "destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5",
  "expectDestination": "3384.65",
  "maxDriftBps": 200,
  "reference": "PAYROLL-2026-0042",
  "endUser": {
    "id": "customer_42",
    "name": "Ana López"
  }
}
```

```json
{
  "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": "PAYROLL-2026-0042",
  "endUser": { "id": "customer_42", "name": "Ana López" },
  "createdAt": "2026-08-20T14:03:11.000Z",
  "updatedAt": "2026-08-20T14:03:11.000Z",
  "completedAt": null
}
```

An accepted payout answers `200` with `status: "pending"`. The fee is deducted
from the send, so `destinationAmount = sourceAmount × rate`, where `rate` is the
effective rate after the fee, not the mid-rate from `/rates`. `fee` is `null`
when the network has not disclosed one.

> [!CAUTION]
> **A timeout is not a failure**
> If the call times out, the payout may exist. Retry with the same
> `Idempotency-Key` and body to get the original back. A new key is refused
> with `409 DUPLICATE_REQUEST_DETECTED` for 15 minutes and pays twice after
> that ([Idempotency](/idempotency/)).

### Rate protection

`expectDestination` is the destination amount you showed the user.
`maxDriftBps` is how far the rate may slip, in basis points (default `200`,
or 2%). Drift is checked only when you send `expectDestination`. A payout that
drifts further is refused with `400 RATE_DRIFT_EXCEEDED`
and nothing is debited.

### Purpose of payment

INR, GHS, CNY and BRL payouts need `purposeOfPayment`: on `POST /payouts`, or,
on the two-step path, on `POST …/quotes/offramp` (the accept call takes no
`purposeOfPayment`). Pick a value from the currency's list; we never default one:

```http
GET /payments/organizations/{orgId}/payment-reasons?currency=MXN
```

The response is `{ payment_reasons: [...], default? }`. A missing or unlisted
value is refused with `400 VALIDATION_ERROR` before anything is priced. So is
`paymentReason` on `POST /payouts`, where it does not exist; on
`POST …/quotes/accept` it is an optional free-text field, and
`purposeOfPayment` there is refused as unknown.

### Approvals (`202`)

Above your organization's approval threshold, `POST /payouts` answers `202` and
nothing is priced or sent:

```json
{ "status": "pending_approval", "approvalId": "cmf3k2xf90008q8b7q4r6s8tu", "requiredApprovals": 2, "expiresAt": "2026-09-04T10:00:00.000Z" }
```

Wait for `payout_approval.executed`, which carries the `payoutId`. A replay of
the same key returns the same `202` ([Approvals](/status/#approvals)).

### Caps and refusals

| Refusal | When |
| --- | --- |
| `422 PAYOUT_LIMIT_EXCEEDED` | Over one of your USD caps: per payout, per day, or per end user per day |
| `422 PAYOUT_REFUSED` | Screening refused it |
| `400 INSUFFICIENT_BALANCE` | Larger than your USD balance; no quote is used up |

The per-end-user cap counts against `endUser.id` and makes it required. Caps and
screening apply to `POST /payouts`, batches and payout links. Quote-then-accept
is refused outright with `403 USE_POST_PAYOUTS` once your organization has any
cap or approval threshold. [Errors](/errors/) lists the rest.

## 3. Check the outcome

CLI:

```bash
avvio-payments status $payoutId
```

curl:

```bash
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/orders/$payoutId" \
  -H "x-api-key: $AVVIO_API_KEY"
```

Node:

```js
const order = await avvio.getPayout(payout.payoutId);
// pending → processing → completed, and sometimes back to failed
```

Python:

```python
import os
import requests

payoutId = "<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())
```

The read is authoritative even when a webhook went missing.
[Track status & failures](/status/) covers every status.

## Quote, then accept

A quote locks the rate until its `expires_at`. Nothing is debited until you
accept. Creating a quote takes no `Idempotency-Key`; accepting it does.

> [!NOTE]
> Quote-then-accept is only open while your organization has no caps and no
> approval threshold. With either set, both calls answer
> `403 USE_POST_PAYOUTS`; send with `POST /payouts` instead.

```http
POST /payments/organizations/{orgId}/quotes/offramp
x-api-key: avvio_live_…
Content-Type: application/json

{
  "amount": "200.00",
  "destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5",
  "amountLeg": "source"
}
```

The quote may also carry `purposeOfPayment` (validated here, for the currencies
that need one) and `sourceCurrency`, the balance currency to debit. This
returns a snapshot `id`, a `best_quote_id` and `expires_at` (snake case). Accepting
moves the money:

```http
POST /payments/organizations/{orgId}/quotes/accept
x-api-key: avvio_live_…
Idempotency-Key: <uuid>
Content-Type: application/json

{
  "snapshotId": "sbx_quote_eyJkIjoic2J4X2FjY3RfTVhOXzQ0NzFfYWU2NmNiYzUiLCJzIjoiMjAwLjAwIn0",
  "quoteId": "sbx_quote_eyJkIjoic2J4X2FjY3RfTVhOXzQ0NzFfYWU2NmNiYzUiLCJzIjoiMjAwLjAwIn0",
  "type": "OFFRAMP",
  "reference": "INV-1092"
}
```

The accept body also takes an optional `paymentReason`. Its `reference` allows
only letters, digits, spaces, `:` and `-`.

`amount` is a decimal string, never a JSON number: up to six decimals on a
quote, two on a one-call payout. Quote-accept and one-call payouts refuse
near-duplicates unless you send `X-Allow-Duplicate` ([Idempotency](/idempotency/)).

## Fixing the amount the recipient receives

Payouts are priced from the amount you send (`amountLeg: "source"`). To fix
what arrives instead, set `amountLeg: "destination"`:

```json
{
  "amount": "5000.00",
  "amountLeg": "destination",
  "destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5"
}
```

Check `capabilities.exactOutput` on `GET /recipients/{orgId}/corridors` first.
When it is `false`, this returns `400 EXACT_OUTPUT_UNSUPPORTED`.

## Fees

`fees.payout` on `GET …/policy` is the schedule your routing states before a
quote. Where published, the fee is `fixedUsd + sourceAmount × bps / 10000`,
rounded to the cent and deducted before conversion.

| Field | Holds |
| --- | --- |
| `bps` | Basis points of the send, for every currency not in `byCurrency` |
| `fixedUsd` | The fixed part, in USD |
| `byCurrency` | Overrides by destination currency |
| `note` | Prose; don't branch on it |

A `null` number means "not published before a quote", never zero. Some routings
price the whole fee inside each quote, and one adds the network's charge on top
of `bps`. The binding figure is always `fee` on the quote and payout, which
matches its events and `/balance_transactions` row.
