Build a Referral Payout System
Build a referral payout system by generating unique referral codes per user, tracking conversions when referred users complete a paid action, crediting the referring user's balance with a commission, and paying out via Paystack Transfers when they request a withdrawal. Never pay referral earnings automatically — hold in a pending balance and pay on explicit request.
Referral Tracking and Commission Logic
Tables: users (id, email, referral_code, referred_by, pending_balance, paid_balance), referrals (referrer_id, referred_id, status, commission_amount, paid_at), withdrawals (user_id, amount, status, paystack_transfer_ref, requested_at).
// Generate unique referral code on signup
function generateReferralCode(userId) {
return 'REF-' + userId.toString(36).toUpperCase() + '-' + Math.random().toString(36).slice(2, 6).toUpperCase();
}
// When a referred user registers
async function handleReferralSignup(newUserId, referralCode) {
var referrer = await db.users.findByReferralCode(referralCode);
if (!referrer) return;
await db.referrals.create({
referrer_id: referrer.id,
referred_id: newUserId,
status: 'signed_up', // only credits commission after first payment
});
await db.users.update(newUserId, { referred_by: referrer.id });
}
// Webhook: when referred user makes their first payment, credit commission
if (event.event === 'charge.success') {
var payingUserId = event.data.metadata.user_id;
var referral = await db.referrals.findPendingByReferredId(payingUserId);
if (referral && referral.status === 'signed_up') {
var commission = Math.floor(event.data.amount * 0.1); // 10% commission
await db.referrals.update(referral.id, { status: 'converted', commission_amount: commission });
await db.users.increment(referral.referrer_id, 'pending_balance', commission);
await notifyReferrer(referral.referrer_id, commission);
}
}
Withdrawal Request and Transfer
async function requestWithdrawal(userId, amount, bankDetails) {
var user = await db.users.findById(userId);
var minWithdrawal = 500000; // NGN 5,000 in kobo
if (amount < minWithdrawal) throw new Error('Minimum withdrawal is NGN 5,000');
if (amount > user.pending_balance) throw new Error('Insufficient balance');
// Register recipient if not already done
if (!user.paystack_recipient_code) {
var recRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'nuban', name: bankDetails.name, account_number: bankDetails.account, bank_code: bankDetails.bank_code, currency: 'NGN' }),
});
var recData = await recRes.json();
await db.users.update(userId, { paystack_recipient_code: recData.data.recipient_code });
user.paystack_recipient_code = recData.data.recipient_code;
}
// Deduct from balance immediately (prevent double withdrawal)
await db.users.decrement(userId, 'pending_balance', amount);
// Initiate transfer
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, recipient: user.paystack_recipient_code, reason: 'Referral payout' }),
});
var transfer = await res.json();
await db.withdrawals.create({ user_id: userId, amount, status: 'processing', paystack_transfer_ref: transfer.data.reference });
}
// Webhook: handle transfer outcome
if (event.event === 'transfer.success') {
await db.withdrawals.update({ ref: event.data.reference }, { status: 'completed' });
await db.users.increment(event.data.metadata.user_id, 'paid_balance', event.data.amount);
}
if (event.event === 'transfer.failed') {
// Restore balance on failure
await db.users.increment(event.data.metadata.user_id, 'pending_balance', event.data.amount);
await db.withdrawals.update({ ref: event.data.reference }, { status: 'failed' });
await notifyUserOfFailedWithdrawal(event.data.metadata.user_id);
}
Learn More
See build an affiliate commission engine for a more detailed affiliate tracking system with conversion attribution.
Key Takeaways
- ✓Track referrals with a unique code per user, not a shared link — unique codes prevent fraud.
- ✓Credit commissions to a pending_balance only after the referred user's payment clears (webhook-driven).
- ✓Pay out via Paystack Transfers on explicit withdrawal request — not automatically.
- ✓Implement a minimum withdrawal threshold (NGN 5,000) to reduce small transfer fees.
- ✓Handle transfer.failed webhooks — retry or notify the user to update their bank details.
Frequently Asked Questions
- How do I prevent referral fraud (users creating fake accounts)?
- Only credit commissions after the referred user makes a real payment via Paystack (not just after signup). Add velocity limits — flag if a single referrer converts more than 5 referrals per day. Require phone number verification on signup. Consider a 30-day hold before commissions can be withdrawn, giving time to detect and reverse fraudulent activity.
- Should I pay commissions on recurring revenue or just the first purchase?
- Most referral programs pay on the first purchase only (simple and clear). Recurring commission (earning 5% on every purchase the referred user makes) requires more complex tracking but creates stronger incentives for high-value referrers. Decide based on your customer LTV — if customers spend consistently over time, recurring commissions make sense.
- What taxes apply to referral income in Nigeria or Kenya?
- Referral commissions are income and may be subject to personal income tax (PIT) or withholding tax in Nigeria and Kenya. In Nigeria, withholding tax (WHT) of 5% may apply on commissions paid to individuals. In Kenya, withholding tax may apply depending on the amount and recipient's tax status. Consult a tax advisor — this is not legal or tax advice.
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