Payouts (pay-outs)
A payout sends money to a recipient in a supported country. You send USD, and the recipient gets the local currency. We subtract the USD from your balance.
We calculate the price again when the payout executes, so the rate is always the current one. To show a figure to your customer, get a quote first. Then start the payout.
Quote a payout
Get a quote with each fee before you commit. The quote shows what you send, what the recipient gets, the rate, and the fees.
POST /v1/payouts/quote
{
"code": 200,
"message": "Quote created",
"data": {
"type": "payout",
"usd": { "amount": 100.5, "currency": "USD" },
"local": { "amount": 158200, "currency": "NGN", "country": "NG" },
"exchange_rate": { "pair": "USD/NGN", "rate": 1582 },
"fees": {
"platform_fee": 0.5,
"provider_fee": 0,
"total_fee": 0.5,
"currency": "USD"
}
}
}
usd.amount is the total leaving your balance, fee included — so quoting a
$100 payout with a $0.50 fee returns 100.50, not 100. The fee is added on
top of what you asked to send rather than taken out of it, which is why
local.amount is the rate applied to your amount and not to usd.amount.
All amounts are JSON numbers, not strings, and every fee is in USD.
local.amount is an estimate at the quoted rate; the realised figure is
finalised at settlement.
Initiate a payout
You can pay out over two rails, set by rail:
bank(default) — local currency to a bank or mobile-money account.crypto— a digital asset to a wallet address.
Either way you send USD, debited from your balance.
Bank payout
POST /v1/payouts
curl -X POST {BASE_URL}/v1/payouts \
-H "x-api-key: sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"rail": "bank",
"country": "NG",
"currency": "NGN",
"amount": 100,
"method": "bank",
"customer_id": "cus_123",
"recipient": {
"account_name": "Ada Obi",
"account_number": "0123456789",
"bank_code": "000013",
"bank_name": "GTBANK PLC"
},
"reference": "payroll_jun_8842"
}'
| Field | Required | Description |
|---|---|---|
rail |
no | bank (default) or crypto. |
country |
yes | Corridor country (ISO-3166 alpha-2), e.g. NG. |
currency |
yes | Local currency the recipient receives, e.g. NGN. |
amount |
yes | By default the USD you send. With amount_type: "destination", the local amount to deliver. |
method |
no | bank (default) or momo. |
recipient |
yes | Destination account — account_name, account_number, plus bank_code/bank_name (bank) or network (momo). |
customer_id |
recommended | The customer this is for. |
reference |
recommended | Your own reference for reconciliation. |
Bank codes are six digits, not three. A Nigerian bank_code is the NIP
institution code — GTBank is 000013, Access Bank is 000014, OPay is
100004. The three-digit CBN sort codes you may have seen elsewhere (058,
044) are not accepted and the payout will fail.
Don't hard-code them. Read the list from
GET /v1/payouts/banks and use the code it returns.
List banks
The banks you can pay out to, with the code to put in recipient.bank_code.
GET /v1/payouts/banks?currency=NGN
{
"code": 200,
"message": "Banks fetched successfully",
"data": [
{
"code": "000013",
"name": "GTBANK PLC",
"logo": "https://cdn.jsdelivr.net/…/GTBank.svg"
},
{
"code": "000014",
"name": "ACCESS BANK",
"logo": "https://cdn.jsdelivr.net/…/Access%20Bank.svg"
},
{
"code": "100004",
"name": "OPAY",
"logo": "https://cdn.jsdelivr.net/…/OPay.svg"
}
]
}
| Field | Required | Description |
|---|---|---|
currency |
no | Local currency to list banks for. Defaults to NGN, the only currency with a bank list today. |
logo is always present for NGN and falls back to a generic bank mark when we
have no specific one, so it is safe to render without a null check. The list
changes rarely — cache it for a day rather than fetching it per payout.
Account number format. A Nigerian (NGN) bank account uses a NUBAN of 10
digits. We check the format before we send the payout. An incorrect number gives
you this error immediately: Account number must be a valid 10-digit NUBAN. The
bank does not reject it later. The usual cause is one more zero at the start,
which makes 11 digits.
Crypto payout
First list the assets you can send and pick a network’s asset_id:
GET /v1/payouts/assets
{
"code": 200,
"message": "Payout assets fetched successfully",
"data": [
{
"symbol": "USDT",
"asset_logo": "https://cdn.jsdelivr.net/…/USDT.png",
"preferred": true,
"chains": [
{
"asset_id": "usdt-tron",
"chain": "tron",
"chain_name": "Tron",
"chain_logo": "https://…/TRX.png",
"decimals": 6,
"price": 0.9996,
"contract_address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"direct": false,
"can_deposit": true,
"can_payout": true,
"checked_at": "2026-10-05T09:30:00.000Z"
},
{
"asset_id": "usdt-polygon",
"chain": "pol",
"chain_name": "Polygon",
"chain_logo": "https://…/POL.png",
"decimals": 6,
"price": 0.9996,
"contract_address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
"direct": false,
"can_deposit": true,
"can_payout": true,
"checked_at": "2026-10-05T09:30:00.000Z"
}
]
}
]
}
chain is a short code (pol, tron, eth), not the display name — read
chain_name for that. preferred marks the assets we suggest first: they are
the cheapest to move. direct: true means we hold that asset on that network
and send it as-is; false means it is bridged, which is slower. price is
indicative and for display only, the rate that settles is the one on the quote.
Whether a corridor actually works
can_deposit, can_payout and checked_at report what a corridor did the
last time we asked, rather than what a catalogue claims. Each one is
dry-quoted in both directions on a schedule, and the answer is what you read
here. A listed asset is not the same as a priceable one: a network can be in
every catalogue and still refuse to quote.
Absent is not false. All three fields are missing when we have not checked
that corridor yet, which is a different thing from knowing it is shut. Treat
them as a hint for ordering a picker or warning before a quote — not as a
gate. If you gate on them, gate on can_payout === false, never on
!can_payout.
// Fine: hide only what we have measured as unusable.
const sendable = chains.filter((c) => c.can_payout !== false);
// Wrong: this also hides every corridor we have not probed yet.
const sendable = chains.filter((c) => c.can_payout);
A corridor with a minimum still reports true. A minimum is a limit on the
amount, not on the corridor, and the quote tells you the figure.
Then send:
curl -X POST {BASE_URL}/v1/payouts \
-H "x-api-key: sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"rail": "crypto",
"amount": 100,
"asset_id": "usdt-polygon",
"address": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
"reference": "supplier_2231"
}'
| Field | Required | Description |
|---|---|---|
rail |
yes | crypto. |
amount |
yes | The USD you send. The recipient receives the equivalent in the chosen asset. |
asset_id |
yes | A network’s asset_id from GET /v1/payouts/assets. |
address |
yes | Destination wallet address on that asset’s network. |
customer_id |
recommended | The customer this is for. |
reference |
recommended | Your own reference for reconciliation. |
Response
{
"code": 201,
"message": "Payout initiated",
"data": {
"id": "txn_…",
"type": "withdrawal",
"status": "processing",
"amount": 100,
"currency": "USD",
"fee": 0.5,
"local_amount": 158200,
"local_currency": "NGN",
"exchange_rate": 1582,
"reference": "payroll_jun_8842",
"description": "Payout to Ada Obi",
"created_at": "2026-06-25T10:16:57.000Z",
"timeline": []
}
}
The amount and fee fields are in USD; local_amount and local_currency
are what the recipient is set to receive. We calculate the final local amount
at settlement.
A crypto payout carries local_asset_id and local_asset_symbol instead of
local_currency:
{
"local_amount": 1.169813,
"local_asset_id": "usdc-solana",
"local_asset_symbol": "USDC"
}
Read local_asset_symbol for the ticker; do not split local_asset_id.
The handle names the network as well as the coin, and the two are not
interchangeable — USDC on Solana is USDC, not SOL. The symbol is resolved for
you so that a caller never has to know which part of the handle is which.
timeline is empty on creation and fills in as the payout moves. Don't read
progress from this response — poll
GET /v1/transactions/{id} or handle the webhook.
Testing a payout in sandbox
Nothing finishes a payout on its own with test keys — there is no bank on the other end to confirm one. You decide when it lands:
POST /v1/sandbox/payouts/{reference}/settle
{ "outcome": "success" }
{reference} is either the payout's id or the reference you gave it.
{
"code": 200,
"message": "Payout settled",
"data": { "reference": "payroll_jun_8842", "status": "success" }
}
A payout that has already finished cannot be settled as the other outcome. That
returns 400 rather than reporting success while leaving the payout failed, so
if you settled one as failed you need a new payout, not a second call.
Everything downstream is real: the transaction settles and payout.completed
fires at your webhook. Send {"outcome": "failed"} to rehearse a payout the
bank rejects, which fires payout.failed instead — worth doing at least once,
since a payout that is refused is the case most integrations have never run.
This is the only way to complete a payout in sandbox, on either rail. See getting started for the rest of what sandbox does and does not do.
Lifecycle
A payout moves through five stages: requested, debited, in progress,
sent, and delivered. The timeline field shows the current stage. See
transaction lifecycle. If your balance is too low,
we reject the payout immediately.