Bonaventure OgetoBy Bonaventure Ogeto|

System Design Interview: Design a Payout System

A payout system design: 1) Payout Request Service (creates payout record with status "pending"), 2) Approval Service (dual approval for large amounts, auto-approve for small), 3) Transfer Dispatcher (calls POST /transfer when approved), 4) Status Poller (polls GET /transfer/:id or listens for transfer.success/failed webhooks), 5) Retry Handler (exponential backoff for failed transfers), 6) Audit Log (immutable record of every state change). Never transfer without first checking balance, never retry more than 3-5 times without human review.

Payout System Components

Finance Team / Automated Trigger
          |
          | POST /payouts/request
          v
Payout Request Service
   - Creates payout record: { id, recipient_code, amount, status: 'pending', created_by, created_at }
   - Inserts into payout_requests table
          |
          | (amount > threshold → approval required)
          v
Approval Service
   - Sends notification to approver(s)
   - Approver 1 approves → status: 'pending_second_approval'
   - Approver 2 approves → status: 'approved'
   - Either rejects → status: 'rejected', notify requester
          |
          | (status = 'approved')
          v
Transfer Dispatcher
   - Checks Paystack balance (GET /balance)
   - Calls POST /transfer with idempotency key = payout_id
   - Updates status: 'transfer_initiated', stores transfer_code
   - Returns transfer_code to audit log
          |
          | (async)
          v
Status Resolver (webhook OR polling fallback)
   - transfer.success → status: 'completed'
   - transfer.failed → status: 'failed', trigger retry or escalate
   - No webhook in 24h → poll GET /transfer/:code

Idempotency for Money Movement

Never call POST /transfer twice for the same payout request. Use your payout_id as the idempotency key:

async function dispatchTransfer(payoutRequest) {
  // Check if we already initiated a transfer for this payout
  if (payoutRequest.transfer_code) {
    // Already dispatched — check status, do not re-dispatch
    return await getTransferStatus(payoutRequest.transfer_code);
  }

  var res = await fetch('https://api.paystack.co/transfer', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      source: 'balance',
      amount: payoutRequest.amount,
      recipient: payoutRequest.recipient_code,
      reason: payoutRequest.reason,
      reference: 'payout_' + payoutRequest.id, // unique per payout
    }),
  });

  var data = await res.json();
  // Store transfer_code before returning — if we crash here, the payout_request still has the code
  await db.payoutRequests.update(payoutRequest.id, { transfer_code: data.data.transfer_code, status: 'transfer_initiated' });
  return data.data;
}

Failure Mode Discussion

  • Insufficient balance — check GET /balance before dispatching. Queue the transfer for when balance is sufficient, or alert finance team. Never retry blindly on insufficient balance errors.
  • Bank account invalid — transfer.failed with gateway_response "Invalid Account Number". Do not retry. Alert the requester to verify recipient details.
  • Network timeout during POST /transfer — you don't know if Paystack received the request. Check the payout_requests table: if transfer_code is null, you can safely retry (Paystack deduplicates on reference). If transfer_code is set, poll GET /transfer/:code for status.
  • transfer.success webhook never arrives — poll GET /transfer/:code after 24 hours. If status is "success", update DB. If still "pending", escalate to Paystack support.

Learn More

See system design: payment service for the payment collection version.

Sign up for the McTaba newsletter

Key Takeaways

  • Payout requests must be idempotent — retrying a failed transfer must not create a second transfer.
  • Dual approval workflow for large amounts is a security and compliance requirement, not optional.
  • Every state change in a payout (pending → approved → sent → success/failed) must be audited.
  • Check Paystack balance before dispatching transfers — insufficient balance returns a 400 error.
  • Distinguish between "transfer not initiated yet" and "transfer initiated but no confirmation" — different recovery paths.

Frequently Asked Questions

What amount threshold should I suggest for dual approval?
In your interview answer, propose a configurable threshold rather than a hardcoded one. Common patterns: NGN 500,000 / KES 50,000 requires dual approval, larger amounts require CFO approval. The exact threshold is a business decision — what matters in the interview is that you know dual approval is required and that the threshold should be configurable.
Should the payout system use webhooks or polling to detect transfer status?
Both, with webhooks as primary. Configure Paystack to send transfer.success and transfer.failed webhooks. This handles the happy path quickly. For the fallback: a background job polls GET /transfer for any payouts in "transfer_initiated" status for more than 30 minutes with no webhook. This covers Paystack webhook delivery failures.
How do you handle bulk payouts (1,000+ vendor payouts) in the system?
Use the Paystack bulk transfer endpoint (POST /transfer/bulk). In your system design, the Transfer Dispatcher batches payout requests into groups of up to 100 (Paystack's batch limit), calls bulk transfer for each batch, and stores the batch_id. The Status Resolver polls GET /transfer/bulk/:batch_id for status updates. Approval workflow runs per individual payout before batching.

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