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.
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
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;
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
See system design: payment service for the payment collection version.
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