Skip to content

Payouts

Verify identity, register a mobile number or a bank account, get a quote, then withdraw the available balance.

A payout sends the merchant's available balance to a registered mobile number or, for the Senegal balance, to a registered bank account (see "Payout to a bank account" below). It happens in two steps: a quote (amounts frozen, no money movement), then the payout.

Prerequisite: identity verification

Identity verification governs payouts and the registration of a payout number. It covers an identity document and is done in the dashboard, Identity verification section. 221 reviews the file; the result appears in the same section.

Check the state from the API:

Terminal
curl "$API_URL/v1/kyc/status" -H "Authorization: Bearer $API_KEY"
200 · application/json (excerpt)
{
  "status": "verified",
  "merchant_status": "active",
  "withdrawals_blocked": false,
  "withdrawals_blocked_reason": null
}
statusMeaning
not_startedNo verification started.
pendingFile under review.
verifiedIdentity verified: payouts allowed.
rejectedFile rejected; reason_code gives the reason.

Before verification, payout routes answer 403 KYC_REQUIRED, with kyc_status.

1. Register a payout number

Terminal
curl -X POST "$API_URL/v1/payout-destinations" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rail": "sn_wave", "phone": "77 123 45 67"}'
201 · application/json
{ "id": "5e8c…", "rail": "sn_wave", "phone_last_digits": "4567", "verified": false }
  • rail is a rail active on the account (see Rails and countries).
  • The number is a valid mobile in that rail's country, in local or E.164 format.
  • A number is verified with a code received by e-mail before the first payout, like a bank account (see below). Otherwise, 409 DESTINATION_NOT_VERIFIED.
  • At most 5 additions per 24 hours (429 RATE_LIMITED beyond).
  • An already registered (not removed) number returns 200 and the same destination, even when the add limit is reached: only new numbers count.
  • The response shows only the last four digits.

List: GET /v1/payout-destinations. Remove a number: DELETE /v1/payout-destinations/{id} (204).

2. Get a quote

Terminal
curl -X POST "$API_URL/v1/payout-quotes" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rail": "sn_wave", "destination_id": "5e8c…", "amount": "100000"}'
201 · application/json
{
  "quote_hash": "9b2d…",
  "rail": "sn_wave",
  "destination_id": "5e8c…",
  "amount": "100000",
  "fee": "…",
  "net": "…",
  "expires_at": "2026-10-07T10:02:00Z"
}

amount is debited from the balance; net = amount − fee is what the number receives. The quote expires after 2 minutes. The quote rail is the number's rail.

3. Withdraw

Terminal
curl -X POST "$API_URL/v1/payouts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: payout-2026-10-07-1" \
  -H "Content-Type: application/json" \
  -d '{"quote_hash": "9b2d…"}'
ResponseMeaning
201Payout processed: status is succeeded or a failure status.
202Payout accepted, outcome pending: only id and status are returned. Read GET /v1/payouts/{id}.
409 QUOTE_EXPIREDQuote expired. Request a new quote.
409 QUOTE_INVALIDQuote unknown or already used. Request a new quote.
409 IDEMPOTENCY_CONFLICTKey already used with another body.
409 INSUFFICIENT_FUNDSAvailable balance too low. Nothing is debited.
503 CHANNEL_UNAVAILABLEPayouts temporarily suspended on this rail; the balance is untouched.

Funds are reserved on acceptance: they move from available to reserved in Balances.

Statuses

GroupStatusesMeaning
In progresscreated, reserving, reserved, queued, dispatch_started, processingFunds reserved, payment being prepared or sent.
SucceededsucceededThe number received net. Event payout.succeeded.
Failedfailed_confirmed, releasedPayment declined; funds return to the available balance. Event payout.failed.
UnknownunknownUncertain outcome, being checked by 221.

A failed payout carries failure_reason (absent on older payouts):

failure_reasonMeaning
destination_limitThe recipient wallet has reached its limit. A smaller amount, another number or a bank account is still possible.
destination_invalidRecipient account or number refused by the operator. Check the number or the account.
provider_unavailablePayment service temporarily unavailable. A new attempt is possible later.

In all three cases the funds return to the available balance. The same field is in the body of payout.failed (see Webhooks). The payout object also carries destination_type: mobile or bank.

GET /v1/payouts lists the last 50 payouts; a payout refused before sending shows as not_sent, with no money movement.

Payout with an unknown outcome

An unknown payout is never resent or released by default: a double payment is ruled out. The funds stay reserved until the outcome is proven. Do not create a second payout to compensate; follow GET /v1/payouts/{id} or the webhook.

Receipt

GET /v1/payouts/{id}/receipt returns the receipt of a succeeded payout: id, amount, fee, net, rail, rail_label, destination_last_digits, succeeded_at and livemode. The route answers 404 until the payout has succeeded.

Payout to a bank account

