Bonaventure OgetoBy Bonaventure Ogeto|

Building an Automated Payout System on Paystack

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).

System Architecture Overview

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.

Designing the Payout Queue

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).

Approval Workflow

// 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.

Batch Execution Engine

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).

Webhook-Driven Status Updates

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.

Daily Reconciliation Job

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.

Monitoring and Alerts

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.

Build Production Payment Systems

An automated payout system is one of the most complex pieces of payment infrastructure you will build. Getting it right means your vendors trust your platform and your operations team sleeps at night.

The McTaba bootcamp covers payment systems at production scale. You build real payout pipelines, handle real edge cases, and deploy to production.

Create a free McTaba account

Key Takeaways

  • A payout system has five layers: queue, approval, execution, webhook handling, and reconciliation. Skip any layer and you will have problems at scale.
  • Every payout request goes into a database queue first. Nothing goes to Paystack until the payout is approved and the system has verified sufficient balance.
  • Disable OTP on Paystack and replace it with application-level approval controls: maker-checker, threshold-based auto-approval, or batch review.
  • Execute approved payouts in batches of up to 100 using the bulk transfer endpoint. Add delays between batches to respect rate limits.
  • Webhook handlers are the source of truth for payout outcomes. Never mark a payout complete based on the API response alone.
  • Run a reconciliation job daily to catch missed webhooks, stuck payouts, and discrepancies between your database and Paystack.

Frequently Asked Questions

How often should I run the payout execution job?
It depends on your business. Marketplace vendor payouts typically run weekly or biweekly. Ride-hailing driver payouts might run daily. Payroll runs monthly. Some platforms run the execution job every hour and process whatever is approved. Match the cadence to your vendors expectations and your operational capacity.
What happens if the execution job crashes mid-batch?
The payouts that were already sent to Paystack continue processing normally. Payouts that were not yet sent remain in the approved state and will be picked up on the next execution run. Because each payout has a deterministic reference, re-running the job will not create duplicate transfers for payouts that already went through.
Should I build this in-house or use a third-party payout service?
For most African startups, building on Paystack directly gives you the most control and the lowest cost. Third-party payout orchestration services add a layer of abstraction but also add fees and a dependency. If your payout needs are straightforward (same country, one currency, standard bank accounts), build it yourself. If you need multi-country, multi-currency, complex compliance workflows, consider a payout orchestration layer.
How do I handle payouts to recipients in multiple countries?
Run separate payout batches per currency. Each batch goes through the same pipeline: queue, approve, check balance, execute. The balance check ensures you have sufficient funds in the specific currency. You cannot transfer NGN to a KES recipient or vice versa.

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