Skip to main content

Payouts

A payout moves money from one of your accounts to a beneficiary. It is the one call on this API that pays someone, so it is built to be safe to retry and explicit about what has and has not happened.

All routes are on the money API: secret key, master key or dashboard session. A dashboard session also needs step-up; keys do not.

From a digital-asset account (sandbox)

The same route sends from a USDC, EURC or other digital-asset account — on chain and across chains, to an IBAN, to US, UK, Mexican and Brazilian bank accounts, by @handle, as a swap, and back to your own EUR account — with a destination or destination_account_id instead of beneficiary_id. An API key sends to a third party there only to a trusted beneficiary. See Sending from a digital-asset account.


Send a payout​

HTTP
POST /v1/banking/payouts
Idempotency-Key: payout-INV-0042
Content-Type: application/json
JSON
{
"source_account_id": "mockacct_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_eur",
"beneficiary_id": "mockben_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_4da118ece0f5b6bd",
"amount": "10.00",
"currency": "EUR",
"reference": "INV-0042"
}
FieldRequiredNotes
source_account_idYesOne of your accounts — the money leaves from here
beneficiary_idYesA beneficiary you registered
amountYesDecimal string, greater than zero. What the beneficiary receives. Send no more decimal places than the currency has: the sandbox refuses more, and production passes the amount to the rail unchecked.
currencyYesMust be the beneficiary's currency
referenceShown to the beneficiary. Production rails take at most 18 characters; the sandbox does not enforce it.
fx_quote_idPay in a different currency from the source account's — see Paying in another currency
purpose_of_paymentOne of the corridor's payment_purposes
rail, destination_countryRouting hints; normally leave them out
quote_idHold the payout to the route a quote disclosed — see the route lock
destination_account_id, destination, source_network, destination_networkIn place of beneficiary_id: a payout to one of your own accounts, or a send from a digital-asset account (sandbox) — see Sending

A payee the receiving bank's name check does not pass is not a field on this request: the payer's acceptance is recorded on the beneficiary first, then the payout is sent again under the same key — see the payee-name check. A partner field or query, which older clients used to name the rail, is 400 bad_request: the rail is chosen for you, and responses no longer name it.

202 Accepted:

JSON
{
"id": "5f0c2e9a-3b1d-4c7e-8a6f-2d9b4e1c7a30",
"status": "submitted",
"partner_payment_id": "…",
"amount": "10.0000",
"currency": "EUR",
"direction": "outbound",
"created_at": "2026-10-15T20:14:49Z",
"updated_at": "2026-10-15T20:14:49Z",
"submitted_at": "2026-10-15T20:14:49Z"
}

id is the payout's permanent handle. 202 means the rail accepted the instruction — not that the money arrived. In the sandbox the response already reads status: "settled"; in production it reads submitted and moves on from there.

Fields: id, status, partner_payment_id (the rail's own reference), amount, currency, direction (outbound), rail, partner_status (the rail's own wording — for support, not for display), created_at, updated_at, submitted_at, settled_at, and — sent under a route lock — quote_id and route. A field without a value is omitted, except source_account_id, destination_account_id and arrival, which read null where they do not apply (destination_account_id and arrival are for a payout to one of your own accounts). A payout from or to a digital-asset account also carries kind, network, destination_network, destination_currency and tx_hash (details). The rail itself is not named.

Retries and the Idempotency-Key​

The key is required, and it is what makes a payout safe to retry:

You sendYou get
The same key and the same body200 and the payout as it stands now — never a second payout
The same key and a different body409 idempotency_key_reuse
A new keyA new payout

"The same body" means the same field values exactly as sent: "10.0" and "10.00", or "eur" and "EUR", are different bodies. Key order and whitespace do not matter. A replay also passes the checks that come before the stored payout is looked up — your verification status, that the account and beneficiary are yours, a lockdown, and in production the payee-name check — so it can answer 403, 404, 422 or 503 where the first attempt did not.

Derive the key from your own record of the payment — an invoice or ledger id — and keep it for every retry of that payment. Keys are shared with FX conversions: a key used for a conversion cannot be used for a payout. See Idempotency.

