Back to Blog

SaaS Referral System: Tracking, Reward Logic, Fraud Prevention, and Analytics

Build a production SaaS referral system: referral link generation, conversion tracking, reward fulfillment with Stripe credits, fraud prevention rules, and referral funnel analytics in TypeScript and PostgreSQL.

Viprasol Tech Team
November 20, 2026
13 min read

A well-designed referral program is the highest-CAC-efficiency growth channel in SaaS. Dropbox grew 3900% with referrals. But the engineering behind it is deceptively complex: attributing conversions correctly, fulfilling rewards without double-paying, and blocking the inevitable fraud attempts. This post covers the complete implementation: database schema, referral link generation, conversion tracking, Stripe credit rewards, fraud rules, and funnel analytics.

System Design

Referrer generates link: /signup?ref=abc123
         โ”‚
         โ–ผ
Visitor clicks โ†’ cookie set (30-day window) + referral_click recorded
         โ”‚
         โ–ผ
Visitor signs up โ†’ referral_conversion created (status: pending)
         โ”‚
         โ–ผ
Fraud check passes + qualifying event (paid, activated) โ†’
  referral_conversion.status = 'approved'
  reward issued (Stripe credit to referrer + optionally to referee)

1. Database Schema

-- Referral codes: one per user (or custom vanity codes)
CREATE TABLE referral_codes (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id   UUID        NOT NULL REFERENCES accounts(id),
  code        TEXT        NOT NULL UNIQUE,
  is_active   BOOLEAN     NOT NULL DEFAULT true,
  
  -- Limits
  max_uses    INTEGER,    -- NULL = unlimited
  uses_count  INTEGER     NOT NULL DEFAULT 0,
  
  -- Custom reward override (NULL = use program defaults)
  referrer_reward_cents  INTEGER,
  referee_reward_cents   INTEGER,
  
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  INDEX idx_referral_codes_user (user_id)
);

-- Track each click on a referral link
CREATE TABLE referral_clicks (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  code_id     UUID        NOT NULL REFERENCES referral_codes(id),
  session_id  TEXT        NOT NULL,   -- Anonymous session identifier
  ip_hash     TEXT,                   -- SHA-256 of IP (for fraud, not stored raw)
  user_agent  TEXT,
  landing_url TEXT,
  clicked_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  INDEX idx_referral_clicks_code (code_id, clicked_at DESC),
  INDEX idx_referral_clicks_session (session_id)
);

-- Conversion: when a referred user signs up + qualifies
CREATE TABLE referral_conversions (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  code_id         UUID        NOT NULL REFERENCES referral_codes(id),
  referrer_id     UUID        NOT NULL REFERENCES users(id),
  referee_id      UUID        NOT NULL REFERENCES users(id),
  
  status          TEXT        NOT NULL DEFAULT 'pending'
                              CHECK (status IN ('pending', 'approved', 'paid', 'rejected', 'expired')),
  
  -- Qualifying event that triggers approval
  qualifying_event TEXT,      -- 'first_payment', 'plan_upgrade', 'activation'
  qualifying_event_at TIMESTAMPTZ,
  
  -- Fraud signals
  fraud_score     INTEGER     NOT NULL DEFAULT 0,  -- 0-100
  fraud_flags     TEXT[]      NOT NULL DEFAULT '{}',
  
  -- Rewards
  referrer_reward_cents   INTEGER NOT NULL DEFAULT 0,
  referee_reward_cents    INTEGER NOT NULL DEFAULT 0,
  referrer_reward_issued  BOOLEAN NOT NULL DEFAULT false,
  referee_reward_issued   BOOLEAN NOT NULL DEFAULT false,
  
  -- Stripe credit IDs for idempotency
  referrer_credit_id  TEXT UNIQUE,
  referee_credit_id   TEXT UNIQUE,
  
  approved_at     TIMESTAMPTZ,
  paid_at         TIMESTAMPTZ,
  rejected_at     TIMESTAMPTZ,
  rejection_reason TEXT,
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  UNIQUE (referee_id),     -- One conversion per referee
  INDEX idx_conversions_referrer (referrer_id, status),
  INDEX idx_conversions_pending (status, created_at) WHERE status = 'pending'
);

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

