To run payroll through Paystack, create each employee as a transfer recipient with their bank details, prepare a bulk transfer batch with each employee salary as a separate entry, pre-fund your Paystack balance to cover the total payroll plus fees, and execute via POST /transfer/bulk. Track each salary payment through webhooks and reconcile against your payroll records.
Paystack is a payment platform, not payroll software. It does not calculate taxes, generate payslips, or manage leave balances. What it does well is move money from your account to your employees' bank accounts. For many African startups, this is enough. You calculate the net salary in your payroll system (or a spreadsheet), and Paystack handles the disbursement.
This approach works well for companies with up to a few hundred employees. Beyond that, dedicated payroll services may offer better compliance features and tax filing integration. But for the vast majority of African startups, Paystack bulk transfers are a reliable and cost-effective payroll rail.
The flow: your payroll system produces a list of employees with their net salary amounts. You convert that list into Paystack bulk transfer entries. Paystack sends the money to each employee's bank account. You track delivery through webhooks and confirm with your payroll records.
async function onboardEmployeeForPayroll(employeeId, bankDetails) {
var resolved = await resolveAccount(bankDetails.accountNumber, bankDetails.bankCode);
if (!resolved) {
throw new Error('Bank account could not be verified');
}
var recipient = await createRecipient(
resolved.account_name,
bankDetails.accountNumber,
bankDetails.bankCode,
bankDetails.currency || 'NGN'
);
await db.employees.update(employeeId, {
bankCode: bankDetails.bankCode,
accountNumber: bankDetails.accountNumber,
resolvedAccountName: resolved.account_name,
paystackRecipientCode: recipient.recipientCode,
payrollActive: true,
});
return { recipientCode: recipient.recipientCode, resolvedName: resolved.account_name };
}
Collect bank details during employee onboarding. Show the resolved account name and ask for confirmation. Store the recipient code in your HR database. When an employee changes banks, follow the bank update process: resolve the new account, create a new recipient, and update the record. For recipient management details, see creating transfer recipients on Paystack.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
async function preparePayrollBatch(month, year) {
var employees = await db.employees.findPayrollActive();
var batch = [];
var issues = [];
for (var i = 0; i < employees.length; i++) {
var emp = employees[i];
if (!emp.paystackRecipientCode) {
issues.push({ employeeId: emp.id, name: emp.name, issue: 'No bank details on file' });
continue;
}
// Get net salary from your payroll calculation
var payrollEntry = await db.payroll.findByEmployeeAndPeriod(emp.id, month, year);
if (!payrollEntry) {
issues.push({ employeeId: emp.id, name: emp.name, issue: 'No payroll entry for this period' });
continue;
}
batch.push({
amount: payrollEntry.netSalaryMinorUnit,
recipient: emp.paystackRecipientCode,
reason: 'Salary - ' + month + '/' + year,
reference: 'salary_' + emp.id + '_' + year + '_' + month,
});
}
return {
batch: batch,
issues: issues,
totalAmount: batch.reduce(function(sum, b) { return sum + b.amount; }, 0),
employeeCount: batch.length,
};
}
The reference pattern salary_employeeId_year_month is deterministic. If you run the payroll preparation twice for the same month, it produces the same references, and Paystack deduplication prevents double payments. This is critical for payroll, where the cost of a duplicate is an entire salary payment.
Payroll timing matters to employees. Late salary creates anxiety and erodes trust. Execute early enough to allow for bank settlement before the promised payday.
async function executePayroll(batchData) {
// Pre-flight balance check
var balance = await getAvailableBalance(batchData.currency || 'NGN');
var bufferForFees = batchData.employeeCount * 5000; // Estimate per transfer
var totalNeeded = batchData.totalAmount + bufferForFees;
if (balance.total < totalNeeded) {
throw new Error(
'Insufficient balance for payroll. Need: ' + (totalNeeded / 100)
+ ', Available: ' + (balance.total / 100)
+ '. Fund the balance before executing.'
);
}
// Execute in chunks of 100
var chunkSize = 100;
var results = [];
for (var i = 0; i < batchData.batch.length; i += chunkSize) {
var chunk = batchData.batch.slice(i, i + chunkSize);
try {
await initiateBulkTransfer(batchData.currency || 'NGN', chunk);
results.push({ chunkIndex: Math.floor(i / chunkSize), status: 'sent', count: chunk.length });
} catch (err) {
results.push({ chunkIndex: Math.floor(i / chunkSize), status: 'failed', error: err.message });
}
if (i + chunkSize < batchData.batch.length) {
await new Promise(function(r) { setTimeout(r, 3000); });
}
}
return results;
}
Best practice: pre-fund your balance 24-48 hours before payroll day. Execute the payroll batch on the morning of payday (9-10 AM). Bank transfers during business hours settle faster. If your payday is the 25th, execute at 9 AM on the 25th. Most employees will see the money by lunchtime for bank transfers, or instantly for M-Pesa.
In a payroll batch, some individual transfers may fail while others succeed. You need to handle failures quickly because every failed salary is an employee who did not get paid.
async function handlePayrollFailures(payrollMonth, payrollYear) {
var failedSalaries = await db.payouts.findFailedByPeriod(
'salary', payrollMonth, payrollYear
);
if (failedSalaries.length === 0) {
console.log('All salaries paid successfully');
return;
}
console.log(failedSalaries.length + ' salaries failed');
for (var i = 0; i < failedSalaries.length; i++) {
var failed = failedSalaries[i];
var employee = await db.employees.findById(failed.employeeId);
console.log(
'Failed: ' + employee.name
+ ' - Reason: ' + failed.failureReason
);
// Classify and act
var reason = (failed.failureReason || '').toLowerCase();
if (reason.indexOf('invalid') !== -1 || reason.indexOf('closed') !== -1) {
// Contact employee for updated bank details
await notify(employee.id,
'Your salary could not be processed. Please update your bank details.');
} else {
// Transient failure, retry
var retryRef = failed.reference + '_retry';
await initiateSingleTransfer(
failed.recipientCode,
failed.amountMinorUnit,
'Salary retry - ' + payrollMonth + '/' + payrollYear,
retryRef
);
}
}
}
Communicate proactively. If a salary fails, tell the employee immediately. "We are re-processing your salary payment. You should receive it within a few hours." Silence creates more anxiety than a transparent update.
Using Paystack for payroll is a disbursement mechanism, not a payroll system. You still have obligations around tax withholding, statutory deductions, and reporting. Key points:
For dedicated payroll compliance, consult with an HR/payroll advisor in your jurisdiction. Paystack handles the money movement; compliance is your responsibility.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course