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

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

FieldTypeRequiredNotes
itemsarrayyes1-150 payout objects
wallet_idstringnoAuto-resolved from currency + env
currencystringyesAll 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.

ParamNotes
limit1-100, default 50
starting_afterCursor
statusFilter 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

  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, bank codes are 3 digits, before sending. 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.