2. Referral Code Generation and Link Tracking

// src/services/referral/codes.ts
import { nanoid } from 'nanoid';
import { db } from '../../lib/db';

const CODE_LENGTH = 8; // 8 chars = ~281 trillion combinations

export async function getOrCreateReferralCode(userId: string, tenantId: string): Promise<string> {
  const existing = await db.referralCode.findFirst({
    where: { userId, isActive: true },
    select: { code: true },
  });

  if (existing) return existing.code;

  // Retry on collision (nanoid collisions are extremely rare but possible)
  for (let attempt = 0; attempt < 5; attempt++) {
    const code = nanoid(CODE_LENGTH).toLowerCase();

    try {
      await db.referralCode.create({
        data: { userId, tenantId, code },
      });
      return code;
    } catch (err: any) {
      if (err.code !== 'P2002') throw err; // Retry only on unique constraint
    }
  }

  throw new Error('Failed to generate unique referral code after 5 attempts');
}

export function buildReferralUrl(code: string, baseUrl: string): string {
  return `${baseUrl}/signup?ref=${code}`;
}

// Track a click when someone visits a referral link
export async function trackReferralClick(
  code: string,
  sessionId: string,
  ip: string,
  userAgent: string,
  landingUrl: string
): Promise<void> {
  const referralCode = await db.referralCode.findUnique({
    where: { code, isActive: true },
  });

  if (!referralCode) return; // Invalid or inactive code โ€” ignore silently

  // Hash IP for fraud signals (don't store raw IPs)
  const ipHash = require('crypto')
    .createHash('sha256')
    .update(ip + process.env.IP_HASH_SALT)
    .digest('hex')
    .slice(0, 16); // First 16 chars is sufficient

  await db.referralClick.create({
    data: {
      codeId: referralCode.id,
      sessionId,
      ipHash,
      userAgent,
      landingUrl,
    },
  });
}

3. Conversion Attribution

// src/services/referral/attribution.ts
import { db } from '../../lib/db';

// Called at signup โ€” attribute referral from cookie/query param
export async function attributeReferral(
  refereeId: string,
  referralCode: string,
  sessionId: string
): Promise<void> {
  const code = await db.referralCode.findUnique({
    where: { code: referralCode, isActive: true },
    include: { user: true },
  });

  if (!code) return;

  // Can't refer yourself
  if (code.userId === refereeId) return;

  // Check max uses
  if (code.maxUses && code.usesCount >= code.maxUses) return;

  // Get fraud score for this signup
  const fraudScore = await computeFraudScore(refereeId, code, sessionId);
  const fraudFlags = await getFraudFlags(refereeId, code, sessionId);

  // Determine rewards
  const referrerReward = code.referrerRewardCents ?? PROGRAM_DEFAULTS.referrerRewardCents;
  const refereeReward = code.refereeRewardCents ?? PROGRAM_DEFAULTS.refereeRewardCents;

  await db.$transaction([
    db.referralConversion.create({
      data: {
        codeId: code.id,
        referrerId: code.userId,
        refereeId,
        fraudScore,
        fraudFlags,
        referrerRewardCents: referrerReward,
        refereeRewardCents: refereeReward,
      },
    }),
    db.referralCode.update({
      where: { id: code.id },
      data: { usesCount: { increment: 1 } },
    }),
  ]);
}

const PROGRAM_DEFAULTS = {
  referrerRewardCents: 2000,  // $20 credit
  refereeRewardCents: 1000,   // $10 credit
  qualifyingEvent: 'first_payment',
  fraudScoreThreshold: 60,    // Reject if score >= 60
};

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

4. Fraud Prevention

// src/services/referral/fraud.ts
import { db } from '../../lib/db';
import crypto from 'crypto';