A replay returns the payout's current status, whatever it is. Read status, not just the HTTP code:

  • A sandbox payout refused for lack of funds answers 400 insufficient_balance the first time and 200 with status: "failed" on a replay. Nothing moved either time. To try again after topping up, use a new key.

  • A replay that answers 200 with status: "created" means the outcome of the first attempt is not known yet: it is still running, or the call to the bank failed or timed out after the payout was recorded. A replay never re-submits. A reconciler resolves it: it looks for the bank's own word about that instruction — the notification the bank sends us — and moves the payout to submitted, settled or failed accordingly. Until it has, the payout stays created, and a payout that was throttled or timed out on its way to the bank is never closed as failed on a guess. What to do:

    • a 4xx on the first attempt: nothing was sent. Fix the cause and send a new payout with a new key.
    • a 5xx or a timeout: look before you resend. Check GET /v1/banking/payouts/{id} (or the list). failed means the bank never took it — send again with a new key. Anything else means it was sent, or is still being resolved; do not send it again. If it stays created for longer than an hour, ask support with the payout id. (In the sandbox, nothing leaves the test ledger.)

    This is why you should keep the response — or at least the status and X-Request-Id — of every attempt, not only the last.


Lifecycle​

FromCan move to
createdsubmitted, failed, cancelled, needs_reconciliation
submittedprocessing, settled, failed, cancelled, needs_reconciliation
processingsettled, failed, cancelled, needs_reconciliation
settledreturned
needs_reconciliationsubmitted, processing, settled, failed, cancelled
statusMeansFinal?
createdRecorded; not yet accepted by the rail. Normally lasts only inside the request; after a failed or timed-out call to the rail it lasts until the reconciler has established what happened.No
submittedThe rail accepted the instruction.No
processingThe rail is executing it.No
settledThe money arrived. settled_at is set.Yes, unless returned
failedIt did not happen.Yes
cancelledStopped before execution.Yes
returnedSettled, then sent back by the receiving bank.Yes
needs_reconciliationThe outcome needs resolving: the rail reported a state only a person can resolve, or its answer was lost. An operator or the reconciler resolves it into one of the statuses above.No

No other transition occurs. submitted and processing both mean "in flight"; only settled means "arrived", which is why settled_at exists separately from submitted_at. Today's rails never report cancelled or returned; treat them as final states anyway, so your code is ready when one does.

How the status moves:

  • Sandbox: created → submitted → settled inside the request. The 202 already says settled. A send from a digital-asset account is the exception: it answers submitted and moves as the crypto-asset provider's sandbox reports it — read every 15 seconds for its first ten minutes — or, where that sandbox leaves it in flight, is settled by ours (simulated: true) from a minute after acceptance (five minutes for a us_bank send), once the money has been seen leaving the wallet — it stays processing until then — see which sends finish.
  • Production: submitted in the 202; then processing, then settled or failed, as our banking partner reports each step to us. That takes minutes for an instant scheme and days for SWIFT.

Following a payout​

HTTP
GET /v1/banking/payouts/{id}

Returns the same object. Poll it until the status is final — every 30 seconds for the first minutes, then backing off. A payout that is not yours, or does not exist, is 404. A payout still created a minute after you sent it is being reconciled against the bank's own notifications — see Retries — so keep polling it rather than sending it again.

There is no payout webhook today: the webhook catalogue has no payout events. The event stream relays the partner's raw status notifications for production payouts, but the ticket that opens it takes a dashboard session or master key — not a secret key — and its payloads are the partner's own format. Build payout tracking on polling.

Listing​

HTTP
GET /v1/banking/payouts?limit=50

{"payouts": [...], "has_more": true, "next_cursor": "…"}, newest first; limit 1–200, default 50. has_more is true exactly when next_cursor is set: send it as before to read the next, older page. On the last page has_more is false and next_cursor is null. Payouts sharing a timestamp are neither skipped nor repeated from one page to the next. The list also contains your FX conversions and the conversions made when an account closes — they carry rail: "fx_conversion".

