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 |
Create a batch
POST /v1/batches
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.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes | 1-150 payout objects |
wallet_id | string | no | Auto-resolved from currency + env |
currency | string | yes | All items must share the same currency |
Each item follows the single-payout request shape — same recipient block per currency, same optional sender for IMTO compliance.
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.
Example
curl https://api.swappr.me/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"
}
]
}'Response
201 Created with the Batch object.
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. The response includes per-row reasons:
{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "3 rows failed validation. See detail.row_errors.",
"detail": {
"row_errors": [
{ "row_index": 4, "code": "invalid_recipient", "message": "..." },
{ "row_index": 7, "code": "name_mismatch", "message": "..." },
{ "row_index": 12, "code": "globally_blocked", "message": "..." }
]
}
}
}Retrieve a batch
GET /v1/batches/{id}
Accepts cuid OR bat_xxx reference. Returns the Batch object plus per-status counts.
List batch items
GET /v1/batches/{id}/items
Cursor-paginated list of payouts in the batch.
| Param | Notes |
|---|---|
limit | 1-100, default 50 |
starting_after | Cursor |
status | Filter to one status |
Returns the same {object: 'list', has_more, data} envelope; each item is a full Payout object.
Cancel a batch
POST /v1/batches/{id}/cancel
Cancels the entire batch atomically. All draft and queued rows are cancelled; processing and terminal-status rows are left untouched.
Request
{
"reason": "Mismatch in payroll calculation — recalculating"
}Response
200 OK with updated Batch (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, bank codes are 3 digits, before sending. 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.