export async function computeFraudScore(
  refereeId: string,
  code: ReferralCode & { user: User },
  sessionId: string
): Promise<number> {
  let score = 0;
  const flags: string[] = [];

  const [referee, clicksFromSession, recentConversionsFromReferrer] =
    await Promise.all([
      db.user.findUnique({
        where: { id: refereeId },
        select: { createdAt: true, email: true },
      }),
      db.referralClick.count({
        where: { sessionId, codeId: code.id },
      }),
      db.referralConversion.count({
        where: {
          referrerId: code.userId,
          createdAt: { gte: new Date(Date.now() - 7 * 86_400_000) },
        },
      }),
    ]);

  if (!referee) return 100; // Can't find referee โ€” reject

  // Signal: same session clicked and signed up (same browser)
  // This is actually GOOD โ€” score stays low

  // Signal: multiple conversions from same referrer in 7 days
  if (recentConversionsFromReferrer > 10) {
    score += 20;
    flags.push('high_volume_referrer');
  }

  // Signal: referrer and referee share email domain (potential self-referral)
  const referrerDomain = code.user.email.split('@')[1];
  const refereeDomain = referee.email.split('@')[1];
  if (referrerDomain === refereeDomain && !CORPORATE_DOMAIN_EXCEPTIONS.has(referrerDomain)) {
    score += 25;
    flags.push('same_email_domain');
  }

  // Signal: disposable email address
  if (DISPOSABLE_EMAIL_DOMAINS.has(refereeDomain)) {
    score += 40;
    flags.push('disposable_email');
  }

  // Signal: account created within 5 minutes of click (bot-like speed)
  const lastClick = await db.referralClick.findFirst({
    where: { sessionId, codeId: code.id },
    orderBy: { clickedAt: 'desc' },
  });

  if (lastClick) {
    const signupDelay = referee.createdAt.getTime() - lastClick.clickedAt.getTime();
    if (signupDelay < 60_000) { // < 1 minute
      score += 30;
      flags.push('instant_signup');
    }
  }

  // Update flags in DB
  await db.referralConversion.updateMany({
    where: { refereeId, status: 'pending' },
    data: { fraudFlags: flags },
  });

  return Math.min(100, score);
}

const DISPOSABLE_EMAIL_DOMAINS = new Set([
  'mailinator.com', 'guerrillamail.com', 'tempmail.com',
  'throwaway.email', 'yopmail.com', '10minutemail.com',
]);

const CORPORATE_DOMAIN_EXCEPTIONS = new Set([
  'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com',
]);

5. Reward Fulfillment with Stripe

// src/services/referral/rewards.ts
import { stripe } from '../../lib/stripe';
import { db } from '../../lib/db';

// Called when qualifying event occurs (e.g., first payment succeeded)
export async function approveAndRewardReferral(
  refereeId: string,
  qualifyingEvent: string
): Promise<void> {
  const conversion = await db.referralConversion.findUnique({
    where: { refereeId },
    include: {
      referrer: { include: { account: { include: { subscription: true } } } },
      referee: { include: { account: { include: { subscription: true } } } },
    },
  });

  if (!conversion || conversion.status !== 'pending') return;

  // Reject high-fraud conversions
  if (conversion.fraudScore >= PROGRAM_DEFAULTS.fraudScoreThreshold) {
    await db.referralConversion.update({
      where: { id: conversion.id },
      data: {
        status: 'rejected',
        rejectedAt: new Date(),
        rejectionReason: `Fraud score too high: ${conversion.fraudScore}`,
      },
    });
    return;
  }

  // Approve and issue rewards
  await db.referralConversion.update({
    where: { id: conversion.id },
    data: {
      status: 'approved',
      approvedAt: new Date(),
      qualifyingEvent,
      qualifyingEventAt: new Date(),
    },
  });

  // Issue Stripe credits (idempotent via unique credit ID)
  await Promise.all([
    issueStripeCredit(
      conversion.referrer.account.subscription?.stripeCustomerId,
      conversion.referrerRewardCents,
      conversion.id,
      'referrer',
      `Referral reward: ${conversion.referee.email} signed up`
    ),
    issueStripeCredit(
      conversion.referee.account.subscription?.stripeCustomerId,
      conversion.refereeRewardCents,
      conversion.id,
      'referee',
      'Welcome credit from referral program'
    ),
  ]);

  await db.referralConversion.update({
    where: { id: conversion.id },
    data: {
      status: 'paid',
      paidAt: new Date(),
      referrerRewardIssued: true,
      refereeRewardIssued: true,
    },
  });
}

