Bulk payouts guide
Push 150 payouts in one API call. End-to-end walkthrough including pre-flight validation, error handling, and webhook reconciliation.
Prerequisites
- An API key with
payout_bulk_uploadpermission (Owner-only by default — your Owner can grant via the dashboard team page) - IP allowlist populated on the key (bulk endpoints reject keys with empty allowlists)
- A verified webhook endpoint subscribed to
payout_paid+payout_failedevents - Sufficient wallet balance to cover the entire batch + estimated fees
Prepare your payout list
Bulk batches are limited to 150 rows per call. For larger payrolls, split into multiple batches in your code.
For each row, you’ll need:
amount_minor— string, BigInt-saferecipient.account_number+recipient.bank_code(NGN) OR currency-specific fieldsmerchant_reference— your unique ref per row (helps with reconciliation)
All rows in a batch must share the same currency. To pay in mixed currencies, send separate batches.
Send the batch
import { randomUUID } from 'crypto';
interface PayrollRow {
employeeId: string;
amountMinor: string;
account: string;
bankCode: string;
}
async function sendPayrollBatch(rows: PayrollRow[]) {
if (rows.length > 150) {
throw new Error('Bulk batches limited to 150 rows. Split your payroll.');
}
const items = rows.map((r) => ({
amount_minor: r.amountMinor,
recipient: {
account_number: r.account,
bank_code: r.bankCode,
},
merchant_reference: `PAYROLL_${r.employeeId}_${Date.now()}`,
}));
const idempotencyKey = randomUUID();
const res = await fetch('https://api.swappr.me/api/v1/batches', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SWAPPR_API_KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ currency: 'NGN', items }),
});
if (!res.ok) {
const err = await res.json();
// Semantic rejections (422) list every failing row. The key is top-level
// on `error` — and it is `unresolved` for recipient_unresolvable, but
// `violations` for the rest. Read the one that matches the code.
const rows =
err.error?.code === 'recipient_unresolvable'
? err.error.unresolved
: err.error?.violations;
if (rows) {
// Fix these rows, then retry with a NEW idempotency key.
console.error(`Rows to fix (${err.error.code}):`, rows);
throw new Error(`${rows.length} row(s) rejected; check rows`);
}
// Structural rejections (400) stop at the first bad row and name its
// index in the message, e.g. "items[3].amount_minor must be...".
throw new Error(`Batch failed: ${err.error?.message}`);
}
return await res.json();
}Handle validation errors
If any row fails pre-flight validation (unknown bank code, an account that won’t resolve, a risk cap breach, etc.), the whole batch is rejected before any wallet movement happens. How the failures are reported depends on where the batch stopped.
Structural problems return 400 and stop at the first bad row, naming its index in message. There is no per-row list — fix that row and resubmit:
{
"error": {
"type": "invalid_request_error",
"code": "invalid_body",
"message": "items[3].recipient_account_number must be a 10+ digit string."
}
}Semantic problems return 422 and list every failing row, 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 under error.detail — and the key varies by code. sender_info_required, unknown_bank_code, limit_violation and fraud_rule_blocked use violations; recipient_unresolvable uses unresolved. Reading only error.violations silently drops every unresolved-recipient row. The samples above branch on error.code first.
See the Batches reference for the full list of codes and their per-row entry fields.
Fix the bad rows in your data, then retry with a NEW idempotency key (the old key is now associated with the failed body — reusing it would 409).
Track row-level status via webhooks
When the batch dispatches, each row produces its own payout_paid or payout_failed webhook. Aggregate them client-side to track batch progress.
// Webhook handler for payout_paid + payout_failed
app.post('/webhooks/swappr', verifySignature, async (req, res) => {
const event = req.body;
if (event.event === 'payout_paid' || event.event === 'payout_failed') {
const { reference, batch_id, status, merchant_reference } = event.data;
if (batch_id) {
// This row belongs to a batch; update the batch progress in our DB
await db.batchRows.update({
where: { swapprReference: reference },
data: { status, completedAt: new Date() },
});
// Check if this completes the batch
const remaining = await db.batchRows.count({
where: { batchId: batch_id, status: { in: ['queued', 'processing'] } },
});
if (remaining === 0) {
await markBatchComplete(batch_id);
}
}
}
res.status(200).end();
});Poll batch status (optional)
If webhooks aren’t available, poll the batch endpoint:
curl https://api.swappr.me/api/v1/batches/bat_xxx \
-H "Authorization: Bearer sk_test_..."The response includes total_count / success_count / failure_count / in_flight_count. The batch is “complete” when in_flight_count === 0 and success_count + failure_count === total_count.
Don’t poll faster than 30 seconds per batch — use webhooks for real-time signals.
Cancelling a batch
If you realise mid-flight that the batch was wrong (e.g. wrong currency rate applied), you can cancel:
curl https://api.swappr.me/api/v1/batches/bat_xxx/cancel \
-X POST \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "reason": "Recalculating payroll due to FX rate change" }'This atomically cancels every draft and queued row. Rows already in processing or terminal status are left untouched — they’ll complete normally.
Common patterns
Splitting a 1000-row payroll
const CHUNK_SIZE = 150;
for (let i = 0; i < allRows.length; i += CHUNK_SIZE) {
const chunk = allRows.slice(i, i + CHUNK_SIZE);
const batch = await sendPayrollBatch(chunk);
console.log(`Batch ${batch.id} dispatched: ${batch.total_count} rows`);
await sleep(2000); // Stagger to avoid rate-limit cliff
}Reconciliation after batch completes
// Fetch all rows in the batch + check against your source-of-truth
const items = await fetch(
`https://api.swappr.me/api/v1/batches/${batchId}/items?limit=100&status=paid`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
).then(r => r.json());
for (const payout of items.data) {
await db.payroll.update({
where: { merchantReference: payout.merchant_reference },
data: { paidAt: new Date(payout.completed_at), nipReference: payout.nip_reference },
});
}Rate-limit awareness
Bulk endpoints have their own bucket — 10 calls/min. Don’t try to push more than 10 batches/minute. For higher throughput, contact support to bump your limit.
If you hit rate_limit_exceeded, the Retry-After header tells you exactly when to retry. Same idempotency key on retry.