A maker-checker workflow requires two different people to process a payout: the maker creates the request, the checker reviews and approves it. Implement this with a payouts table that tracks maker_id and checker_id, a validation rule that prevents the same person from filling both roles, and optional threshold-based auto-approval for small amounts that do not warrant human review.
When OTP is enabled on Paystack, every transfer has a built-in approval step: someone must enter the code. When you disable OTP for automation, that approval step disappears. Any valid API call moves money. Your code is the only thing between an approved payout and an unauthorized one.
Approval workflows replace OTP with something better: structured, auditable controls that fit your business process. Instead of a generic code sent to one phone number, you get specific approvals from named individuals with documented reasons and timestamps.
This matters for three reasons:
// Database schema additions for approval workflow
// payout table fields:
// maker_id (integer, FK to admin_users)
// checker_id (integer, FK to admin_users, nullable)
// approved_at (timestamp, nullable)
// rejected_at (timestamp, nullable)
// rejection_reason (string, nullable)
// approval_ip (string, nullable)
// Maker creates a payout request
async function createPayout(makerId, vendorId, amount, reason) {
var vendor = await db.vendors.findById(vendorId);
var payout = await db.payouts.create({
vendorId: vendorId,
recipientCode: vendor.paystackRecipientCode,
amountMinorUnit: amount,
reason: reason,
reference: 'payout_' + vendorId + '_' + Date.now(),
status: 'pending_approval',
makerId: makerId,
createdAt: new Date(),
});
// Notify available checkers
var checkers = await db.adminUsers.findByRole('checker');
for (var i = 0; i < checkers.length; i++) {
if (checkers[i].id !== makerId) {
await notify(checkers[i].id, 'New payout awaiting approval: '
+ formatCurrency(amount) + ' to ' + vendor.businessName);
}
}
return payout;
}
// Checker approves
async function approvePayout(payoutId, checkerId, ipAddress) {
var payout = await db.payouts.findById(payoutId);
if (payout.status !== 'pending_approval') {
throw new Error('Payout is not pending approval');
}
if (payout.makerId === checkerId) {
throw new Error('You cannot approve your own payout request');
}
await db.payouts.update(payoutId, {
status: 'approved',
checkerId: checkerId,
approvedAt: new Date(),
approvalIp: ipAddress,
});
// Log the approval action
await db.auditLog.create({
action: 'payout_approved',
payoutId: payoutId,
userId: checkerId,
ipAddress: ipAddress,
details: 'Approved payout of ' + payout.amountMinorUnit
+ ' to vendor ' + payout.vendorId,
timestamp: new Date(),
});
}
// Checker rejects
async function rejectPayout(payoutId, checkerId, reason) {
var payout = await db.payouts.findById(payoutId);
if (payout.status !== 'pending_approval') {
throw new Error('Payout is not pending approval');
}
await db.payouts.update(payoutId, {
status: 'rejected',
checkerId: checkerId,
rejectedAt: new Date(),
rejectionReason: reason,
});
// Notify the maker
await notify(payout.makerId,
'Your payout request was rejected: ' + reason);
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Not every payout needs human review. A 500 NGN vendor payout for a single order does not warrant the same scrutiny as a 5,000,000 NGN monthly settlement. Use thresholds to auto-approve low-risk payouts while routing high-value ones to human checkers.
var APPROVAL_THRESHOLDS = {
NGN: {
autoApprove: 5000000, // Auto-approve below 50,000 NGN
singleApproval: 50000000, // Single checker up to 500,000 NGN
dualApproval: Infinity, // Two checkers above 500,000 NGN
},
KES: {
autoApprove: 500000, // Auto-approve below 5,000 KES
singleApproval: 5000000, // Single checker up to 50,000 KES
dualApproval: Infinity,
},
};
async function routePayoutForApproval(payoutId) {
var payout = await db.payouts.findById(payoutId);
var thresholds = APPROVAL_THRESHOLDS[payout.currency];
if (!thresholds) {
// Unknown currency, require manual approval
return;
}
if (payout.amountMinorUnit <= thresholds.autoApprove) {
await db.payouts.update(payoutId, {
status: 'approved',
checkerId: 'system_auto',
approvedAt: new Date(),
});
await db.auditLog.create({
action: 'payout_auto_approved',
payoutId: payoutId,
details: 'Amount below auto-approval threshold',
});
} else if (payout.amountMinorUnit <= thresholds.singleApproval) {
// Single checker required
await db.payouts.update(payoutId, {
approvalLevel: 'single',
});
} else {
// Dual checker required
await db.payouts.update(payoutId, {
approvalLevel: 'dual',
});
}
}
Review and adjust thresholds as your business grows. A startup might auto-approve everything below 10,000 NGN. A company processing millions daily might only auto-approve below 1,000 NGN. The right threshold depends on your risk tolerance and operational capacity.
Every payout action should be logged. Who created it, who approved it, who rejected it, when each action happened, and from which IP address. This audit trail serves three purposes: dispute resolution with vendors, internal investigations, and regulatory compliance.
// Audit log schema
// id (auto-increment)
// action (string: payout_created, payout_approved, payout_rejected,
// payout_executed, payout_completed, payout_failed)
// payout_id (FK to payouts)
// user_id (FK to admin_users, nullable for system actions)
// ip_address (string)
// details (text)
// timestamp (datetime)
async function logPayoutAction(action, payoutId, userId, ipAddress, details) {
await db.auditLog.create({
action: action,
payoutId: payoutId,
userId: userId || 'system',
ipAddress: ipAddress || 'internal',
details: details,
timestamp: new Date(),
});
}
// Usage throughout the payout lifecycle
await logPayoutAction('payout_created', payout.id, makerId, req.ip,
'Created payout of ' + amount + ' to vendor ' + vendorId);
await logPayoutAction('payout_approved', payout.id, checkerId, req.ip,
'Approved by ' + checkerName);
await logPayoutAction('payout_executed', payout.id, null, null,
'Sent to Paystack. Transfer code: ' + transferCode);
Store audit logs separately from the payouts table. They should be append-only (never update or delete). In a dispute or investigation, you need the complete history showing every action taken on a payout, in chronological order, with the responsible person identified.
Checkers need a clear view of what is waiting for their approval. Build a simple dashboard that shows pending payouts with enough context to make informed decisions.
The dashboard should display: the vendor name and business details, the payout amount and currency, the reason or description, the maker's name (who requested it), how long the payout has been waiting, the vendor's payout history (is this amount consistent with past payouts?), and action buttons for approve and reject.
Add quick filters: sort by amount (largest first for priority review), filter by currency, filter by maker (to review one person's batch). Include a summary at the top: total pending, total amount pending, number of payouts awaiting dual approval.
For the checker experience, speed matters. If approving a payout takes 30 seconds of clicking through screens, your checker will batch their reviews and your vendors wait longer. Make it possible to approve with one click after reviewing the details.
For the broader payout system architecture, see building an automated payout system on Paystack.
Separate your payout operations into distinct roles with specific permissions.
Maker: Can create payout requests. Cannot approve or execute. Typically operations staff, account managers, or the system itself (for automated calculation of vendor earnings).
Checker: Can approve or reject payout requests. Cannot create or execute. Typically finance managers or senior operations staff. Must be a different person than the maker.
Executor: Can trigger the batch execution of approved payouts. In automated systems, this is a system process, not a person. In semi-manual systems, a senior finance person clicks "Run Payouts" after reviewing the approved batch.
Viewer: Can see payout status and history. Cannot take any action. Useful for support staff who need to answer vendor questions about payout status without having the ability to create or approve payouts.
Enforce these roles at the application level. Your API endpoints for payout creation, approval, and execution should check the user's role before allowing the action. Do not rely on hiding UI buttons as your only access control.
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