async function issueStripeCredit(
  stripeCustomerId: string | null | undefined,
  amountCents: number,
  conversionId: string,
  role: 'referrer' | 'referee',
  description: string
): Promise<void> {
  if (!stripeCustomerId || amountCents <= 0) return;

  // Idempotency key prevents duplicate credits on retry
  await stripe.customers.createBalanceTransaction(stripeCustomerId, {
    amount: -amountCents, // Negative = credit
    currency: 'usd',
    description,
    metadata: { conversionId, role },
  });
}

6. Referral Analytics

-- Referral funnel: clicks โ†’ signups โ†’ paid conversions
SELECT
  DATE_TRUNC('week', rc.clicked_at) AS week,
  COUNT(DISTINCT rc.id) AS total_clicks,
  COUNT(DISTINCT conv.id) AS signups,
  COUNT(DISTINCT conv.id) FILTER (WHERE conv.status IN ('approved', 'paid')) AS conversions,
  COUNT(DISTINCT conv.id) FILTER (WHERE conv.status = 'paid') AS paid_conversions,
  ROUND(
    100.0 * COUNT(DISTINCT conv.id) FILTER (WHERE conv.status = 'paid') /
    NULLIF(COUNT(DISTINCT rc.id), 0), 1
  ) AS click_to_paid_pct,
  SUM(conv.referrer_reward_cents) FILTER (WHERE conv.status = 'paid') / 100.0 AS rewards_paid_usd
FROM referral_clicks rc
LEFT JOIN referral_conversions conv ON rc.code_id = conv.code_id
  AND conv.created_at >= rc.clicked_at
  AND conv.created_at <= rc.clicked_at + INTERVAL '30 days'
WHERE rc.clicked_at >= NOW() - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1 DESC;

-- Top referrers by paid conversions
SELECT
  u.email AS referrer_email,
  COUNT(*) FILTER (WHERE conv.status = 'paid') AS paid_referrals,
  SUM(conv.referrer_reward_cents) FILTER (WHERE conv.status = 'paid') / 100.0 AS total_rewards_usd,
  AVG(conv.fraud_score) AS avg_fraud_score
FROM referral_codes rc
JOIN users u ON rc.user_id = u.id
JOIN referral_conversions conv ON rc.id = conv.code_id
GROUP BY u.id, u.email
HAVING COUNT(*) FILTER (WHERE conv.status = 'paid') > 0
ORDER BY paid_referrals DESC
LIMIT 20;

Cost Reference

Program StructureCAC ImpactReward CostFraud Risk
$10 referee credit onlyLow liftLowLow
$20 referrer + $10 refereeHigh liftMediumMedium
$50 referrer (SaaS, ACV > $500)High liftMediumMedium
% of revenue shareHighest liftVariableHigh

Benchmark: A well-implemented referral program typically produces 15โ€“30% of new signups at 20โ€“40% of paid acquisition CAC.


See Also


Working With Viprasol

Building a referral program that needs to handle attribution correctly, prevent fraud, and reward referrers reliably via Stripe? We design and implement end-to-end referral systems โ€” from link generation and conversion tracking to fraud scoring and automated reward fulfillment โ€” integrated with your existing billing infrastructure.

Talk to our team โ†’ | Explore our SaaS engineering services โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.