Batch payouts
Send up to 500 payouts in one currency as a single batch: one Idempotency-Key, one atomic reserve of the whole total, one approval decision on the sum. Every item is a regular payout (same object, same statuses, same events) that carries its batch id and position, and the batch rolls their outcomes up into one status and one set of counts.
What a batch is
A batch is N payout rows that share one reserve transaction and one approval decision. Nothing about a single payout changes: routing, limits, fees, risk scoring, sanctions screening, dispatch and the provider outcome all happen per item, exactly as for POST /v1/payouts. What the batch adds is the money and control model around them: the available balance is reserved for the whole total (amounts plus merchant-borne fees) in one step, so a batch never half-starts; the maker-checker threshold is compared once, against the SUM of the item amounts; and the batch object exposes counts and a roll-up status so you can follow 500 payouts through one id.
Batches are all-or-nothing on the way in and per item on the way out. Any invalid item rejects the whole request and writes nothing; once created, each item succeeds or fails on its own and the batch ends completed, partially_completed, failed or cancelled accordingly. A batch cannot be edited: fix the file and create a new one.
One currency per batch. Items may target different countries, methods and providers as long as they share the currency; the reserve is taken from that currency balance.
Create a batch
Create the batch and its items in one call. The handler validates every item, resolves a route, the limits and the fee for each one, inserts the batch and its payouts (status initiated), runs risk scoring on all of them, then reserves the total. If the reserve succeeds the call returns 201 and the intake (sanctions screening, holds, the move to pending) continues after the response.
Headers
Authorization: Bearer sk_...- Your secret key. The payouts:write scope is required. The key decides the environment: sk_test_ is sandbox, sk_live_ is production.Idempotency-Key- Required, 1 to 255 characters, at batch level. Resending the same key with the same body replays the stored 201 (with Idempotency-Replayed: true); the same key with a different body answers 422 idempotency_key_reused; a key still in flight answers 409 request_in_flight. Every 422 or 402 asks you to retry with a NEW key.X-Scenario-Key- Sandbox only, optional. Applies the same simulator scenario to every item. As strict as on a single payout: an unknown key answers 422 with a validation_errors entry for X-Scenario-Key before any row is written.X-Correlation-Id- Optional trace id (printable ASCII, max 128), echoed back and recorded in the API usage log.Content-Type: application/json- The request body is JSON.
Request body
| Parameter | Type | Description |
|---|---|---|
| currencyrequired | string | 3-letter ISO 4217 code, uppercased. The one currency of every item; the reserve is taken from this balance. |
| itemsrequired | array | 1 to 500 payout items (below). An empty array answers no_items, more than 500 answers too_many_items: split the file. |
| titleoptional | string | A label for your dashboard, max 140 characters. Never shown to recipients. |
| merchant_referenceoptional | string | Your reference for the batch, max 128 characters, unique per app across batches. |
| allow_duplicate_recipientsoptional | boolean | Default false: two items with the same phone number and the same amount are rejected with duplicate_recipient, the usual sign of a duplicated row. Set true when both payouts are intended. |
| metadataoptional | object | Free-form batch metadata, echoed back on the batch. Card data is never accepted anywhere in the body (card_data_rejected). |
Fields of each item
| Parameter | Type | Description |
|---|---|---|
| amountrequired | string | Decimal string in the batch currency. XAF and XOF are zero-decimal ("150000" = 150,000 XAF). Never a JSON number. |
| countryrequired | string | 2-letter ISO 3166 code, uppercased (CM). |
| methodrequired | string | mobile_money or bank. card answers method_not_available on the item. |
| merchant_referencerequired | string | Your reference for this payout, max 128 characters. Unique per app across every payout ever created (reference_in_use otherwise) and unique inside the batch (duplicate_in_batch otherwise). |
| msisdnoptional | string | Recipient phone number, 8 to 15 digits with an optional leading +. Required when method is mobile_money. |
| recipient_nameoptional | string | Beneficiary name, max 140 characters; passed to the provider with the transfer. |
| metadataoptional | object | Free-form metadata for this payout, echoed on the payout and its webhooks. The server adds batch (the pob_ id) and created_by to it. |
Example request
curl -X POST https://api.capayglobal.com/v1/payouts/batch \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Idempotency-Key: payroll-2026-03" \
-H "Content-Type: application/json" \
-d '{
"currency": "XAF",
"title": "March payroll",
"merchant_reference": "payroll-2026-03",
"allow_duplicate_recipients": false,
"metadata": { "department": "ops" },
"items": [
{ "amount": "150000", "country": "CM", "method": "mobile_money", "msisdn": "+237670000001",
"recipient_name": "Amina Njoya", "merchant_reference": "emp-0017", "metadata": { "employee_id": "17" } },
{ "amount": "80000", "country": "CM", "method": "mobile_money", "msisdn": "+237670000002",
"recipient_name": "Paul Etoa", "merchant_reference": "emp-0018" }
]
}'Example response
{
"status": "success",
"data": {
"id": "pob_7Kq2mN9xAbCd",
"object": "payout_batch",
"status": "processing",
"approval_status": "NOT_REQUIRED",
"currency": "XAF",
"title": "March payroll",
"merchant_reference": "payroll-2026-03",
"total_amount": "230000",
"total_fee": "2300",
"total_debit": "232300",
"counts": { "total": 2, "open": 2, "completed": 0, "failed": 0, "cancelled": 0, "reversed": 0 },
"reason_code": null,
"metadata": { "department": "ops" },
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": null,
"items": [
{ "id": "po_9fQ2r7Xm4KdA", "index": 0, "merchant_reference": "emp-0017", "status": "initiated" },
{ "id": "po_a1B8t3Yq5Le0", "index": 1, "merchant_reference": "emp-0018", "status": "initiated" }
]
},
"request_id": "d4e7a2b1-9c33-4f16-8a70-2c05e9f21d3b"
}A successful create returns 201 with the batch and a compact reference map of its items in batch_index order: id, index, merchant_reference and status (initiated at that moment). This keeps the stored idempotency snapshot small for 500 items; fetch the full payout objects with GET /v1/payouts/batches/:id or GET /v1/payouts?batch=. total_amount is the sum of the amounts, total_fee the sum of the fees, total_debit what the reserve actually locked.
The batch comes back with status processing (the roll-up trigger moves it off created as soon as items exist). In the sandbox the simulator resolves every item on its own; nothing touches a real provider.
Validation: all or nothing
Every item is validated before anything is written, and every issue is reported, not just the first, so a 500-line payroll is fixed in one round trip. If any issue exists the whole batch answers 422 validation_error, validation_errors lists each one with its path (items[7].msisdn), a machine-readable code and a message, and zero payouts are created. The same applies to a merchant_reference already used by an earlier payout (reference_in_use) and to card data anywhere in the body (422 card_data_rejected).
Routing and limits are checked per item after structural validation. A route failure that cannot be enqueued (unsupported country or currency, a prohibited or closed corridor, a paused service, no provider at all) answers the HTTP status of the FIRST failing code, with every failing item listed in validation_errors; a limit failure answers 403 limit_exceeded or amount_below_minimum the same way. A provider outage that a single payout would survive by being enqueued does not fail the batch either: those items are created and enqueued with their reserve held.
{
"status": "error",
"error": {
"type": "validation_error",
"code": "validation_error",
"message": "One or more request parameters are invalid.",
"next_step": "Fix the fields listed in validation_errors and retry with a new Idempotency-Key. No payout was created.",
"validation_errors": [
{ "field": "items[2].msisdn", "code": "invalid_msisdn", "message": "msisdn is required for mobile_money (8-15 digits, optional +)" },
{ "field": "items[4].merchant_reference", "code": "duplicate_in_batch", "message": "merchant_reference duplicates items[1]" },
{ "field": "items[7].msisdn", "code": "duplicate_recipient", "message": "same recipient and amount as items[3]; set allow_duplicate_recipients to true if both payouts are intended" }
],
"request_id": "8a1c0f34-2e77-4b19-9d02-5f7c8e1a2b3c"
}
}validation_errors[].code values
| Code | Description |
|---|---|
| no_items / too_many_items | items is empty, or holds more than 500 rows (field items). |
| invalid_currency / invalid_title / invalid_reference / invalid_flag / invalid_metadata | A batch-level field is malformed (currency, title, merchant_reference, allow_duplicate_recipients, metadata). |
| invalid_item | items[i] is not an object. |
| invalid_amount / invalid_country / invalid_method / invalid_msisdn / invalid_recipient_name | The named field of items[i] is missing or malformed. invalid_reference on an item means merchant_reference is missing or longer than 128 characters. |
| duplicate_in_batch | items[i].merchant_reference repeats an earlier item; the message names it (duplicates items[1]). |
| duplicate_recipient | items[i] has the same phone number and amount as an earlier item and allow_duplicate_recipients is not true. Reported on items[i].msisdn. |
| reference_in_use | items[i].merchant_reference was already used by another payout of this app. |
| unsupported_country / unsupported_currency / method_not_available / corridor_closed / prohibited_corridor / service_paused / provider_unavailable | Routing failed for items[i]; the HTTP status is that of the first failing code (422, 403 or 503). |
| limit_exceeded / amount_below_minimum | items[i].amount breaks a configured limit (403), or the aggregate does (field items). |
Reserve and insufficient balance
After the items are inserted and risk-scored, the whole total (every surviving item's amount plus merchant-borne fees) is reserved from your available balance in one atomic step: it moves into the locked balance for the batch. If the available balance cannot cover it, the call answers 402 insufficient_balance, the batch is recorded as failed, every item is failed with reason_code insufficient_balance, and nothing was dispatched. Top up (or wait for pending settlements) and retry with a new Idempotency-Key.
Do not confuse the two codes: insufficient_balance is YOUR available balance on the gateway ledger (category PLATFORM, an error at creation or a reason on the batch); insufficient_funds is the recipient or payer wallet on the operator side (category CUSTOMER, a reason on a single payout that failed after dispatch).
From the reserve on, each item settles or releases its own share: a completed item settles (locked funds leave your balance), a failed, cancelled or expired item releases its share back to available, a reversed item credits it back. The batch's total_debit stays what the reserve locked.
{
"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 with a new Idempotency-Key. Batch pob_7Kq2mN9xAbCd was recorded as failed; no item was dispatched.",
"request_id": "9b2d1a45-3f88-4c2a-8e13-6a8d9f2b3c4d"
}
}Asynchronous intake and dispatch
The 201 is returned as soon as the reserve holds. The intake then runs in the background (and is resumed by the payout reconciler if it is interrupted): every item is screened and either moves to pending (queued for dispatch, or held with a reason in hold_reasons: AMOUNT_THRESHOLD, RISK_REVIEW, SANCTIONS_REVIEW) or fails right away when risk rejects it or screening blocks it. Dispatch takes pending, unheld items to processing when a provider accepts them; the provider outcome then sets the terminal status: completed, failed, expired, and later possibly reversed.
So an item goes initiated, pending, processing, then terminal, and for a while after the 201 the batch shows every item as open. Do not read the creation response as an outcome. Poll GET /v1/payouts/batches/:id (counts and status) or subscribe to the payout.* events for each item and the payout_batch.* events for the batch as a whole. In the sandbox the simulator resolves items within seconds; in production the provider's own timing applies.
Items that could not reach a provider (breaker open, every route down) are enqueued with their reserve held and drained automatically when a provider recovers, exactly like a single payout.
Maker-checker on the batch
The approval threshold configured for your merchant (or the platform default) is compared once, against the sum of the item amounts: at or above it, the whole batch is created with approval_status PENDING_APPROVAL and every item is created on hold, with AMOUNT_THRESHOLD in hold_reasons and held_for_dispatch true. The reserve is taken all the same, so the money is locked while a second person decides. Batches created from a CSV upload by a FINANCE member always require approval.
Approval is a human action taken in the dashboard by an OWNER or ADMIN member who is not the creator; the API key that created the batch can never approve it and there is no approve endpoint. Approving the batch approves every pending item at once: items with no other hold are released for dispatch, items still under a risk or sanctions review stay held until compliance clears them, and the batch shows approval_status APPROVED. Rejecting it in the dashboard, or cancelling it through the API while it waits, cancels every undispatched item with reason_code batch_cancelled, releases their reserves and stamps approval_status REJECTED.
{
"status": "success",
"data": {
"id": "pob_8Lr3nP0yBcDe",
"object": "payout_batch",
"status": "processing",
"approval_status": "PENDING_APPROVAL",
"currency": "XAF",
"title": "Supplier settlement",
"merchant_reference": "suppliers-2026-09",
"total_amount": "12500000",
"total_fee": "125000",
"total_debit": "12625000",
"counts": { "total": 3, "open": 3, "completed": 0, "failed": 0, "cancelled": 0, "reversed": 0 },
"reason_code": null,
"metadata": {},
"livemode": false,
"created_at": "2026-09-04T10:21:04.550Z",
"completed_at": null,
"items": [
{ "id": "po_b2C9u4Zr6Mf1", "index": 0, "merchant_reference": "inv-88213", "status": "initiated" },
{ "id": "po_c3D0v5As7Ng2", "index": 1, "merchant_reference": "inv-88214", "status": "initiated" },
{ "id": "po_d4E1w6Bt8Oh3", "index": 2, "merchant_reference": "inv-88215", "status": "initiated" }
]
},
"request_id": "c7e2a911-4d80-42fb-9c31-6a05e9f21e4a"
}Retrieve a batch
Fetch one batch by its public id (pob_...) or raw uuid, scoped to your merchant and environment, together with one page of its items as full payout objects in batch_index order. Scope required: payouts:read. An unknown id answers 404 resource_not_found.
Query parameters
| Parameter | Type | Description |
|---|---|---|
| limitoptional | integer | Items per page, 1 to 100, default 25. |
| statusoptional | string | Only items in this payout status (initiated, pending, processing, enqueued, in_reconciliation, completed, failed, cancelled, expired, reversed). |
| starting_afteroptional | string | An item id (po_...) of THIS batch: returns the items after its batch_index. An id that is not in the batch answers 404. |
Example request
curl "https://api.capayglobal.com/v1/payouts/batches/pob_7Kq2mN9xAbCd?limit=100" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
Example response
{
"status": "success",
"data": {
"id": "pob_7Kq2mN9xAbCd",
"object": "payout_batch",
"status": "partially_completed",
"approval_status": "NOT_REQUIRED",
"currency": "XAF",
"title": "March payroll",
"merchant_reference": "payroll-2026-03",
"total_amount": "230000",
"total_fee": "2300",
"total_debit": "232300",
"counts": { "total": 2, "open": 0, "completed": 1, "failed": 1, "cancelled": 0, "reversed": 0 },
"reason_code": null,
"metadata": { "department": "ops" },
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": "2026-09-04T09:12:41.204Z",
"items": {
"object": "list",
"data": [
{
"id": "po_9fQ2r7Xm4KdA",
"object": "payout",
"status": "completed",
"amount": "150000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237670000001",
"recipient_name": "Amina Njoya",
"merchant_reference": "emp-0017",
"metadata": { "employee_id": "17", "batch": "pob_7Kq2mN9xAbCd", "created_by": "api_key:key_...", "statement_description": "Douala Coffee Roasters" },
"fees": { "platform_fee": "1500", "provider_fee": "0", "total_fee": "1500", "bearer": "merchant" },
"total_debit": "151500",
"reason_code": null,
"requires_requery": false,
"approval_status": "NOT_REQUIRED",
"hold_reasons": [],
"held_for_dispatch": false,
"batch": "pob_7Kq2mN9xAbCd",
"batch_index": 0,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": "2026-09-04T09:12:09.318Z",
"expires_at": null
},
{
"id": "po_a1B8t3Yq5Le0",
"object": "payout",
"status": "failed",
"amount": "80000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237670000002",
"recipient_name": "Paul Etoa",
"merchant_reference": "emp-0018",
"metadata": { "batch": "pob_7Kq2mN9xAbCd", "created_by": "api_key:key_...", "statement_description": "Douala Coffee Roasters" },
"fees": { "platform_fee": "800", "provider_fee": "0", "total_fee": "800", "bearer": "merchant" },
"total_debit": "80800",
"reason_code": "invalid_customer",
"requires_requery": false,
"approval_status": "NOT_REQUIRED",
"hold_reasons": [],
"held_for_dispatch": false,
"batch": "pob_7Kq2mN9xAbCd",
"batch_index": 1,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": "2026-09-04T09:12:41.204Z",
"expires_at": null
}
],
"has_more": false
}
},
"request_id": "1f2e3d4c-5b6a-4790-8123-abcdef012345"
}List batches
List batches newest first, without their items. Keyset pagination like the payouts list: limit (1 to 100, default 10), status (one batch status, see the lifecycle below) and starting_after (a batch id to page past; unknown answers 404). Scope required: payouts:read.
Query parameters
| Parameter | Type | Description |
|---|---|---|
| limitoptional | integer | Page size, 1 to 100, default 10. |
| statusoptional | string | created, processing, completed, partially_completed, failed or cancelled. Any other value answers 422. |
| starting_afteroptional | string | A batch id (pob_...) to page past. |
Example request
curl "https://api.capayglobal.com/v1/payouts/batches?limit=2&status=partially_completed" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
Example response
{
"status": "success",
"data": {
"object": "list",
"data": [
{
"id": "pob_7Kq2mN9xAbCd",
"object": "payout_batch",
"status": "partially_completed",
"approval_status": "NOT_REQUIRED",
"currency": "XAF",
"title": "March payroll",
"merchant_reference": "payroll-2026-03",
"total_amount": "230000",
"total_fee": "2300",
"total_debit": "232300",
"counts": { "total": 2, "open": 0, "completed": 1, "failed": 1, "cancelled": 0, "reversed": 0 },
"reason_code": null,
"metadata": { "department": "ops" },
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": "2026-09-04T09:12:41.204Z"
}
],
"has_more": true
},
"request_id": "2b3c4d5e-6f70-4812-9a34-bcdef0123456"
}Cancel a batch
Cancel everything that has not left yet. The handler cancels every item a provider has not accepted (status initiated, pending or enqueued with no provider acceptance) with reason_code batch_cancelled and releases their reserves; items already with a provider are left untouched and finish on their own, so a batch that is half dispatched ends partially_completed rather than cancelled. A batch still waiting for approval is stamped approval_status REJECTED. Scope required: payouts:write; no Idempotency-Key is read.
Only a created or processing batch can be cancelled. A terminal batch answers 422 validation_error with a validation_errors entry on status. The body is optional; a reason of up to 500 characters is recorded on every cancelled item's transition and, for a rejected batch, on the batch.
Request body
| Parameter | Type | Description |
|---|---|---|
| reasonoptional | string | Optional, max 500 characters. Stored for your audit trail; never shown to recipients. |
Example request
curl -X POST https://api.capayglobal.com/v1/payouts/batches/pob_8Lr3nP0yBcDe/cancel \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{ "reason": "Duplicate payroll run" }'Example response
{
"status": "success",
"data": {
"id": "pob_8Lr3nP0yBcDe",
"object": "payout_batch",
"status": "cancelled",
"approval_status": "REJECTED",
"currency": "XAF",
"title": "Supplier settlement",
"merchant_reference": "suppliers-2026-09",
"total_amount": "12500000",
"total_fee": "125000",
"total_debit": "12625000",
"counts": { "total": 3, "open": 0, "completed": 0, "failed": 0, "cancelled": 3, "reversed": 0 },
"reason_code": null,
"metadata": {},
"livemode": false,
"created_at": "2026-09-04T10:21:04.550Z",
"completed_at": "2026-09-04T10:40:12.007Z",
"items": {
"object": "list",
"data": [
{ "id": "po_b2C9u4Zr6Mf1", "object": "payout", "status": "cancelled", "reason_code": "batch_cancelled", "batch": "pob_8Lr3nP0yBcDe", "batch_index": 0, "...": "..." },
{ "id": "po_c3D0v5As7Ng2", "object": "payout", "status": "cancelled", "reason_code": "batch_cancelled", "batch": "pob_8Lr3nP0yBcDe", "batch_index": 1, "...": "..." },
{ "id": "po_d4E1w6Bt8Oh3", "object": "payout", "status": "cancelled", "reason_code": "batch_cancelled", "batch": "pob_8Lr3nP0yBcDe", "batch_index": 2, "...": "..." }
],
"has_more": false
},
"cancel": { "cancelled": 3, "skipped": 0 }
},
"request_id": "4d5e6f70-8192-4a34-bc56-def012345678"
}{
"status": "error",
"error": {
"type": "validation_error",
"code": "validation_error",
"message": "One or more request parameters are invalid.",
"next_step": "Fix the fields listed in validation_errors and retry the request.",
"validation_errors": [
{ "field": "status", "message": "only processing batches can be cancelled (current: completed)" }
],
"request_id": "5e6f7081-92a3-4b45-8c67-ef0123456789"
}
}The items of a batch through GET /v1/payouts
Every payout of a batch is also a regular payout: GET /v1/payouts/:id returns it with batch (the pob_ id) and batch_index, GET /v1/payouts/:id/events returns its history, and POST /v1/payouts/:id/cancel cancels it alone under the usual rules. To page one batch's payouts with the regular list filters, add batch=pob_... to GET /v1/payouts; an unknown batch answers 404 resource_not_found.
curl "https://api.capayglobal.com/v1/payouts?batch=pob_7Kq2mN9xAbCd&status=failed&limit=100" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
The batch object
Every batch endpoint returns the same object; the create, retrieve and cancel answers add items, and the cancel answer adds cancel.
| Field | Type | Description |
|---|---|---|
| id | string | Batch id, prefixed pob_. |
| object | string | Always "payout_batch". |
| status | string | Batch status, derived from the items. See the lifecycle below. |
| approval_status | string | NOT_REQUIRED, PENDING_APPROVAL, APPROVED or REJECTED: the maker-checker state of the whole batch. |
| currency | string | The batch currency, shared by every item. |
| title | string | null | The title you supplied, or null. |
| merchant_reference | string | null | The batch reference you supplied, or null. |
| total_amount | string | Sum of the item amounts, as a decimal string in the batch currency. |
| total_fee | string | Sum of the item fees (platform plus provider). |
| total_debit | string | null | What the reserve actually locked: amounts plus merchant-borne fees over the reserved items. null until the reserve ran. |
| counts | object | total, open (initiated, pending, processing, enqueued, in_reconciliation), completed, failed (failed and expired), cancelled, reversed. Maintained by the database on every item transition. |
| reason_code | string | null | insufficient_balance when the reserve was refused and every item failed; null otherwise. |
| metadata | object | The batch metadata you supplied. |
| livemode | boolean | false in the sandbox, true in production. |
| created_at | string | ISO 8601 creation timestamp. |
| completed_at | string | null | ISO 8601 timestamp set once, when the batch first reaches a terminal status; null before. |
| items | array | object | On create: an array of { id, index, merchant_reference, status }. On retrieve and cancel: a list object { object: "list", data: [payout objects], has_more } in batch_index order. Absent from the list endpoint. |
| cancel | object | Cancel answer only: { cancelled, skipped }, the items this call cancelled and the items it left alone. |
Status lifecycle
The batch status is written by the database from the item statuses on every item transition (never by the API directly), so it is always consistent with counts. Items carry the ordinary payout vocabulary; the batch has its own. While counts.open is above zero the batch is processing; once every item is terminal it becomes one of the four terminal values below, and completed_at is set. A later completed to reversed move on one item recomputes counts but never changes a terminal batch status again.
| Status | Meaning |
|---|---|
| created | Transient: the batch row exists and its items are being inserted. You will rarely see it. |
| processing | At least one item is still open (initiated, pending, processing, enqueued or in_reconciliation). |
| completed | Terminal. Every item completed (a completed item later reversed still counts here). Emits payout_batch.completed. |
| partially_completed | Terminal. Some items completed, the others failed, expired or were cancelled. Emits payout_batch.partially_failed. |
| failed | Terminal. No item completed and not all were cancelled, for example every item failed with insufficient_balance. Emits payout_batch.failed. |
| cancelled | Terminal. Every item was cancelled (batch cancel, dashboard rejection, or individual cancels). Emits payout_batch.cancelled. |
Batch webhooks
When a batch turns terminal, one event for the batch as a whole is emitted to your webhook endpoints and to the GET /v1/events stream (resource_type payout_batch, resource_id pob_...), exactly once, on the transition from a non-terminal to a terminal status. The per-item payout.* events keep firing for every payout of the batch as usual; subscribe to those for item-level progress and to payout_batch.* for batch closure. Deliveries are signed with X-Capay-Signature like every other event.
| Event type | Description |
|---|---|
| payout_batch.completed | Every item completed. |
| payout_batch.partially_failed | The batch ended partially_completed. |
| payout_batch.failed | No item completed. |
| payout_batch.cancelled | Every item was cancelled. |
The event data is the batch as the database sees it at that moment. Note the shape: the counts are flat count_total, count_completed, count_failed, count_cancelled and count_reversed keys (there is no count_open on a terminal batch), not the counts object of the API response. GET /v1/events?type=payout_batch.completed filters the stream to one type.
{
"id": "evt_5p2Kq8Zr41Xb",
"object": "event",
"type": "payout_batch.partially_failed",
"created_at": "2026-09-04T09:12:41.260Z",
"livemode": false,
"data": {
"id": "pob_7Kq2mN9xAbCd",
"object": "payout_batch",
"status": "partially_completed",
"approval_status": "NOT_REQUIRED",
"currency": "XAF",
"total_amount": "230000",
"total_fee": "2300",
"total_debit": "232300",
"count_total": 2,
"count_completed": 1,
"count_failed": 1,
"count_cancelled": 0,
"count_reversed": 0,
"title": "March payroll",
"merchant_reference": "payroll-2026-03",
"reason_code": null,
"livemode": false,
"created_at": "2026-09-04T09:12:00.000Z",
"completed_at": "2026-09-04T09:12:41.204Z"
}
}Errors
Batch endpoints use the shared error envelope and the closed error catalog. On create, every 4xx means no payout was created, except 402 which records the batch and its items as failed.
| Code | HTTP | When it is returned |
|---|---|---|
| validation_error | 422 | A batch or item field is malformed, a merchant_reference repeats or was already used, a duplicate recipient was not allowed, or the X-Scenario-Key is unknown; every issue is listed. Also on retrieve and list for an unknown status value, and on cancel for a terminal batch (field status) or an over-long reason. |
| unsupported_country / unsupported_currency / method_not_available | 422 | Routing failed for at least one item (first failing code decides the status); every failing item is listed with its code. |
| prohibited_corridor | 403 | At least one item targets a prohibited corridor. |
| corridor_closed / service_paused / provider_unavailable | 503 | At least one item cannot be routed at all right now and cannot be enqueued. |
| limit_exceeded / amount_below_minimum | 403 | An item, or the aggregate, breaks a configured merchant, country, provider or platform limit. |
| card_data_rejected | 422 | The body contained card data (PAN, CVV or expiry) anywhere. Nothing is stored. |
| insufficient_balance | 402 | The available balance cannot cover the whole total. The batch and its items exist as failed; nothing was dispatched. |
| 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. |
| insufficient_scope | 403 | The API key lacks payouts:write (create, cancel) or payouts:read (retrieve, list). |
| resource_not_found | 404 | No batch matches the id in this environment; the starting_after cursor is unknown or belongs to another batch; or the batch given to GET /v1/payouts?batch= does not exist. |
| rate_limited | 429 | The per-app request quota was exceeded. See the X-RateLimit-* and Retry-After headers. |
| internal_error | 500 | An unexpected server error. Retry the create with the same Idempotency-Key so the batch cannot double-process. |