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.
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
POST /v1/banking/payouts
Idempotency-Key: payout-INV-0042
Content-Type: application/json
{
"source_account_id": "mockacct_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_eur",
"beneficiary_id": "mockben_7d2f19c4e8a04b6f9e1c3a5b7d9f0e21_4da118ece0f5b6bd",
"amount": "10.00",
"currency": "EUR",
"reference": "INV-0042"
}
| Field | Required | Notes |
|---|---|---|
source_account_id | Yes | One of your accounts — the money leaves from here |
beneficiary_id | Yes | A beneficiary you registered |
amount | Yes | Decimal 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. |
currency | Yes | Must be the beneficiary's currency |
reference | Shown to the beneficiary. Production rails take at most 18 characters; the sandbox does not enforce it. | |
fx_quote_id | Pay in a different currency from the source account's — see Paying in another currency | |
purpose_of_payment | One of the corridor's payment_purposes | |
rail, destination_country | Routing hints; normally leave them out | |
quote_id | Hold the payout to the route a quote disclosed — see the route lock | |
destination_account_id, destination, source_network, destination_network | In 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:
{
"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 send | You get |
|---|---|
| The same key and the same body | 200 and the payout as it stands now — never a second payout |
| The same key and a different body | 409 idempotency_key_reuse |
| A new key | A 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_balancethe first time and200withstatus: "failed"on a replay. Nothing moved either time. To try again after topping up, use a new key. -
A replay that answers
200withstatus: "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 tosubmitted,settledorfailedaccordingly. Until it has, the payout stayscreated, and a payout that was throttled or timed out on its way to the bank is never closed asfailedon a guess. What to do:- a
4xxon the first attempt: nothing was sent. Fix the cause and send a new payout with a new key. - a
5xxor a timeout: look before you resend. CheckGET /v1/banking/payouts/{id}(or the list).failedmeans 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 stayscreatedfor longer than an hour, ask support with the payoutid. (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. - a
Lifecycle
| From | Can move to |
|---|---|
created | submitted, failed, cancelled, needs_reconciliation |
submitted | processing, settled, failed, cancelled, needs_reconciliation |
processing | settled, failed, cancelled, needs_reconciliation |
settled | returned |
needs_reconciliation | submitted, processing, settled, failed, cancelled |
status | Means | Final? |
|---|---|---|
created | Recorded; 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 |
submitted | The rail accepted the instruction. | No |
processing | The rail is executing it. | No |
settled | The money arrived. settled_at is set. | Yes, unless returned |
failed | It did not happen. | Yes |
cancelled | Stopped before execution. | Yes |
returned | Settled, then sent back by the receiving bank. | Yes |
needs_reconciliation | The 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 → settledinside the request. The202already sayssettled. A send from a digital-asset account is the exception: it answerssubmittedand 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 aus_banksend), once the money has been seen leaving the wallet — it staysprocessinguntil then — see which sends finish. - Production:
submittedin the202; thenprocessing, thensettledorfailed, as our banking partner reports each step to us. That takes minutes for an instant scheme and days for SWIFT.
Following a payout
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
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
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:
{
"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 israte: "1"; a different currency is the partner's current rate, withrate_sourcesaying 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:
{
"…": "…",
"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:
POST /v1/banking/fx/quotewithsell_currency= the source account's currency,buy_currency= the payout currency,amount= the payout amount and"fixed_side": "buy".POST /v1/banking/payoutswithfx_quote_id= that quote'squote_idandamountexactly equal to itsbuy_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:
{
"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_verifieduntil your account isactive(KYB), before anything is sent. Sandbox allowspending. - Corridor:
422 corridor_unsupportedwhen 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_deniedunless the seat holdspayouts.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 back202and later turnsfailed. A request the rail rejects outright is400 bad_requestwith its reason.
Every code is in the error reference.