Payouts
Send money to a mobile money or bank recipient. Payouts follow a reserve-then-settle model: the available balance is checked and locked before any provider is called, so a payout never leaves money in flight that your balance did not back. Every money-moving call is idempotent and every amount is a decimal string with an explicit currency.
The reserve-then-settle model
Creating a payout writes the intent row first, then reserves funds, then dispatches, in that order. The reserve moves the total debit (amount plus any merchant-borne fees) from your available balance into a locked balance in one atomic step. If the available balance cannot cover it, the call fails with 402 insufficient_balance and no provider is ever contacted.
Once a provider accepts the payout its provider_accepted_at marker is set. From that point the reserve is never released automatically without an independent failed confirmation from a requery: a lone webhook is not enough to claw money back. On success the reserve settles (locked funds leave your balance); on failure, cancellation or expiry the reserve is released back to available. A completed payout the operator later returns moves to reversed and the settlement is unwound.
Create a payout
/v1/payoutsRequired headers: Authorization: Bearer sk_... and Idempotency-Key (1 to 255 characters). The key decides the environment: sk_test_ is sandbox, sk_live_ is production. In the sandbox you may add an X-Scenario-Key header to force a specific outcome. Scope required: payouts:write.
| Field | Type | Description |
|---|---|---|
| amountrequired | string | Amount to pay out, as a decimal string in the currency's own units. XAF and XOF are zero-decimal ("30000" = 30,000 XAF); two-decimal currencies carry at most two places ("12.50"). Never a JSON number. |
| currencyrequired | string | 3-letter ISO 4217 code, uppercased (XAF). |
| countryrequired | string | 2-letter ISO 3166 country code, uppercased (CM). |
| methodrequired | string | One of mobile_money or bank. card is rejected on this surface with method_not_available. |
| msisdnrequired | string | Recipient phone number for mobile_money, 8 to 15 digits with an optional leading +. Required when method is mobile_money. |
| merchant_referencerequired | string | Your own reference for this payout. Max 128 characters, unique per app: a duplicate is rejected with validation_error. |
| recipient_name | string | Beneficiary name, truncated to 140 characters. Passed to the provider with the transfer, so where the operator supports it the name rides on the recipient's prompt and statement; also used for the payout record and name-match checks. |
| metadata | object | Arbitrary key/value object echoed back on the payout and its webhooks. Card data is never accepted here and is rejected with card_data_rejected. |
A successful create returns 201 with the serialized payout. In the sandbox the simulator resolves it optimistically to processing (then completed shortly after); nothing touches a real provider. total_debit is amount plus merchant-borne fees and is null until the reserve succeeds.
curl -X POST https://api.capayglobal.com/v1/payouts \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Idempotency-Key: po-payroll-2026-08-0142" \
-H "Content-Type: application/json" \
-d '{
"amount": "30000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"recipient_name": "Awa Ngono",
"merchant_reference": "payroll-2026-08-0142",
"metadata": { "run": "august-payroll" }
}'{
"status": "success",
"data": {
"id": "po_7Hk3mZ0Qr82Yb",
"object": "payout",
"status": "processing",
"amount": "30000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"recipient_name": "Awa Ngono",
"merchant_reference": "payroll-2026-08-0142",
"metadata": { "run": "august-payroll" },
"fees": {
"platform_fee": "300",
"provider_fee": "0",
"total_fee": "300",
"bearer": "merchant"
},
"total_debit": "30300",
"reason_code": null,
"requires_requery": false,
"approval_status": "NOT_REQUIRED",
"hold_reasons": [],
"held_for_dispatch": false,
"batch": null,
"batch_index": null,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-29T10:15:32.918Z",
"completed_at": null,
"expires_at": null
},
"request_id": "b1d4e7a2-9c33-4f16-8a70-2c05e9f21d3b"
}Maker-checker approval
At or over the approval threshold configured for your merchant (or the platform default), a payout parks for a second pair of eyes before any dispatch. The create still returns 201, but the payout comes back with status pending, approval_status PENDING_APPROVAL, held_for_dispatch true and AMOUNT_THRESHOLD in hold_reasons. Its reserve stays locked and nothing is sent.
Approval is a human action taken in the dashboard by an OWNER or ADMIN member: the API key that created the payout can never approve its own request. There is no approve endpoint on this API. Once an approver clears it (and any compliance or risk holds are cleared), the payout dispatches automatically. If it is rejected instead, it moves to cancelled and the reserve is released. A risk review adds RISK_REVIEW and a sanctions hit adds SANCTIONS_REVIEW to hold_reasons the same way.
{
"status": "success",
"data": {
"id": "po_9Lp4nQ1Rs73Zc",
"object": "payout",
"status": "pending",
"amount": "2500000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000042",
"recipient_name": "Fournisseur SARL",
"merchant_reference": "supplier-inv-88213",
"metadata": {},
"fees": {
"platform_fee": "25000",
"provider_fee": "0",
"total_fee": "25000",
"bearer": "merchant"
},
"total_debit": "2525000",
"reason_code": null,
"requires_requery": false,
"approval_status": "PENDING_APPROVAL",
"hold_reasons": ["AMOUNT_THRESHOLD"],
"held_for_dispatch": true,
"batch": null,
"batch_index": null,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-29T10:21:04.550Z",
"completed_at": null,
"expires_at": null
},
"request_id": "c7e2a911-4d80-42fb-9c31-6a05e9f21e4a"
}Idempotency
The Idempotency-Key header is mandatory on create. Replaying the exact same body with the same key returns the stored response verbatim (with an Idempotency-Replayed: true header) rather than creating a second payout. Reusing a key with a different body is rejected with idempotency_key_reused; sending it again while the first is still in flight returns request_in_flight. On a 5xx you can safely retry with the same key. Cancellation does not require an Idempotency-Key.
Retrieve a payout
/v1/payouts/:idFetch one payout by its public id (po_...) or raw uuid, scoped to your merchant and environment. Scope required: payouts:read. An unknown id returns 404 resource_not_found.
curl https://api.capayglobal.com/v1/payouts/po_7Hk3mZ0Qr82Yb \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"id": "po_7Hk3mZ0Qr82Yb",
"object": "payout",
"status": "completed",
"amount": "30000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"recipient_name": "Awa Ngono",
"merchant_reference": "payroll-2026-08-0142",
"metadata": { "run": "august-payroll" },
"fees": {
"platform_fee": "300",
"provider_fee": "0",
"total_fee": "300",
"bearer": "merchant"
},
"total_debit": "30300",
"reason_code": null,
"requires_requery": false,
"approval_status": "NOT_REQUIRED",
"hold_reasons": [],
"held_for_dispatch": false,
"batch": null,
"batch_index": null,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-29T10:15:32.918Z",
"completed_at": "2026-08-29T10:15:41.204Z",
"expires_at": null
},
"request_id": "1f2e3d4c-5b6a-4790-8123-abcdef012345"
}List payouts
/v1/payoutsList payouts newest first. Query parameters: limit (1 to 100, default 10), status (filter by any single status), batch (only the payouts of one batch, by its pob_ id; an unknown batch returns 404 resource_not_found), and starting_after (a payout id to page past, keyset pagination). The envelope wraps a list object whose has_more tells you whether more rows follow. Scope required: payouts:read.
curl "https://api.capayglobal.com/v1/payouts?limit=2&status=completed" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"object": "list",
"data": [
{
"id": "po_7Hk3mZ0Qr82Yb",
"object": "payout",
"status": "completed",
"amount": "30000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"recipient_name": "Awa Ngono",
"merchant_reference": "payroll-2026-08-0142",
"metadata": { "run": "august-payroll" },
"fees": { "platform_fee": "300", "provider_fee": "0", "total_fee": "300", "bearer": "merchant" },
"total_debit": "30300",
"reason_code": null,
"requires_requery": false,
"approval_status": "NOT_REQUIRED",
"hold_reasons": [],
"held_for_dispatch": false,
"batch": null,
"batch_index": null,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-29T10:15:32.918Z",
"completed_at": "2026-08-29T10:15:41.204Z",
"expires_at": null
}
],
"has_more": true
},
"request_id": "2b3c4d5e-6f70-4812-9a34-bcdef0123456"
}Payout event history
/v1/payouts/:id/eventsThe append-only status history of one payout, oldest first: every transition with its from_status, to_status, reason_code, the actor that moved it (api, simulator, webhook, reconciler, requery, admin) and a payload. Scope required: payouts:read.
curl https://api.capayglobal.com/v1/payouts/po_7Hk3mZ0Qr82Yb/events \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"object": "list",
"payout": "po_7Hk3mZ0Qr82Yb",
"data": [
{
"from_status": null,
"to_status": "initiated",
"reason_code": null,
"actor": "api",
"payload": { "routing": { "provider": "simulator", "priority": 1, "degraded": false } },
"created_at": "2026-08-29T10:15:32.918Z"
},
{
"from_status": "initiated",
"to_status": "processing",
"reason_code": null,
"actor": "api",
"payload": { "scenario": "success" },
"created_at": "2026-08-29T10:15:33.010Z"
},
{
"from_status": "processing",
"to_status": "completed",
"reason_code": null,
"actor": "simulator",
"payload": {},
"created_at": "2026-08-29T10:15:41.204Z"
}
],
"has_more": false
},
"request_id": "3c4d5e6f-7081-4923-ab45-cdef01234567"
}Cancel a payout
/v1/payouts/:id/cancelCancel a payout that has not yet been dispatched: only enqueued or pending payouts can be cancelled. Any other status returns validation_error. Cancellation releases the reserve back to your available balance. Scope required: payouts:write. No Idempotency-Key is needed.
curl -X POST https://api.capayglobal.com/v1/payouts/po_9Lp4nQ1Rs73Zc/cancel \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"id": "po_9Lp4nQ1Rs73Zc",
"object": "payout",
"status": "cancelled",
"amount": "2500000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000042",
"recipient_name": "Fournisseur SARL",
"merchant_reference": "supplier-inv-88213",
"metadata": {},
"fees": { "platform_fee": "25000", "provider_fee": "0", "total_fee": "25000", "bearer": "merchant" },
"total_debit": "2525000",
"reason_code": null,
"requires_requery": false,
"approval_status": "PENDING_APPROVAL",
"hold_reasons": ["AMOUNT_THRESHOLD"],
"held_for_dispatch": true,
"batch": null,
"batch_index": null,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-08-29T10:21:04.550Z",
"completed_at": null,
"expires_at": null
},
"request_id": "4d5e6f70-8192-4a34-bc56-def012345678"
}Batch membership
A payout created through POST /v1/payouts/batch is a regular payout: same object, same statuses, same events, same cancel rules. Two fields say where it came from. Everything else about batches (one reserve for the whole total, one approval on the sum, the batch status roll-up and the payout_batch.* events) is on the Batch payouts page.
| Field | Type | Description |
|---|---|---|
| batch | string | null | Public id (pob_...) of the batch this payout was created in through POST /v1/payouts/batch, or null for a payout created on its own. Present on every payout object, in every response and webhook. |
| batch_index | integer | null | Zero-based position of the payout inside its batch (the order of the items array you sent), or null outside a batch. GET /v1/payouts/batches/:id pages items in this order. |
Status lifecycle
A payout is terminal in one of completed, failed, cancelled, expired or reversed. The only transition out of a terminal state is the completed to reversed return path below.
| Status | Meaning | Transitions to |
|---|---|---|
| initiated | Intent row written; ledger reserve about to run. Never returned by a successful create (the reserve and dispatch resolve first). | processing, enqueued, pending, failed, cancelled |
| pending | Held before dispatch: awaiting maker-checker approval, a compliance or risk review, or provider OTP/redirect confirmation. | processing, enqueued, completed, failed, cancelled, expired, in_reconciliation |
| enqueued | Reserve is held but no provider was available (breaker open or every route down). Drains automatically when a provider recovers. | processing, cancelled, failed, in_reconciliation |
| processing | Provider accepted the payout. provider_accepted_at is set: the reserve is never auto-reversed without an independent failed confirmation. | completed, failed, in_reconciliation |
| in_reconciliation | Outcome is being re-queried against the provider after a timeout or a discrepancy. | completed, failed, cancelled |
| completed | Terminal success. The reserve settled: locked funds left the merchant balance. | reversed (provider return only) |
| failed | Terminal failure. reason_code carries the normalized cause; the reserve was released back to available. | (terminal) |
| cancelled | Terminal. Cancelled while enqueued or pending; the reserve was released. | (terminal) |
| expired | Terminal. Timed out before completion; the reserve was released. | (terminal) |
| reversed | Terminal. The operator returned an already-completed payout; the settle was reversed and funds credited back. | (terminal) |
Reversal and returns
A completed payout can still be returned by the operator, for example when the destination rejects the credit. This is the one move out of a terminal state: completed transitions to reversed, driven only by a webhook, a requery or the simulator, never by your API call. The settlement journal is reversed and the funds are credited back to your balance. Because of the provider_accepted_at anti-double-dip rule, this only happens on an independent confirmation, so a settled payout is never clawed back on a stray signal. The reversal is surfaced as the payout.reversed webhook.
Payout webhooks
As a payout moves through its lifecycle we emit events to your webhook endpoints and to the GET /v1/events stream. Each event carries the payout snapshot in its data field. Deliveries are signed with the X-Capay-Signature header (see Authentication): verify the signature against the raw body before trusting the payload. The payout event types are:
| Field | Type | Description |
|---|---|---|
| payout.pending | event | Payout is held before dispatch (approval, review, or provider confirmation). |
| payout.enqueued | event | Reserve held, awaiting an available provider. |
| payout.processing | event | Provider accepted the payout. |
| payout.completed | event | Payout delivered and settled. Terminal. |
| payout.failed | event | Payout failed; reserve released. Terminal. |
| payout.reversed | event | A completed payout was returned by the operator and reversed. Terminal. |
| payout_batch.completed | event | Every payout of a batch completed. Emitted once, when the batch turns terminal (resource_type payout_batch, id pob_...). |
| payout_batch.partially_failed | event | A batch turned partially_completed: some payouts completed, others did not. |
| payout_batch.failed | event | No payout of a batch completed. |
| payout_batch.cancelled | event | Every payout of a batch was cancelled. |
The four payout_batch.* rows are emitted for the batch as a whole (data carries the batch, not a payout) and only on top of the per-payout events above. The delivered payout event has this shape:
{
"id": "evt_2n4Kq8Zr41Xa",
"object": "event",
"type": "payout.completed",
"created_at": "2026-08-29T10:15:41.260Z",
"livemode": false,
"data": {
"id": "po_7Hk3mZ0Qr82Yb",
"object": "payout",
"status": "completed",
"amount": "30000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"reason_code": null,
"merchant_reference": "payroll-2026-08-0142",
"livemode": false
}
}Errors
Payout endpoints use the shared error envelope and the closed error catalog. The codes you are most likely to see on this resource:
| Code | HTTP | Returned when |
|---|---|---|
| validation_error | 422 | A field is missing or malformed (currency, country, method, msisdn, amount, merchant_reference), or merchant_reference was already used. |
| method_not_available | 422 | method is card, which this surface never serves. |
| card_data_rejected | 422 | The body contained card data (PAN, CVV or expiry). Nothing is stored. |
| insufficient_balance | 402 | The available balance cannot cover amount plus merchant-borne fees. No provider call is made. |
| limit_exceeded | 403 | The payout exceeds a configured merchant, country, provider or platform limit. |
| risk_rejected | 403 | The risk engine declined the payout. No money moved. |
| compliance_blocked | 403 | Sanctions screening blocked the payout. Any reserved funds were released. |
| insufficient_scope | 403 | The API key lacks the payouts:write (create/cancel) or payouts:read (retrieve/list) scope. |
| idempotency_key_reused | 422 | The Idempotency-Key was reused with a different request body. |
| request_in_flight | 409 | A request with this Idempotency-Key is still being processed. |
| rate_limited | 429 | The per-app request quota was exceeded. See the X-RateLimit-* and Retry-After headers. |
| resource_not_found | 404 | No payout matches the id in this environment (retrieve, list cursor, events, cancel). |
| internal_error | 500 | An unexpected server error. Retry with the same Idempotency-Key so the payout cannot double-process. |
{
"status": "error",
"error": {
"type": "invalid_request_error",
"code": "insufficient_balance",
"message": "The available balance is insufficient to cover this request.",
"next_step": "Top up your balance or wait for pending settlements to clear, then retry.",
"request_id": "8a1c0f34-2e77-4b19-9d02-5f7c8e1a2b3c"
}
}