Capay Global

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

POST/v1/payouts

Required 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.

FieldTypeDescription
amountrequiredstringAmount 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.
currencyrequiredstring3-letter ISO 4217 code, uppercased (XAF).
countryrequiredstring2-letter ISO 3166 country code, uppercased (CM).
methodrequiredstringOne of mobile_money or bank. card is rejected on this surface with method_not_available.
msisdnrequiredstringRecipient phone number for mobile_money, 8 to 15 digits with an optional leading +. Required when method is mobile_money.
merchant_referencerequiredstringYour own reference for this payout. Max 128 characters, unique per app: a duplicate is rejected with validation_error.
recipient_namestringBeneficiary 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.
metadataobjectArbitrary 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
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" }
  }'
201 response
{
  "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.

201 response (held for approval)
{
  "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

GET/v1/payouts/:id

Fetch 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
curl https://api.capayglobal.com/v1/payouts/po_7Hk3mZ0Qr82Yb \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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

GET/v1/payouts

List 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
curl "https://api.capayglobal.com/v1/payouts?limit=2&status=completed" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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

GET/v1/payouts/:id/events

The 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
curl https://api.capayglobal.com/v1/payouts/po_7Hk3mZ0Qr82Yb/events \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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

POST/v1/payouts/:id/cancel

Cancel 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
curl -X POST https://api.capayglobal.com/v1/payouts/po_9Lp4nQ1Rs73Zc/cancel \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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.

FieldTypeDescription
batchstring | nullPublic 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_indexinteger | nullZero-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.

StatusMeaningTransitions to
initiatedIntent row written; ledger reserve about to run. Never returned by a successful create (the reserve and dispatch resolve first).processing, enqueued, pending, failed, cancelled
pendingHeld before dispatch: awaiting maker-checker approval, a compliance or risk review, or provider OTP/redirect confirmation.processing, enqueued, completed, failed, cancelled, expired, in_reconciliation
enqueuedReserve is held but no provider was available (breaker open or every route down). Drains automatically when a provider recovers.processing, cancelled, failed, in_reconciliation
processingProvider accepted the payout. provider_accepted_at is set: the reserve is never auto-reversed without an independent failed confirmation.completed, failed, in_reconciliation
in_reconciliationOutcome is being re-queried against the provider after a timeout or a discrepancy.completed, failed, cancelled
completedTerminal success. The reserve settled: locked funds left the merchant balance.reversed (provider return only)
failedTerminal failure. reason_code carries the normalized cause; the reserve was released back to available.(terminal)
cancelledTerminal. Cancelled while enqueued or pending; the reserve was released.(terminal)
expiredTerminal. Timed out before completion; the reserve was released.(terminal)
reversedTerminal. 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:

FieldTypeDescription
payout.pendingeventPayout is held before dispatch (approval, review, or provider confirmation).
payout.enqueuedeventReserve held, awaiting an available provider.
payout.processingeventProvider accepted the payout.
payout.completedeventPayout delivered and settled. Terminal.
payout.failedeventPayout failed; reserve released. Terminal.
payout.reversedeventA completed payout was returned by the operator and reversed. Terminal.
payout_batch.completedeventEvery payout of a batch completed. Emitted once, when the batch turns terminal (resource_type payout_batch, id pob_...).
payout_batch.partially_failedeventA batch turned partially_completed: some payouts completed, others did not.
payout_batch.failedeventNo payout of a batch completed.
payout_batch.cancelledeventEvery 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:

payout.completed event
{
  "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:

CodeHTTPReturned when
validation_error422A field is missing or malformed (currency, country, method, msisdn, amount, merchant_reference), or merchant_reference was already used.
method_not_available422method is card, which this surface never serves.
card_data_rejected422The body contained card data (PAN, CVV or expiry). Nothing is stored.
insufficient_balance402The available balance cannot cover amount plus merchant-borne fees. No provider call is made.
limit_exceeded403The payout exceeds a configured merchant, country, provider or platform limit.
risk_rejected403The risk engine declined the payout. No money moved.
compliance_blocked403Sanctions screening blocked the payout. Any reserved funds were released.
insufficient_scope403The API key lacks the payouts:write (create/cancel) or payouts:read (retrieve/list) scope.
idempotency_key_reused422The Idempotency-Key was reused with a different request body.
request_in_flight409A request with this Idempotency-Key is still being processed.
rate_limited429The per-app request quota was exceeded. See the X-RateLimit-* and Retry-After headers.
resource_not_found404No payout matches the id in this environment (retrieve, list cursor, events, cancel).
internal_error500An unexpected server error. Retry with the same Idempotency-Key so the payout cannot double-process.
402 response
{
  "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"
  }
}