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/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();
if (err.error?.code === 'validation_failed') {
// Per-row issues — fix + retry with a NEW idempotency key
console.error('Row errors:', err.error.detail.row_errors);
throw new Error('Validation failed; check rows');
}
throw new Error(`Batch failed: ${err.error?.message}`);
}
return await res.json();
}Handle validation errors
If any row fails pre-flight validation (bad bank code, name mismatch, blacklisted recipient, etc.), the whole batch is rejected before any wallet movement happens. The response details which rows failed:
{
"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": "..." }
]
}
}
}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/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/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/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.