GuidesBulk payouts

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_upload permission (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_failed events
  • 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-safe
  • recipient.account_number + recipient.bank_code (NGN) OR currency-specific fields
  • merchant_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.