Bonaventure OgetoBy Bonaventure Ogeto|

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.

Sign up for the McTaba newsletter

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