Bonaventure OgetoBy Bonaventure Ogeto|

Payroll Disbursement with Paystack Bulk Transfers

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.

Using Paystack as a Payroll Rail

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.

Setting Up Employees as Recipients

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.

Preparing the Payroll Batch

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.

Execution and Timing

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.

Handling Payroll Failures

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.

Compliance Considerations

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:

  • Tax withholding: Calculate PAYE (Pay As You Earn) and other statutory deductions before determining the net salary. Only transfer the net amount through Paystack. Remit deductions to the relevant authorities through their designated channels.
  • Payslips: Generate payslips from your payroll system showing gross salary, deductions, and net pay. The Paystack transfer is the disbursement of the net amount, not the full payroll record.
  • Records: Keep payroll records that link each Paystack transfer to the corresponding payroll entry. The Paystack reference should map directly to the employee and pay period for audit purposes.
  • Timing: Employment laws in most African countries require salary payment by specific dates. Ensure your Paystack execution and settlement timing meets these requirements.

For dedicated payroll compliance, consult with an HR/payroll advisor in your jurisdiction. Paystack handles the money movement; compliance is your responsibility.

Build Reliable Payment Systems

Payroll is the highest-stakes payout use case. Every transfer is someone's livelihood. Building it right means reliable execution, fast failure recovery, and clear communication.

The McTaba bootcamp covers payment integration for real-world business applications.

Create a free McTaba account

Key Takeaways

  • Create each employee as a Paystack transfer recipient during onboarding. Store their recipient code alongside their HR record.
  • Prepare payroll batches with each employee as a separate transfer entry. Include net salary (after tax and deductions) as the transfer amount.
  • Pre-fund your Paystack balance at least 24 hours before payroll day. The total must cover all salaries plus transfer fees.
  • Execute payroll during business hours (9 AM - 2 PM) for the fastest bank settlement. Avoid month-end congestion if possible.
  • Handle failures per employee, not per batch. If one salary fails, the others still go through. Retry the failed ones after investigating.
  • Paystack is a payment rail, not payroll software. You still need separate systems for tax calculation, leave management, and payslip generation.

Frequently Asked Questions

Is Paystack a good choice for payroll disbursement?
Paystack works well as a payroll disbursement rail for small to medium-sized companies (up to a few hundred employees). It handles the money movement reliably. For larger organizations or those needing integrated tax filing, leave management, and compliance features, consider dedicated payroll software that may also handle disbursement.
Can I send salaries to M-Pesa in Kenya?
Yes. Create employees as M-Pesa recipients and include them in your payroll batch. M-Pesa salaries settle instantly, which employees appreciate. For Kenyan teams, offering both bank and M-Pesa options covers all employees.
How do I handle salary advances or deductions?
Calculate the net amount in your payroll system before sending to Paystack. If an employee took a 10,000 NGN advance, deduct it from their gross salary along with taxes and other deductions. Only the final net amount goes through Paystack as a transfer.

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