Batches
Bulk payouts up to 150 rows per call. Same endpoint family as single payouts but optimized for high-volume operations like payroll, marketplace splits, and bulk refunds.
The batch object
{
"object": "batch",
"id": "ckxxxxxxxxxxxxxxxxxx",
"reference": "bat_xxxxxxxxxxxx",
"status": "approved",
"currency": "NGN",
"total_count": 23,
"success_count": 22,
"failure_count": 1,
"in_flight_count": 0,
"total_amount_minor": "12500000",
"created_at": "2026-05-05T12:00:00Z",
"approved_at": "2026-05-05T12:00:30Z",
"completed_at": "2026-05-05T12:02:15Z"
}Status values
| Status | Description |
|---|---|
draft | Created via dual-control flow; awaiting team approval |
awaiting_approval | Above your dual-control threshold; needs second admin approval |
approved | Approved/auto-approved; dispatching now |
processing | Some rows dispatched, some pending |
completed | All rows completed (paid + failed accounted for) |
completed_with_errors | Completed but with failures |
cancelled | Cancelled before dispatch (all rows cancelled atomically) |
rejected | Rejected at approval stage |
flagged_held | Rows deferred for fix via the rejected-uploads queue |
POSTCreate a batch
Permissions required: payout_bulk_upload. Owner-only by default; assign explicitly to other team members.
IP allowlist required: bulk endpoints reject keys with empty allowlists.
1-150 payout objects. Each item follows the single-payout request shape — same recipient block per currency, same optional sender for IMTO compliance.
Auto-resolved from currency + env.
All items must share the same currency.
Maker-checker thresholds
If the batch total exceeds your account’s dual-control threshold, the batch lands in awaiting_approval status. A second team member must approve via the dashboard before dispatch.
Below threshold → batch auto-approves to approved status; rows dispatch immediately.
Live-merchant Owner-only self-approve
On live-approved merchants, only the team member assigned the Owner role can self-approve their own batches even when they hold both payout_bulk_upload and payout_bulk_approve permissions. Other dual-perm roles (Admin, Approver, etc.) drop to maker-checker on live and need a different teammate to approve.
This is a security tightening for real-money flows — non-Owner self-approve still works on sandbox for development velocity. The Owner role is capped at 3 holders per merchant, so the “self-approve surface” stays narrow even when team size grows.
When self-approve is denied, the API returns:
{
"type": "self_approval_denied",
"message": "A different teammate must approve this batch on a live merchant. Only the Owner can self-approve on live."
}Email-OTP gate (dashboard only)
When the merchant has MerchantLimits.otpRequiredAtAmountMinor configured, dashboard batch approvals trigger an email-OTP for the approver before dispatch. ONE OTP unlocks the entire batch (similar to provider-side bulk-OTP UX). Live env defaults to OTP-on; sandbox bypasses unless requirePayoutOtpInSandbox is set.
API-initiated batches do NOT trigger the OTP gate. POST /v1/batches authenticates via Bearer + IP allowlist + Idempotency-Key. The OTP is reserved for the dashboard’s human-clicked surface.
Validation errors
If any row fails validation, the WHOLE batch is rejected — all-or-nothing semantics. Partial success would leave you guessing about which rows landed; this way you fix the bad rows and resubmit the corrected batch with confidence. Nothing is created and no wallet moves until every row passes.
Rejections come in two shapes, and which one you get depends on how far the batch got.
Shape 1 — structural rejection (400, and 404 currency_not_supported). The body or a row is malformed. Validation stops at the first bad row and names its index in message. There is no per-row array — fix the row it names, resubmit, and the next problem (if any) is reported the same way.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_body",
"message": "items[3].recipient_account_number must be a 10+ digit string."
}
}Shape 2 — semantic rejection (422). The body parsed cleanly, so every row is checked and all failing rows are reported together. These carry a per-row array so you can fix the whole batch in one pass:
{
"error": {
"type": "invalid_request_error",
"code": "unknown_bank_code",
"message": "One or more rows have a bank_code that doesn't match any Bank row.",
"violations": [
{ "row_index": 4, "field": "recipient_bank_code", "input": "999", "message": "..." },
{ "row_index": 12, "field": "recipient_bank_code", "input": "0000", "message": "..." }
]
}
}The per-row array is top-level on error, not nested under error.detail. And the key is not the same for every code — four use violations, but recipient_unresolvable uses unresolved. A client that reads only error.violations will silently drop every unresolved-recipient row and see an empty list. Branch on error.code first, then read the key for that code.
| Code | HTTP | Per-row key | Entry fields |
|---|---|---|---|
sender_info_required | 422 | violations | row_index, missing[] |
unknown_bank_code | 422 | violations | row_index, field, input, message |
recipient_unresolvable | 422 | unresolved | row_index, code, message |
limit_violation | 422 | violations | row_index, kind, message |
fraud_rule_blocked | 422 | violations | row_index, action, rule_labels[] |
Every error POST /v1/batches can return
Structural — stops at the first offending row, index named in message:
| Code | HTTP | Cause |
|---|---|---|
invalid_body | 400 | Body is not JSON, a required field is missing, or a field has the wrong type |
invalid_amount | 400 | A row’s amount_minor is not a positive integer (minor units) |
invalid_format | 400 | sender_id_expiry, sender_date_of_birth (both DDMMYYYY) or sender_country (ISO 3166-1 alpha-2) is malformed |
invalid_id_type | 400 | sender_id_type is not one of the accepted values |
items_empty | 400 | items is an empty array |
items_too_many | 400 | More rows than the per-request cap — chunk across multiple calls |
currency_not_supported | 404 | Only NGN bulk batches are supported in this release |
Account and key state — checked before any row is examined:
| Code | HTTP | Cause |
|---|---|---|
bulk_disabled_for_key | 403 | The API key is not enabled for bulk. Enable it on the key in API keys |
bulk_requires_ip_allowlist | 403 | Bulk requires the key to have an IP allowlist configured |
idempotency_key_conflict | 409 | This Idempotency-Key was used with a different body. Use a fresh key |
wallet_not_found | 404 | No wallet matches this merchant + currency + env |
currency_mismatch | 400 | The resolved wallet’s currency is not the requested currency |
env_mismatch | 400 | The wallet’s environment does not match the key’s environment |
wallet_frozen | 422 | The source wallet is frozen or inactive |
Semantic — every failing row reported together:
| Code | HTTP | Cause |
|---|---|---|
sender_info_required | 422 | Your account requires sender info and some rows omit required sender fields |
unknown_bank_code | 422 | A row’s bank code matches no known bank — look codes up via GET /v1/banks |
recipient_unresolvable | 422 | A row’s bank code + account number could not be resolved to an account holder |
insufficient_balance | 422 | The wallet cannot cover the batch total |
limit_violation | 422 | A row would breach a configured risk cap — lower amounts, split the batch, or contact support |
fraud_rule_blocked | 422 | Rows were flagged by fraud rules. Unlike single payouts (which hold for review), bulk requires fix-then-resubmit |
Which rows failed? Validation-time vs. post-acceptance
These are two different questions with two different answers, and it matters which one you’re asking.
Before acceptance (the errors above). Nothing was created. The batch does not exist, no Payout rows exist, and no money moved. The answer lives in the error response — either the row index in message (structural) or the per-row array (semantic).
After acceptance (201). Every row passed validation and became a Payout. From here rows succeed or fail individually at dispatch, and the answer lives on the items endpoint:
GET /v1/batches/{id}/itemsEach item carries status plus failure_code and failure_message when it failed. You can filter server-side with ?status=failed. A batch that was accepted and then had rows fail at the rail is not a validation error and will never appear as one.
curl https://api.swappr.me/api/v1/batches \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"currency": "NGN",
"items": [
{
"amount_minor": "500000",
"recipient": { "account_number": "0690000032", "bank_code": "044" },
"merchant_reference": "PAYROLL_001"
},
{
"amount_minor": "750000",
"recipient": { "account_number": "0123456789", "bank_code": "058" },
"merchant_reference": "PAYROLL_002"
}
]
}'{
"object": "batch",
"id": "ckxxxxxxxxxxxxxxxxxx",
"reference": "bat_xxxxxxxxxxxx",
"status": "approved",
"currency": "NGN",
"total_count": 2,
"success_count": 0,
"failure_count": 0,
"in_flight_count": 2,
"total_amount_minor": "1250000",
"created_at": "2026-05-05T12:00:00Z",
"approved_at": "2026-05-05T12:00:30Z",
"completed_at": null
}GETRetrieve a batch
Accepts cuid OR bat_xxx reference. Returns the Batch object plus per-status counts.
curl https://api.swappr.me/api/v1/batches/bat_xxx \
-H "Authorization: Bearer sk_test_..."{
"object": "batch",
"id": "ckxxxxxxxxxxxxxxxxxx",
"reference": "bat_xxxxxxxxxxxx",
"status": "completed",
"currency": "NGN",
"total_count": 23,
"success_count": 22,
"failure_count": 1,
"in_flight_count": 0,
"total_amount_minor": "12500000"
}GETList batch items
Cursor-paginated list of payouts in the batch.
1-100, default 50.
Cursor.
Filter to one status.
Returns the same {object: 'list', has_more, data} envelope; each item is a full Payout object.
curl 'https://api.swappr.me/api/v1/batches/bat_xxx/items?limit=50' \
-H "Authorization: Bearer sk_test_..."{
"object": "list",
"has_more": false,
"data": [
{ "object": "payout", "id": "...", "status": "paid", ... },
...
]
}POSTCancel a batch
Cancels the entire batch atomically. All draft and queued rows are cancelled; processing and terminal-status rows are left untouched.
Why the batch is being cancelled.
{
"reason": "Mismatch in payroll calculation — recalculating"
}{
"object": "batch",
"id": "ckxxxxxxxxxxxxxxxxxx",
"reference": "bat_xxxxxxxxxxxx",
"status": "cancelled"
}Best practices
- One batch per logical operation — don’t pile unrelated payouts into one batch. If one row fails validation, the whole batch is refused.
- Use unique merchant_references — helps with reconciliation. Rejection of duplicates within a 30-day window prevents accidental double-pay.
- Pre-validate locally where possible — check NGN account numbers are 10 digits, and look bank codes up in
GET /v1/banksrather than validating by length (real codes are 3-, 5- or 6-digit — e.g.058for GTBank,50515for Moniepoint). Any code the bank list publishes is accepted onrecipient_bank_code; Swappr canonicalizes to 3-digit CBN internally before dispatch. Reduces validation failures. - Stagger across batches — push 150 rows per call, wait for batch to complete, then push next 150. Don’t try 1000+ rows in parallel calls.
- Subscribe to webhooks —
payout_paid/payout_failedevents fire per-row as the batch processes. Aggregate them client-side for live dashboards.
See Bulk payouts guide for end-to-end examples.