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