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:
curl "$API_URL/v1/kyc/status" -H "Authorization: Bearer $API_KEY"{
"status": "verified",
"merchant_status": "active",
"withdrawals_blocked": false,
"withdrawals_blocked_reason": null
}status | Meaning |
|---|---|
not_started | No verification started. |
pending | File under review. |
verified | Identity verified: payouts allowed. |
rejected | File rejected; reason_code gives the reason. |
Before verification, payout routes answer 403 KYC_REQUIRED, with
kyc_status.
1. Register a payout number
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"}'{ "id": "5e8c…", "rail": "sn_wave", "phone_last_digits": "4567", "verified": false }railis 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_LIMITEDbeyond). - An already registered (not removed) number returns
200and 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
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"}'{
"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
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…"}'| Response | Meaning |
|---|---|
201 | Payout processed: status is succeeded or a failure status. |
202 | Payout accepted, outcome pending: only id and status are returned. Read GET /v1/payouts/{id}. |
409 QUOTE_EXPIRED | Quote expired. Request a new quote. |
409 QUOTE_INVALID | Quote unknown or already used. Request a new quote. |
409 IDEMPOTENCY_CONFLICT | Key already used with another body. |
409 INSUFFICIENT_FUNDS | Available balance too low. Nothing is debited. |
503 CHANNEL_UNAVAILABLE | Payouts temporarily suspended on this rail; the balance is untouched. |
Funds are reserved on acceptance: they move from available to reserved in
Balances.
Statuses
| Group | Statuses | Meaning |
|---|---|---|
| In progress | created, reserving, reserved, queued, dispatch_started, processing | Funds reserved, payment being prepared or sent. |
| Succeeded | succeeded | The number received net. Event payout.succeeded. |
| Failed | failed_confirmed, released | Payment declined; funds return to the available balance. Event payout.failed. |
| Unknown | unknown | Uncertain outcome, being checked by 221. |
A failed payout carries failure_reason (absent on older payouts):
failure_reason | Meaning |
|---|---|
destination_limit | The recipient wallet has reached its limit. A smaller amount, another number or a bank account is still possible. |
destination_invalid | Recipient account or number refused by the operator. Check the number or the account. |
provider_unavailable | Payment 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
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"}'{
"id": "7a1d…",
"type": "bank",
"bank_code": "SN012",
"rib_last4": "0154",
"holder_name": "Awa Diop",
"verified": false
}| Field | Format |
|---|---|
rib | 24 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_name | 2 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.
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}/resendsends a new code (3 per hour at most).- Wrong code:
400 INVALID_CODEwithattempts_left. Expired code:410 CODE_EXPIRED. Too many attempts:429 TOO_MANY_ATTEMPTS. - Once the account is verified,
available_atgives the date of the first payout, 24 hours later. - In test mode only, the code
000000is 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.
curl -X POST "$API_URL/v1/payout-quotes" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"destination_id": "7a1d…", "amount": "300000"}'{
"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"
}debitssplitsamountbetween the Senegal Wave and Orange Money balances, pro rata to their available balance.delayannounces the arrival time.- The payout is confirmed as for a mobile number:
POST /v1/payoutswith thequote_hashand anIdempotency-Key.
Fees, minimum and limits
The rate of the tier that contains the amount applies to the whole amount.
| Payout amount | Fee |
|---|---|
| 200,000 to 499,999 CFA francs | 5% |
| 500,000 to 999,999 CFA francs | 4% |
| 1,000,000 to 2,499,999 CFA francs | 3% |
| 2,500,000 to 5,000,000 CFA francs | 2.5% |
GET /v1/fees publishes these tiers in bank_tiers (from, to, bps), along with bank_min, bank_max and bank_monthly_cap.
| Limit | Value | Error beyond |
|---|---|---|
| Minimum per payout | 200,000 CFA francs | 400 BELOW_MINIMUM, with min_amount, max_amount and fields.amount. |
| Maximum per payout | 5,000,000 CFA francs | 400 ABOVE_MAXIMUM, with min_amount, max_amount and fields.amount. |
| Total per month | 10,000,000 CFA francs | 403 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.