Bulk Transfers with the Paystack API
Paystack bulk transfers let you send money to up to 100 recipients in a single POST to /transfer/bulk. Each transfer in the batch includes its own recipient code, amount, reason, and unique reference. Transfers succeed or fail independently. You track outcomes through individual transfer.success and transfer.failed webhook events for each transfer in the batch.
When to Use Bulk Transfers
Single transfers are fine when you are paying one person. But when you have a list of 50 vendors who need their weekly payout, or 200 employees who need salary, calling the single transfer endpoint in a loop is slow and fragile. Each call is a separate HTTP request. If your server restarts mid-loop, you lose track of which transfers went out and which did not.
Bulk transfers solve this by accepting up to 100 transfers in one API call. Paystack processes them as a batch internally, but each transfer is independent. If vendor #47 has an invalid bank account, vendors #1-46 and #48-100 still get paid.
Common use cases for bulk transfers:
- Vendor payouts: Weekly or monthly settlement to marketplace sellers
- Payroll: Monthly salary disbursement to employees
- Affiliate commissions: Paying out earned commissions to affiliate partners
- Driver settlements: End-of-day or end-of-week payouts to ride-hailing or delivery drivers
- Creator earnings: Paying content creators, artists, or freelancers their accumulated earnings
For the full context on how bulk transfers fit into the Paystack transfer system, see the transfers and payouts complete guide.
The Bulk Transfer Request
const initiateBulkTransfer = async (currency, transfers) => {
const response = await fetch('https://api.paystack.co/transfer/bulk', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
currency: currency,
source: 'balance',
transfers: transfers,
}),
});
const data = await response.json();
if (!data.status) {
throw new Error('Bulk transfer failed: ' + data.message);
}
return data.data;
};
// Build the transfer list
var transferList = [
{
amount: 250000, // 2,500 NGN
recipient: 'RCP_vendor_001',
reason: 'Weekly payout - July W3',
reference: 'payout_v001_2026w29',
},
{
amount: 180000, // 1,800 NGN
recipient: 'RCP_vendor_002',
reason: 'Weekly payout - July W3',
reference: 'payout_v002_2026w29',
},
{
amount: 420000, // 4,200 NGN
recipient: 'RCP_vendor_003',
reason: 'Weekly payout - July W3',
reference: 'payout_v003_2026w29',
},
];
var result = await initiateBulkTransfer('NGN', transferList);
Key constraints:
- Maximum 100 transfers per batch. If you exceed this, the request is rejected.
- All transfers must use the same currency. You cannot mix NGN and KES transfers in one batch. Run separate batches per currency.
- Each transfer needs a unique reference. If two transfers share a reference (within the batch or with a previous transfer), one is treated as a duplicate.
- Balance must cover the total. Sum up all amounts in the batch plus estimated fees. If your balance is short, the entire batch fails. Paystack does not process a partial batch.
Balance Pre-Check Before Batch Execution
Running a batch without checking your balance first is asking for trouble. The entire batch fails if the total exceeds your available balance, and you have to re-run everything.
async function canAffordBatch(transfers, currency) {
// Sum all transfer amounts
var totalAmount = 0;
for (var i = 0; i < transfers.length; i++) {
totalAmount += transfers[i].amount;
}
// Fetch balance
var response = await fetch('https://api.paystack.co/balance', {
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
});
var balances = (await response.json()).data;
var currencyBalance = null;
for (var j = 0; j < balances.length; j++) {
if (balances[j].currency === currency) {
currencyBalance = balances[j];
break;
}
}
if (!currencyBalance) {
return { canAfford: false, available: 0, needed: totalAmount };
}
// Leave a buffer for transfer fees
var buffer = transfers.length * 5000; // rough estimate per transfer
var needed = totalAmount + buffer;
return {
canAfford: currencyBalance.balance >= needed,
available: currencyBalance.balance,
needed: needed,
transferCount: transfers.length,
};
}
The buffer for transfer fees is an estimate. Paystack's transfer fees vary by amount and destination type, so calculate the buffer based on the actual fee structure that applies to your account. Check Paystack's pricing page or your dashboard for current rates. Being conservative with the buffer is better than having a batch fail because you were 500 kobo short.
For more on balance management, see transfer balance checks before initiating payouts.
Batching Large Payout Runs
When you have more than 100 recipients, you need to split the payout run into batches. Here is a production pattern.
async function executePayoutRun(allPayouts, currency) {
// Split into batches of 100
var batchSize = 100;
var batches = [];
for (var i = 0; i < allPayouts.length; i += batchSize) {
batches.push(allPayouts.slice(i, i + batchSize));
}
console.log('Total payouts: ' + allPayouts.length);
console.log('Batches: ' + batches.length);
var results = [];
for (var b = 0; b < batches.length; b++) {
var batch = batches[b];
console.log('Processing batch ' + (b + 1) + ' of ' + batches.length);
try {
var result = await initiateBulkTransfer(currency, batch);
results.push({ batchIndex: b, status: 'sent', data: result });
} catch (err) {
results.push({ batchIndex: b, status: 'failed', error: err.message });
// Do not continue to next batch if this one failed due to balance
if (err.message.indexOf('balance') !== -1) {
console.error('Balance insufficient. Stopping at batch ' + (b + 1));
break;
}
}
// Delay between batches to respect rate limits
if (b < batches.length - 1) {
await new Promise(function(resolve) { setTimeout(resolve, 3000); });
}
}
return results;
}
The 3-second delay between batches keeps you within Paystack's rate limits. Without it, rapid-fire batch submissions can get throttled, and throttled requests look like failures from your code's perspective.
If a batch fails due to insufficient balance, stop processing. There is no point sending more batches if your balance is empty. Log which batches succeeded and which did not, so you can resume from the right point once the balance is funded.
For scheduling strategies and optimal timing for large payout runs, see payout scheduling and batching strategies.
Tracking Individual Transfer Outcomes
Each transfer in a bulk batch fires its own webhook events. A batch of 50 transfers generates 50 separate transfer.success or transfer.failed events. Your webhook handler processes them exactly the same way as single transfers.
// Track batch progress in your database
async function trackBatchProgress(batchId) {
var payouts = await db.payouts.findByBatchId(batchId);
var completed = 0;
var failed = 0;
var pending = 0;
for (var i = 0; i < payouts.length; i++) {
if (payouts[i].status === 'completed') completed++;
else if (payouts[i].status === 'failed') failed++;
else pending++;
}
return {
total: payouts.length,
completed: completed,
failed: failed,
pending: pending,
isComplete: pending === 0,
successRate: payouts.length > 0
? Math.round((completed / payouts.length) * 100)
: 0,
};
}
// Check after all webhooks arrive
var progress = await trackBatchProgress('batch_2026w29');
// { total: 50, completed: 47, failed: 3, pending: 0, isComplete: true, successRate: 94 }
Build a dashboard or report that shows batch progress. Operations teams need to see: how many transfers in this batch are done, how many failed, and what failed transfers need attention. The reference pattern matters here. If your references include the batch identifier (e.g., payout_v001_batch42), you can easily query all transfers in a batch.
Handling Partial Failures in a Batch
In a batch of 100 transfers, you will occasionally have a handful fail. Common reasons: a vendor closed their bank account since the last payout, a bank is temporarily down, or an account has hit its daily inflow limit.
async function handleBatchFailures(batchId) {
var failedPayouts = await db.payouts.findByBatchIdAndStatus(batchId, 'failed');
var retryable = [];
var needsReview = [];
for (var i = 0; i < failedPayouts.length; i++) {
var payout = failedPayouts[i];
var reason = (payout.failureReason || '').toLowerCase();
if (reason.indexOf('timeout') !== -1 ||
reason.indexOf('unavailable') !== -1 ||
reason.indexOf('try again') !== -1) {
retryable.push(payout);
} else {
needsReview.push(payout);
}
}
// Retry transient failures in a new batch
if (retryable.length > 0) {
var retryTransfers = retryable.map(function(p) {
return {
amount: p.amountMinorUnit,
recipient: p.recipientCode,
reason: p.reason + ' (retry)',
reference: p.reference + '_retry' + (p.retryCount + 1),
};
});
await initiateBulkTransfer(retryable[0].currency, retryTransfers);
// Update retry counts
for (var j = 0; j < retryable.length; j++) {
await db.payouts.update(retryable[j].id, {
status: 'retrying',
retryCount: retryable[j].retryCount + 1,
});
}
}
// Flag permanent failures for human review
for (var k = 0; k < needsReview.length; k++) {
await db.payouts.update(needsReview[k].id, {
requiresManualReview: true,
});
}
return {
retried: retryable.length,
flaggedForReview: needsReview.length,
};
}
Notice the retry reference pattern: appending _retry1, _retry2 to the original reference. This ensures the retry is treated as a new transfer (different reference) while maintaining a clear link to the original attempt. Cap retries at 3 attempts per transfer to avoid infinite loops on persistently failing accounts.
Reconciliation After a Batch Run
After a batch run completes (all webhooks received, retries exhausted), reconcile your records against Paystack. This catches missed webhooks, stuck transfers, and discrepancies.
async function reconcileBatch(batchId) {
var payouts = await db.payouts.findByBatchId(batchId);
var discrepancies = [];
for (var i = 0; i < payouts.length; i++) {
var payout = payouts[i];
// Skip already-reconciled payouts
if (payout.reconciled) continue;
// Fetch status from Paystack
var response = await fetch(
'https://api.paystack.co/transfer/verify/' + payout.reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = await response.json();
if (!data.status) {
discrepancies.push({
reference: payout.reference,
issue: 'Not found on Paystack',
});
continue;
}
var paystackStatus = data.data.status;
var localStatus = payout.status;
// Check for mismatch
if (paystackStatus === 'success' && localStatus !== 'completed') {
discrepancies.push({
reference: payout.reference,
issue: 'Paystack says success, local says ' + localStatus,
});
// Fix the local record
await db.payouts.update(payout.id, { status: 'completed', reconciled: true });
} else if (paystackStatus === 'failed' && localStatus === 'processing') {
discrepancies.push({
reference: payout.reference,
issue: 'Missed failure webhook',
});
await db.payouts.update(payout.id, { status: 'failed', reconciled: true });
}
// Small delay to avoid hitting rate limits
await new Promise(function(resolve) { setTimeout(resolve, 200); });
}
return discrepancies;
}
Run reconciliation a few hours after the batch completes. This gives Paystack time to process all transfers and deliver all webhooks. If you find discrepancies consistently, investigate your webhook handler. Missed webhooks usually mean your server was down, timed out, or returned a non-200 response.
Key Takeaways
- ✓Paystack bulk transfers accept up to 100 transfers in a single API call via POST /transfer/bulk. All transfers must use the same currency.
- ✓Each transfer in a batch is independent. If one fails because of an invalid account, the others still succeed. You get separate webhook events for each.
- ✓Your balance must cover the total of all transfers in the batch plus fees. If the balance is insufficient, the entire batch is rejected.
- ✓Every transfer in the batch needs its own unique reference. Duplicate references within or across batches cause transfers to be treated as duplicates.
- ✓For payout runs with more than 100 recipients, split into batches of 100 and add delays between batches to respect rate limits.
- ✓Build a progress tracker that matches webhook events back to your batch records. At the end of a batch run, every transfer should be in a terminal state.
- ✓Pre-validate all recipient codes before assembling a batch. One stale or deleted recipient can cause unnecessary failures.
Frequently Asked Questions
- Can I mix different recipient types in a bulk transfer?
- Yes, as long as all transfers use the same currency. A single bulk batch can include transfers to bank accounts and mobile money wallets, as long as they are all in the same currency (e.g., all NGN or all KES).
- What happens if one transfer in the batch has a duplicate reference?
- Paystack treats the transfer with the duplicate reference as a duplicate and returns the existing transfer for that reference. The other transfers in the batch are processed normally. This does not cause the entire batch to fail.
- Is there a rate limit on how many bulk transfer calls I can make?
- Paystack enforces rate limits on all API endpoints. The specific limits depend on your account and can change. Adding a 2-3 second delay between consecutive bulk transfer calls is a safe practice. If you get rate-limited, back off and retry after the period indicated in the response.
- Can I cancel a bulk transfer after submitting it?
- Once a bulk transfer is submitted and accepted by Paystack, you cannot cancel it as a batch. Individual transfers within the batch follow normal transfer lifecycle rules. If OTP is enabled, you could decline to finalize, but this applies to each transfer individually.
- Should I use bulk transfers or loop through single transfers?
- Use bulk transfers for any payout run with more than a few recipients. Bulk transfers are faster (one API call instead of many), easier to track as a group, and less prone to partial completion issues. The only reason to use single transfers in a loop is if each transfer has complex per-transfer logic that cannot be pre-computed.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon