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.