The Senegal balance (Wave and Orange Money) can also be transferred to a bank account in the UEMOA zone. The payment service carries out the transfer, which arrives in 3 to 5 business days.

Register a bank account

Terminal
curl -X POST "$API_URL/v1/payout-destinations" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type": "bank", "rib": "SN012 01234 012345678901 54", "holder_name": "Awa Diop"}'
201 · application/json (excerpt)
{
  "id": "7a1d…",
  "type": "bank",
  "bank_code": "SN012",
  "rib_last4": "0154",
  "holder_name": "Awa Diop",
  "verified": false
}
FieldFormat
rib24 characters: bank code (country code and 3 digits, for example SN012), branch code (5 digits), account number (12 characters), key (2 digits). Spaces are accepted. Country codes: BJ, BF, CI, GW, ML, NE, SN, TG.
holder_name2 to 70 characters: letters, spaces, apostrophe, hyphen and dot.

The key is checked: key = 97 − ((bank code, branch code and account number, letters converted to digits) × 100 modulo 97). Conversion: A to I are 1 to 9, J to R are 1 to 9, S to Z are 2 to 9. A refused RIB returns 400 INVALID_REQUEST, with fields.rib or fields.holder_name.

The list (GET /v1/payout-destinations) returns for a bank account type, bank_code, rib_last4 (the last 4 characters only), holder_name, verified, available_at and verification. A mobile number has type: "mobile", or no type on older data.

Verify the account

A 6-digit code is sent by e-mail to the merchant account owner. It is valid for 10 minutes, with 5 attempts.

Terminal
curl -X POST "$API_URL/v1/payout-destinations/7a1d…/verify" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
  • POST /v1/payout-destinations/{id}/resend sends a new code (3 per hour at most).
  • Wrong code: 400 INVALID_CODE with attempts_left. Expired code: 410 CODE_EXPIRED. Too many attempts: 429 TOO_MANY_ATTEMPTS.
  • Once the account is verified, available_at gives the date of the first payout, 24 hours later.
  • In test mode only, the code 000000 is also accepted, for a number or a bank account (an expired code is still refused: 410 CODE_EXPIRED). Live mode refuses it.

Quote and payout

The quote and the payout use the same routes as for a mobile number, with the bank account's destination_id.

Terminal
curl -X POST "$API_URL/v1/payout-quotes" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination_id": "7a1d…", "amount": "300000"}'
201 · application/json
{
  "quote_hash": "c41f…",
  "destination_id": "7a1d…",
  "amount": "300000",
  "fee": "15000",
  "net": "285000",
  "debits": [
    { "rail": "sn_wave", "amount": "180000" },
    { "rail": "sn_orange", "amount": "120000" }
  ],
  "delay": "3-5 business days",
  "expires_at": "2026-10-07T10:02:00Z"
}
  • debits splits amount between the Senegal Wave and Orange Money balances, pro rata to their available balance.
  • delay announces the arrival time.
  • The payout is confirmed as for a mobile number: POST /v1/payouts with the quote_hash and an Idempotency-Key.

Fees, minimum and limits

The rate of the tier that contains the amount applies to the whole amount.

Payout amountFee
200,000 to 499,999 CFA francs5%
500,000 to 999,999 CFA francs4%
1,000,000 to 2,499,999 CFA francs3%
2,500,000 to 5,000,000 CFA francs2.5%

GET /v1/fees publishes these tiers in bank_tiers (from, to, bps), along with bank_min, bank_max and bank_monthly_cap.

LimitValueError beyond
Minimum per payout200,000 CFA francs400 BELOW_MINIMUM, with min_amount, max_amount and fields.amount.
Maximum per payout5,000,000 CFA francs400 ABOVE_MAXIMUM, with min_amount, max_amount and fields.amount.
Total per month10,000,000 CFA francs403 MONTHLY_LIMIT_REACHED, with remaining.

The limits of payouts to a mobile number (see below) do not apply to bank transfers.

Transfer statuses

A transfer stays processing for 3 to 5 business days, then becomes succeeded or failed_confirmed. On failure, the funds return to the available balance. The events are payout.succeeded and payout.failed, as for a mobile number.

Limits of payouts to a mobile number

Platform defaults, days and months counted in Dakar time: 150,000 CFA francs per payout, 3 payouts and 300,000 CFA francs per day, 1,500,000 CFA francs per month. 221 can adjust them for a merchant. Beyond: 403 WITHDRAWAL_LIMIT_EXCEEDED, with limit_type (per_withdrawal, daily_count, daily_amount or monthly_amount) and limit.

A merchant account can also have a daily or monthly payout limit, and a rail a maximum amount per payout. Beyond: 403 MERCHANT_LIMIT_EXCEEDED (with limit_type and limit) or 400 INVALID_REQUEST with the maximum amount in the message.

Payout fee rate: see Fees.