An automated Paystack payout system has five layers: a payout queue (database table tracking every payout request), an approval workflow (maker-checker or threshold-based), a batch executor (groups approved payouts and sends them via bulk transfer), webhook handlers (update payout status from transfer.success and transfer.failed events), and a reconciliation job (catches missed webhooks and stuck payouts).
A production payout system has five distinct layers, each with a clear responsibility. Skipping any one of them creates blind spots that become bugs at scale.
1. Payout Queue - A database table that stores every payout request with its status, amount, recipient, and metadata. This is your source of truth.
2. Approval Layer - Business logic that determines whether a payout should be executed. Could be manual (admin reviews and clicks approve), threshold-based (auto-approve below 50,000 NGN), or maker-checker (one person creates, another approves).
3. Execution Engine - Groups approved payouts into batches, checks the balance, and sends them to Paystack via the bulk transfer API.
4. Webhook Handler - Receives transfer.success, transfer.failed, and transfer.reversed events from Paystack and updates your payout records.
5. Reconciliation - A scheduled job that compares your database against Paystack's records, catches missed webhooks, and flags discrepancies.
This guide walks through each layer with working code. For the foundational concepts, see the transfers and payouts complete guide.
The payout queue is a database table where every payout request lives from creation through completion. Here is the schema.
// Payout record fields
// id (auto-increment primary key)
// vendor_id (foreign key to vendors table)
// recipient_code (string, Paystack recipient code)
// amount_minor_unit (integer, amount in kobo/cents)
// currency (string, e.g., 'NGN')
// reason (string, appears in bank statement)
// reference (string, unique, used as Paystack reference)
// status (string: created, pending_approval, approved, processing,
// completed, failed, reversed, retry_scheduled, cancelled)
// transfer_code (string, from Paystack response)
// failure_reason (string, from Paystack webhook)
// retry_count (integer, default 0)
// maker_id (foreign key to admin users)
// checker_id (foreign key to admin users)
// batch_id (string, groups payouts into execution batches)
// created_at (timestamp)
// approved_at (timestamp)
// initiated_at (timestamp)
// completed_at (timestamp)
// failed_at (timestamp)
// Create a payout request
async function createPayoutRequest(vendorId, amount, currency, reason, makerId) {
var vendor = await db.vendors.findById(vendorId);
if (!vendor || !vendor.paystackRecipientCode) {
throw new Error('Vendor has no payment details on file');
}
var payout = await db.payouts.create({
vendorId: vendorId,
recipientCode: vendor.paystackRecipientCode,
amountMinorUnit: amount,
currency: currency,
reason: reason,
reference: 'payout_' + Date.now() + '_' + vendorId,
status: 'pending_approval',
retryCount: 0,
makerId: makerId,
createdAt: new Date(),
});
return payout;
}
The status field is the heart of the queue. It tracks the payout through every stage: created (initial state), pending_approval (waiting for checker), approved (ready to send), processing (sent to Paystack, waiting for webhook), completed (transfer.success received), failed (transfer.failed received), reversed (transfer.reversed received), retry_scheduled (failed but will retry), or cancelled (manually cancelled before execution).
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// Approval with maker-checker and threshold rules
async function approvePayout(payoutId, checkerId) {
var payout = await db.payouts.findById(payoutId);
// Validation
if (payout.status !== 'pending_approval') {
throw new Error('Payout is not pending approval');
}
if (payout.makerId === checkerId) {
throw new Error('Maker and checker must be different people');
}
// Check checker's daily approval limit
var todayApproved = await db.payouts.sumApprovedByChecker(checkerId, today());
var checkerLimit = await db.admins.getApprovalLimit(checkerId);
if (todayApproved + payout.amountMinorUnit > checkerLimit) {
throw new Error('Approval would exceed your daily limit');
}
await db.payouts.update(payoutId, {
status: 'approved',
checkerId: checkerId,
approvedAt: new Date(),
});
}
// Auto-approval for small amounts
async function autoApproveIfEligible(payoutId) {
var payout = await db.payouts.findById(payoutId);
var autoApproveThreshold = 5000000; // 50,000 NGN
if (payout.amountMinorUnit <= autoApproveThreshold) {
await db.payouts.update(payoutId, {
status: 'approved',
checkerId: 'system_auto',
approvedAt: new Date(),
});
return true;
}
return false;
}
The approval layer is your safety net now that OTP is disabled. Without it, any code path that calls the transfer API sends money immediately. With it, there is always a deliberate step between "someone requested this payout" and "the money left the account." For detailed approval patterns, see payout approval workflows and maker-checker controls.
async function executeApprovedPayouts() {
// Fetch all approved payouts
var approved = await db.payouts.findByStatus('approved');
if (approved.length === 0) {
console.log('No approved payouts to process');
return;
}
// Group by currency
var byCurrency = {};
for (var i = 0; i < approved.length; i++) {
var currency = approved[i].currency;
if (!byCurrency[currency]) byCurrency[currency] = [];
byCurrency[currency].push(approved[i]);
}
for (var curr in byCurrency) {
var payouts = byCurrency[curr];
await executeCurrencyBatch(payouts, curr);
}
}
async function executeCurrencyBatch(payouts, currency) {
// Pre-flight balance check
var totalNeeded = 0;
for (var i = 0; i < payouts.length; i++) {
totalNeeded += payouts[i].amountMinorUnit;
}
var balance = await getAvailableBalance(currency);
if (balance.total < totalNeeded) {
console.error(
'Insufficient ' + currency + ' balance. Need: '
+ totalNeeded + ', Have: ' + balance.total
);
await alertFinance(currency, totalNeeded, balance.total);
return;
}
// Create a batch ID
var batchId = 'batch_' + currency + '_' + Date.now();
// Split into chunks of 100 for Paystack
var chunkSize = 100;
for (var c = 0; c < payouts.length; c += chunkSize) {
var chunk = payouts.slice(c, c + chunkSize);
var transfers = chunk.map(function(p) {
return {
amount: p.amountMinorUnit,
recipient: p.recipientCode,
reason: p.reason,
reference: p.reference,
};
});
try {
var result = await initiateBulkTransfer(currency, transfers);
// Mark all in this chunk as processing
for (var j = 0; j < chunk.length; j++) {
await db.payouts.update(chunk[j].id, {
status: 'processing',
batchId: batchId,
initiatedAt: new Date(),
});
}
} catch (err) {
console.error('Batch chunk failed: ' + err.message);
for (var k = 0; k < chunk.length; k++) {
await db.payouts.update(chunk[k].id, {
status: 'execution_error',
note: err.message,
});
}
}
// Delay between chunks
if (c + chunkSize < payouts.length) {
await new Promise(function(r) { setTimeout(r, 3000); });
}
}
}
Run this on a schedule (cron job) or trigger it manually from your admin dashboard. The choice depends on your business needs: scheduled for predictable payouts (weekly vendor settlements), manual for ad-hoc payouts (refunds, bonuses).
app.post('/webhooks/paystack', async (req, res) => {
res.sendStatus(200);
var event = req.body;
var reference = event.data.reference;
var payout = await db.payouts.findByReference(reference);
if (!payout) {
console.error('Webhook for unknown reference: ' + reference);
return;
}
switch (event.event) {
case 'transfer.success':
await db.payouts.update(payout.id, {
status: 'completed',
completedAt: new Date(event.data.updated_at),
});
// Notify vendor
await notifyVendor(payout.vendorId,
'Your payout of ' + formatCurrency(payout.amountMinorUnit, payout.currency)
+ ' has been sent to your bank account.'
);
break;
case 'transfer.failed':
await db.payouts.update(payout.id, {
status: 'failed',
failureReason: event.data.reason,
failedAt: new Date(event.data.updated_at),
});
// Classify and potentially retry
var isTransient = classifyFailure(event.data.reason) === 'transient';
if (isTransient && payout.retryCount < 3) {
await scheduleRetry(payout);
}
break;
case 'transfer.reversed':
await db.payouts.update(payout.id, {
status: 'reversed',
reversedAt: new Date(event.data.updated_at),
});
await alertOps('REVERSAL: ' + reference);
break;
}
});
The webhook handler must be idempotent. Paystack may send the same event more than once (network retries). If you process a transfer.success event twice, your code should not send two vendor notifications or update the completed timestamp twice. Check the current status before applying changes.
async function dailyReconciliation() {
// Find payouts stuck in 'processing' for more than 6 hours
var sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000);
var stuckPayouts = await db.payouts.findProcessingBefore(sixHoursAgo);
var fixed = 0;
var escalated = 0;
for (var i = 0; i < stuckPayouts.length; i++) {
var payout = stuckPayouts[i];
// Check with Paystack
try {
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 && data.data) {
var paystackStatus = data.data.status;
if (paystackStatus === 'success') {
await db.payouts.update(payout.id, {
status: 'completed',
completedAt: new Date(),
note: 'Fixed by reconciliation (missed webhook)',
});
fixed++;
} else if (paystackStatus === 'failed') {
await db.payouts.update(payout.id, {
status: 'failed',
failureReason: data.data.reason || 'Unknown (from reconciliation)',
note: 'Fixed by reconciliation (missed webhook)',
});
fixed++;
} else {
// Still pending on Paystack, escalate if very old
escalated++;
}
}
} catch (err) {
console.error('Reconciliation check failed for ' + payout.reference);
}
await new Promise(function(r) { setTimeout(r, 200); });
}
console.log('Reconciliation: fixed=' + fixed + ', escalated=' + escalated);
}
Schedule this to run once or twice daily. The reconciliation job is your safety net for missed webhooks, which happen more often than you would expect. Server restarts, network blips, response timeouts, and deployment windows can all cause your webhook handler to miss events.
A payout system needs monitoring that tells you when things are off before your vendors notice.
async function payoutHealthCheck() {
var now = new Date();
var oneHourAgo = new Date(now - 60 * 60 * 1000);
var oneDayAgo = new Date(now - 24 * 60 * 60 * 1000);
var metrics = {
pendingApproval: await db.payouts.countByStatus('pending_approval'),
processing: await db.payouts.countByStatus('processing'),
failedToday: await db.payouts.countFailedSince(oneDayAgo),
completedToday: await db.payouts.countCompletedSince(oneDayAgo),
stuckProcessing: await db.payouts.countProcessingBefore(oneHourAgo),
};
// Alert conditions
if (metrics.stuckProcessing > 0) {
await alert('WARNING: ' + metrics.stuckProcessing
+ ' payouts stuck in processing for over 1 hour');
}
if (metrics.failedToday > metrics.completedToday * 0.1) {
await alert('WARNING: Failure rate exceeds 10% today. '
+ metrics.failedToday + ' failed, '
+ metrics.completedToday + ' completed.');
}
if (metrics.pendingApproval > 50) {
await alert('INFO: ' + metrics.pendingApproval
+ ' payouts awaiting approval. Review queue is building up.');
}
return metrics;
}
Run this health check on a schedule (every 15-30 minutes) and send alerts through Slack, email, or whatever your team uses. The goal is to catch issues in minutes, not hours. A 10% failure rate at 3 PM is a small problem to investigate. The same failure rate discovered at 3 AM when vendors start complaining is a crisis.
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