Bonaventure OgetoBy Bonaventure Ogeto|

Payout Approval Workflows and Maker-Checker Controls

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.

Why You Need Approval Controls

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:

  • Fraud prevention: No single person can both create and execute a payout. Even if someone's account is compromised, they cannot drain funds without a second person's approval.
  • Error prevention: A second pair of eyes catches mistakes before money moves. Wrong amount, wrong recipient, wrong period.
  • Compliance: Financial regulators in most African markets expect businesses handling funds on behalf of others to have documented payment controls. An audit trail showing who approved what and when is your defense during audits.

Implementing Maker-Checker

// 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);
}

Threshold-Based Auto-Approval

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.

Building the Audit Trail

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.

Building the Approval Dashboard

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.

Role-Based Access for Payout Operations

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.

Build Trustworthy Payment Operations

Approval workflows are what make your payout system audit-ready and fraud-resistant. They are not exciting to build, but they are what separates a production system from a prototype.

The McTaba bootcamp covers payment operations and security as part of the payment integration module.

Create a free McTaba account

Key Takeaways

  • Maker-checker is the gold standard for payout controls. One person creates the payout request, a different person approves it. Neither can do both.
  • Threshold-based auto-approval reduces operational overhead. Small payouts below a set amount can be auto-approved, while large ones require human review.
  • Every approval action should be logged: who approved, when, from which IP address. This audit trail is essential for dispute resolution and compliance.
  • Role-based access ensures only authorized team members can create, approve, or execute payouts. Separate maker, checker, and executor roles.
  • Build an approval dashboard that shows pending payouts with recipient details, amounts, and context so checkers can make informed decisions quickly.
  • Multi-level approval chains (manager, then director) are warranted for very large payouts. Keep the chain short to avoid bottlenecks.

Frequently Asked Questions

Can the maker and checker be the same person for small payouts?
For auto-approved payouts below your threshold, the system acts as the checker. For manually approved payouts, you should always enforce that the maker and checker are different people, regardless of the amount. This is a fundamental principle of financial controls.
What if there is only one person on the team who can approve?
This is common in small startups. The pragmatic approach is to have the CEO or founder as the backup checker. Alternatively, use threshold-based auto-approval for routine payouts and only require manual approval for amounts above a reasonable threshold. As you grow, add more people to the checker role.
How long should payouts wait for approval before escalating?
Set an SLA for approval and escalate when it is breached. A common setup: payouts under 24 hours old are normal, payouts between 24-48 hours old trigger a reminder to available checkers, payouts over 48 hours old escalate to a manager. Vendors waiting too long for payout is a trust issue.

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