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

StatusDescription
draftCreated via dual-control flow; awaiting team approval
awaiting_approvalAbove your dual-control threshold; needs second admin approval
approvedApproved/auto-approved; dispatching now
processingSome rows dispatched, some pending
completedAll rows completed (paid + failed accounted for)
completed_with_errorsCompleted but with failures
cancelledCancelled before dispatch (all rows cancelled atomically)
rejectedRejected at approval stage
flagged_heldRows deferred for fix via the rejected-uploads queue

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

Body parameters
itemsarrayRequired

1-150 payout objects. Each item follows the single-payout request shape — same recipient block per currency, same optional sender for IMTO compliance.

wallet_idstring

Auto-resolved from currency + env.

currencystringRequired

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.

CodeHTTPPer-row keyEntry fields
sender_info_required422violationsrow_index, missing[]
unknown_bank_code422violationsrow_index, field, input, message
recipient_unresolvable422unresolvedrow_index, code, message
limit_violation422violationsrow_index, kind, message
fraud_rule_blocked422violationsrow_index, action, rule_labels[]

Every error POST /v1/batches can return

Structural — stops at the first offending row, index named in message:

CodeHTTPCause
invalid_body400Body is not JSON, a required field is missing, or a field has the wrong type
invalid_amount400A row’s amount_minor is not a positive integer (minor units)
invalid_format400sender_id_expiry, sender_date_of_birth (both DDMMYYYY) or sender_country (ISO 3166-1 alpha-2) is malformed
invalid_id_type400sender_id_type is not one of the accepted values
items_empty400items is an empty array
items_too_many400More rows than the per-request cap — chunk across multiple calls
currency_not_supported404Only NGN bulk batches are supported in this release

Account and key state — checked before any row is examined:

CodeHTTPCause
bulk_disabled_for_key403The API key is not enabled for bulk. Enable it on the key in API keys
bulk_requires_ip_allowlist403Bulk requires the key to have an IP allowlist configured
idempotency_key_conflict409This Idempotency-Key was used with a different body. Use a fresh key
wallet_not_found404No wallet matches this merchant + currency + env
currency_mismatch400The resolved wallet’s currency is not the requested currency
env_mismatch400The wallet’s environment does not match the key’s environment
wallet_frozen422The source wallet is frozen or inactive

Semantic — every failing row reported together:

CodeHTTPCause
sender_info_required422Your account requires sender info and some rows omit required sender fields
unknown_bank_code422A row’s bank code matches no known bank — look codes up via GET /v1/banks
recipient_unresolvable422A row’s bank code + account number could not be resolved to an account holder
insufficient_balance422The wallet cannot cover the batch total
limit_violation422A row would breach a configured risk cap — lower amounts, split the batch, or contact support
fraud_rule_blocked422Rows 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}/items

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

Request
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"
      }
    ]
  }'
Response
{
  "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
}
201 Created

GETRetrieve a batch

GET/v1/batches/{id}

Accepts cuid OR bat_xxx reference. Returns the Batch object plus per-status counts.

Request
curl https://api.swappr.me/api/v1/batches/bat_xxx \
  -H "Authorization: Bearer sk_test_..."
Response
{
  "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"
}
200 OK

GETList batch items

GET/v1/batches/{id}/items

Cursor-paginated list of payouts in the batch.

Query parameters
limitinteger

1-100, default 50.

starting_afterstring

Cursor.

statusstring

Filter to one status.

Returns the same {object: 'list', has_more, data} envelope; each item is a full Payout object.

Request
curl 'https://api.swappr.me/api/v1/batches/bat_xxx/items?limit=50' \
  -H "Authorization: Bearer sk_test_..."
Response
{
  "object": "list",
  "has_more": false,
  "data": [
    { "object": "payout", "id": "...", "status": "paid", ... },
    ...
  ]
}
200 OK

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

Body parameters
reasonstring

Why the batch is being cancelled.

Request
{
  "reason": "Mismatch in payroll calculation — recalculating"
}
Response
{
  "object": "batch",
  "id": "ckxxxxxxxxxxxxxxxxxx",
  "reference": "bat_xxxxxxxxxxxx",
  "status": "cancelled"
}
200 OK

Best practices

  1. One batch per logical operation — don’t pile unrelated payouts into one batch. If one row fails validation, the whole batch is refused.
  2. Use unique merchant_references — helps with reconciliation. Rejection of duplicates within a 30-day window prevents accidental double-pay.
  3. Pre-validate locally where possible — check NGN account numbers are 10 digits, and look bank codes up in GET /v1/banks rather than validating by length (real codes are 3-, 5- or 6-digit — e.g. 058 for GTBank, 50515 for Moniepoint). Any code the bank list publishes is accepted on recipient_bank_code; Swappr canonicalizes to 3-digit CBN internally before dispatch. Reduces validation failures.
  4. 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.
  5. Subscribe to webhookspayout_paid / payout_failed events fire per-row as the batch processes. Aggregate them client-side for live dashboards.

See Bulk payouts guide for end-to-end examples.