GET /v1/transactions is a different feed: money coming in, and transfers made in the dashboard. It does not list payouts or conversions made through /v1/banking/* — use this list for those. GET /v1/transactions/{id} does resolve a payout's or a conversion's id — see the route reference.


Quoting​

HTTP
POST /v1/banking/payouts/quote
Content-Type: application/json

{"source_account_id": "…", "beneficiary_id": "…", "currency": "EUR", "amount": "10.00"}

Nothing moves and no key is needed. The answer is what the payout would cost from that account:

JSON
{
"source_currency": "EUR",
"source_amount": "15.00",
"fee": "5.00",
"payment_fee": "5.00",
"rate": "1",
"currency": "EUR",
"amount": "10.00",
"simulated": true
}

amount/currency is what the beneficiary receives; source_amount/ source_currency is what leaves your account. beneficiary_id is optional. In the sandbox it decides the fee, because a local payment and a SWIFT payment are priced differently; a production quote carries no fee.

The two environments price differently:

  • Sandbox (simulated: true): your plan's payment fee is included, and a different source currency is converted at the sandbox rate plus your plan's FX fee.
  • Production (simulated: false): no fee. Same currency is rate: "1"; a different currency is the partner's current rate, with rate_source saying where it came from — an indication, not a price you can hold.

This quote cannot be passed as fx_quote_id. To lock a rate, use the FX quote below.

The route, and holding the payout to it​

A quote that names a destination also answers the route the payout would take — its steps, schemes, countries and the kind of party taking each — and locks it for 15 minutes under a single-use quote_id:

JSON
{
"…": "…",
"quote_id": "b454c169-6786-42a0-a455-77aeea7bce3d",
"expires_at": "2026-11-03T16:15:00Z",
"route": {
"kind": "bank_payout",
"steps": [ { "type": "bank_transfer", "scheme": "sepa", "currency": "EUR",
"from_country": "DE", "to_country": "NL", "by": "bank" } ],
"…": "…"
},
"rate_locked": false
}

The quote also takes rail and destination_country, as the payout does. Send the payout with the same body and quote_id, and it runs on exactly that route or is refused — 409 route_changed, 422 route_quote_mismatch, 422 route_quote_expired, 409 route_quote_used. The lock holds the route, never a rate (rate_locked: false). See Quotes and the route lock.


Paying in another currency​

On the sandbox rail you can pay EUR from a GBP account directly: it converts at the sandbox rate. In production the source account's currency must match the payout's, or you pass a locked FX quote:

  1. POST /v1/banking/fx/quote with sell_currency = the source account's currency, buy_currency = the payout currency, amount = the payout amount and "fixed_side": "buy".
  2. POST /v1/banking/payouts with fx_quote_id = that quote's quote_id and amount exactly equal to its buy_amount.

The quote must still be valid and must describe this payment: its sell currency must be the source account's, its buy currency and amount the payout's. Otherwise 400 bad_request names the mismatch; an expired quote is 409 quote_expired, and one we have no record of is 409 quote_unrecorded. Without a quote, production answers 400: "account … holds GBP, so it cannot pay EUR without an fx_quote_id". In the sandbox a payout with fx_quote_id is checked against the quote in the same way, but debited at the sandbox rate, and the quote is not used up.


Limits​

Your plan sets a single-payment, daily and monthly limit, measured in USD. A payout over one is 422 plan_limit_exceeded, with the limit in the message:

JSON
{
"success": false,
"error": "plan_limit_exceeded",
"message": "This payment (about $5434.78 USD) is above the $2500 single-payment limit of the free plan. Upgrade your plan or send a smaller amount."
}

503 limit_unmeasurable means the amount could not be priced in USD to check it; try again shortly.

Who can stop a payout​

  • Verification: 403 account_not_verified until your account is active (KYB), before anything is sent. Sandbox allows pending.
  • Corridor: 422 corridor_unsupported when nobody pays this currency to this country — refused before anything is recorded, so the key is not spent. Details.
  • Payee-name check: 422 payee_name_mismatch — details.
  • Role: from a dashboard session, 403 permission_denied unless the seat holds payouts.create (owner, admin, member). API keys are not checked, but are capped at their creator's seat when minted.
  • Platform lockdown and holds: 503 platform_paused, 403 account_on_hold — details.
  • Balance: sandbox 400 insufficient_balance. In production the rail accepts the instruction first and checks it afterwards, so a payout it cannot carry out — for lack of funds or any other reason — normally comes back 202 and later turns failed. A request the rail rejects outright is 400 bad_request with its reason.

Every code is in the error reference.