DEV Community

sweet
sweet

Posted on

How to Design an Effective Referral Reward System: A Complete Technical Guide for SaaS

A well-designed referral program can be the highest-ROI customer acquisition channel for SaaS products, with referred customers typically having 25-30% higher retention and 16% higher lifetime value. This guide covers the complete implementation — from reward structure psychology and invite code generation to credit engine architecture, fraud prevention, and analytics. Includes production-ready database schemas and server-side code for TanStack Start with Cloudflare D1. See the referral system live at tanstackship.com.


Why Referral Programs Work (The Data)

Referral marketing is not just a growth tactic — it is a trust mechanic. People trust recommendations from peers more than any other form of marketing:

Channel Trust Rate Conversion Rate Avg. LTV Source
Friend referral 92% 10-30% +16% higher Nielsen
Online review 70% 3-10% Baseline BrightLocal
Paid ad 25% 0.5-2% -20% lower Meta Ads

For SaaS specifically, referred customers have 25-30% lower churn and a 16% higher lifetime value compared to organically acquired customers (Deloitte). This is because:

  1. Pre-qualified leads: The referrer only invites people who would genuinely benefit
  2. Social onboarding: New users have a built-in "buddy" who helps them get started
  3. Network effects: As more people in a team/organization join, stickiness increases

Reward Structure: What Works and What Does Not

The Two-Sided Reward Model

The most effective SaaS referral programs reward both the referrer and the referred user:

Model Referrer Gets Referee Gets Example Companies
Two-sided discount 1 month free 20% off first 3 months Dropbox, Airbnb
Two-sided credit $50 account credit $25 account credit Uber, Robinhood
One-sided reward 1 month free Nothing (Less effective)
Donation-based $5 to charity $5 to charity TOMS, Warby Parker (B2C)

Determining the Reward Value

The golden rule: Your reward should offset 25-50% of one month's subscription value.

For a $29/month SaaS:
  - Referrer reward: $7.25 - $14.50 in credit (1-2 weeks free)
  - Referee reward: $7.25 - $14.50 in credit

For a $99/month SaaS:
  - Referrer reward: $25 - $50 in credit
  - Referee reward: $25 - $50 in credit
Enter fullscreen mode Exit fullscreen mode

Credit vs. Discount vs. Cash

Reward Type Pros Cons Best For
Service credit Low cost (zero marginal cost), encourages continued use User may not need more credit SaaS with usage-based billing
Subscription discount Directly reduces churn barrier Complex to implement with billing intervals Monthly subscription SaaS
Cash/ PayPal Highest motivation Expensive, feels transactional Enterprise SaaS
Gift cards Simple to administer Lower perceived value than cash B2C/B2B hybrid
Feature unlocks Zero cost, high perceived value Only works with freemium model Freemium products

Database Schema for Referral System

-- Table 1: Invite codes (generated by referrers)
CREATE TABLE invite_codes (
  id TEXT PRIMARY KEY,
  code TEXT NOT NULL UNIQUE, -- e.g., 'FRIEND-ABC123'
  creator_id TEXT NOT NULL, -- The user who created this code
  max_uses INTEGER DEFAULT 10,
  use_count INTEGER NOT NULL DEFAULT 0,
  reward_type TEXT NOT NULL DEFAULT 'credit' CHECK (
    reward_type IN ('credit', 'discount_percent', 'discount_fixed', 'month_free')
  ),
  reward_value INTEGER NOT NULL DEFAULT 500, -- In cents or percentage points
  is_active INTEGER NOT NULL DEFAULT 1,
  expires_at INTEGER, -- Optional expiration
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  FOREIGN KEY (creator_id) REFERENCES users(id)
);

CREATE INDEX idx_invite_codes_code ON invite_codes(code);
CREATE INDEX idx_invite_codes_creator ON invite_codes(creator_id);

-- Table 2: Redemption records
CREATE TABLE invite_redemptions (
  id TEXT PRIMARY KEY,
  invite_code_id TEXT NOT NULL,
  referrer_id TEXT NOT NULL, -- The person who shared the code
  referred_user_id TEXT NOT NULL, -- The new user who used the code
  reward_referrer INTEGER NOT NULL DEFAULT 0, -- Cents awarded to referrer
  reward_referred INTEGER NOT NULL DEFAULT 0, -- Cents awarded to referee
  status TEXT NOT NULL DEFAULT 'pending' CHECK (
    status IN ('pending', 'completed', 'expired', 'fraudulent')
  ),
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  completed_at INTEGER, -- When conditions were met (e.g., referee pays)
  FOREIGN KEY (invite_code_id) REFERENCES invite_codes(id),
  FOREIGN KEY (referrer_id) REFERENCES users(id),
  FOREIGN KEY (referred_user_id) REFERENCES users(id)
);

CREATE INDEX idx_invite_redemptions_referrer ON invite_redemptions(referrer_id);
CREATE INDEX idx_invite_redemptions_referred ON invite_redemptions(referred_user_id);

-- Table 3: Credit ledger (for credit-based rewards)
CREATE TABLE credit_ledger (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  amount_cents INTEGER NOT NULL, -- Positive for credit, negative for spend
  balance_after_cents INTEGER NOT NULL,
  reason TEXT NOT NULL CHECK (
    reason IN (
      'referral_reward', 'referral_signup_bonus',
      'credit_purchase', 'subscription_payment',
      'admin_adjustment', 'expired'
    )
  ),
  reference_id TEXT, -- Links to invite_redemptions or invoice
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_credit_ledger_user ON credit_ledger(user_id);

-- View: Referral statistics per user
CREATE VIEW referral_stats AS
SELECT
  ic.creator_id as user_id,
  COUNT(DISTINCT ir.id) as total_referrals,
  COUNT(DISTINCT CASE WHEN ir.status = 'completed' THEN ir.id END) as completed_referrals,
  COALESCE(SUM(CASE WHEN ir.status = 'completed' THEN ir.reward_referrer END), 0) as total_rewards_earned,
  ROUND(AVG(CASE WHEN ir.status = 'completed' THEN ir.reward_referrer END), 0) as avg_reward_per_referral
FROM invite_codes ic
LEFT JOIN invite_redemptions ir ON ir.invite_code_id = ic.id
GROUP BY ic.creator_id;
Enter fullscreen mode Exit fullscreen mode

Invite Code Generation

// src/lib/invite/codes.ts
import { createServerFn } from "@tanstack/react-start"

// Secure, human-friendly invite code generation
export function generateInviteCode(length: number = 8): string {
  // Use a character set that avoids ambiguous characters
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // No I, O, 0, 1
  const prefix = "FRIEND-"
  const random = Array.from({ length }, () =>
    chars[Math.floor(Math.random() * chars.length)]
  ).join("")
  return `${prefix}${random}`
}

export const createInviteCode = createServerFn({ method: "POST" }).handler(
  async (_, { request }) => {
    const userId = await getUserId(request)
    const code = generateInviteCode()

    // Ensure uniqueness (collision probability is negligible but check anyway)
    const existing = await env.DB.prepare(
      "SELECT id FROM invite_codes WHERE code = ?"
    ).bind(code).first()

    if (existing) {
      return createInviteCode(_, { request }) // Retry
    }

    // Default reward: 500 cents ($5.00) account credit for both parties
    await env.DB.prepare(
      `INSERT INTO invite_codes (id, code, creator_id, reward_value)
       VALUES (?, ?, ?, 500)`
    ).bind(crypto.randomUUID(), code, userId).run()

    return { code, shareUrl: `https://tanstackship.com/invite/${code}` }
  }
)
Enter fullscreen mode Exit fullscreen mode

Referral Flow Implementation

Step 1: Share the Referral Link

// src/components/invite/ShareInvite.tsx
import { useMutation } from "@tanstack/react-query"
import { createInviteCode } from "../../lib/invite/codes"

export function ShareInvite() {
  const mutation = useMutation({
    mutationFn: () => createInviteCode(),
  })

  const shareUrl = mutation.data?.shareUrl ?? ""

  return (
    <div className="p-6 border rounded-lg">
      <h2 className="text-xl font-bold mb-4">Refer a Friend, Earn Credit</h2>
      <p className="text-gray-600 mb-4">
        Share your invite link and earn $5 for every friend who signs up
      </p>

      <button
        onClick={() => mutation.mutate()}
        className="bg-blue-600 text-white px-6 py-2 rounded-lg"
        disabled={mutation.isPending}
      >
        {mutation.isPending ? "Generating..." : "Get Your Referral Link"}
      </button>

      {shareUrl && (
        <div className="mt-4">
          <label className="block text-sm font-medium mb-1">
            Your referral link
          </label>
          <div className="flex gap-2">
            <input
              type="text"
              value={shareUrl}
              readOnly
              className="flex-1 px-3 py-2 border rounded"
            />
            <button
              onClick={() => navigator.clipboard.writeText(shareUrl)}
              className="px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
            >
              Copy
            </button>
          </div>
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Redeem the Invite Code on Signup

// src/lib/invite/redeem.ts
import { createServerFn } from "@tanstack/react-start"

export const redeemInviteCode = createServerFn({ method: "POST" }).handler(
  async ({ code, newUserId }: { code: string; newUserId: string }) => {
    // Validate the invite code
    const invite = await env.DB.prepare(
      `SELECT ic.*, u.email as creator_email
       FROM invite_codes ic
       JOIN users u ON u.id = ic.creator_id
       WHERE ic.code = ? AND ic.is_active = 1
       AND (ic.expires_at IS NULL OR ic.expires_at > unixepoch())
       AND ic.use_count < ic.max_uses`
    ).bind(code).first()

    if (!invite) {
      return { success: false, error: "Invalid or expired invite code" }
    }

    // Prevent self-referral
    if (invite.creator_id === newUserId) {
      return { success: false, error: "You cannot use your own invite code" }
    }

    const now = Math.floor(Date.now() / 1000)
    const redemptionId = crypto.randomUUID()

    // Create redemption record (pending until condition is met)
    await env.DB.prepare(
      `INSERT INTO invite_redemptions
        (id, invite_code_id, referrer_id, referred_user_id,
         reward_referrer, reward_referred, status, created_at)
       VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`
    ).bind(
      redemptionId,
      invite.id,
      invite.creator_id,
      newUserId,
      invite.reward_value, // Referrer gets the reward
      invite.reward_value, // Referee gets the reward
      now
    ).run()

    // Increment code usage
    await env.DB.prepare(
      `UPDATE invite_codes SET use_count = use_count + 1 WHERE id = ?`
    ).bind(invite.id).run()

    // Award credit to the referred user immediately (signup bonus)
    await awardCredit(newUserId, invite.reward_value, "referral_signup_bonus", redemptionId)

    return { success: true, bonusAmount: invite.reward_value }
  }
)
Enter fullscreen mode Exit fullscreen mode

Step 3: Release Referrer Reward When Condition Is Met

// src/lib/invite/rewards.ts
// Triggered when the referred user completes their first payment
export const processReferralReward = createServerFn({ method: "POST" }).handler(
  async ({ referredUserId }: { referredUserId: string }) => {
    const pendingRedemption = await env.DB.prepare(
      `SELECT ir.*, ic.reward_type
       FROM invite_redemptions ir
       JOIN invite_codes ic ON ic.id = ir.invite_code_id
       WHERE ir.referred_user_id = ? AND ir.status = 'pending'`
    ).bind(referredUserId).first()

    if (!pendingRedemption) return { processed: false }

    // Award credit to the referrer
    await awardCredit(
      pendingRedemption.referrer_id,
      pendingRedemption.reward_referrer,
      "referral_reward",
      pendingRedemption.id
    )

    // Mark as completed
    await env.DB.prepare(
      `UPDATE invite_redemptions SET status = 'completed', completed_at = unixepoch()
       WHERE id = ?`
    ).bind(pendingRedemption.id).run()

    return { processed: true, amount: pendingRedemption.reward_referrer }
  }
)
Enter fullscreen mode Exit fullscreen mode

Credit Engine Architecture

A robust credit system that handles referral rewards, subscription payments, and manual adjustments:

// src/lib/credit/engine.ts

export async function awardCredit(
  userId: string,
  amountCents: number,
  reason: CreditLedger["reason"],
  referenceId: string
) {
  const currentBalance = await getCreditBalance(userId)
  const newBalance = currentBalance + amountCents

  await env.DB.prepare(
    `INSERT INTO credit_ledger (id, user_id, amount_cents, balance_after_cents, reason, reference_id)
     VALUES (?, ?, ?, ?, ?, ?)`
  ).bind(
    crypto.randomUUID(),
    userId,
    amountCents,
    newBalance,
    reason,
    referenceId
  ).run()

  return newBalance
}

export async function spendCredit(
  userId: string,
  amountCents: number,
  referenceId: string
): Promise<boolean> {
  const balance = await getCreditBalance(userId)
  if (balance < amountCents) return false // Insufficient credit

  const newBalance = balance - amountCents

  await env.DB.prepare(
    `INSERT INTO credit_ledger (id, user_id, amount_cents, balance_after_cents, reason, reference_id)
     VALUES (?, ?, ?, ?, 'subscription_payment', ?)`
  ).bind(
    crypto.randomUUID(),
    userId,
    -amountCents,
    newBalance,
    referenceId
  ).run()

  return true
}

export async function getCreditBalance(userId: string): Promise<number> {
  const result = await env.DB.prepare(
    `SELECT balance_after_cents
     FROM credit_ledger
     WHERE user_id = ?
     ORDER BY created_at DESC
     LIMIT 1`
  ).bind(userId).first()

  return (result?.balance_after_cents as number) ?? 0
}
Enter fullscreen mode Exit fullscreen mode

Fraud Prevention

Referral fraud is a real problem. Here is a multi-layered defense:

// src/lib/invite/fraud-detection.ts
import { createServerFn } from "@tanstack/react-start"

export const validateReferral = createServerFn({ method: "POST" }).handler(
  async ({ referrerId, refereeId, ipAddress }: {
    referrerId: string
    refereeId: string
    ipAddress: string
  }) => {
    const checks = await Promise.all([
      // Check 1: Same IP detection
      env.DB.prepare(
        `SELECT COUNT(*) as count FROM users
         WHERE ip_address = ? AND id != ?`
      ).bind(ipAddress, refereeId).first(),

      // Check 2: Referral velocity (how many in last 24h)
      env.DB.prepare(
        `SELECT COUNT(*) as count FROM invite_redemptions
         WHERE referrer_id = ? AND created_at > unixepoch() - 86400`
      ).bind(referrerId).first(),

      // Check 3: Same device fingerprint (if available)
      // Implementation depends on your fingerprinting approach
    ])

    const [ipCheck, velocityCheck] = (checks as [{ count: number }, { count: number }])

    if (ipCheck.count > 3) {
      // Flag for review — same IP as too many different users
      await flagForReview(referrerId, "referral_fraud_same_ip")
      return { valid: false, reason: "suspicious_activity" }
    }

    if (velocityCheck.count > 10) {
      // More than 10 referrals in 24 hours is suspicious for most SaaS
      await flagForReview(referrerId, "referral_fraud_high_velocity")
      return { valid: false, reason: "rate_limited" }
    }

    return { valid: true }
  }
)
Enter fullscreen mode Exit fullscreen mode

Referral Program Analytics

-- Dashboard queries for monitoring referral program health

-- 1. Referral funnel
SELECT
  COUNT(DISTINCT ic.creator_id) as users_with_codes,
  COUNT(DISTINCT ir.referred_user_id) as users_who_clicked,
  COUNT(DISTINCT CASE WHEN ir.status = 'completed' THEN ir.referred_user_id END) as converted_referrals,
  COUNT(DISTINCT CASE WHEN s.status = 'active' THEN ir.referred_user_id END) as retained_referrals
FROM invite_codes ic
LEFT JOIN invite_redemptions ir ON ir.invite_code_id = ic.id
LEFT JOIN subscriptions s ON s.user_id = ir.referred_user_id AND s.status = 'active'

-- 2. Top referrers
SELECT
  u.email,
  COUNT(ir.id) as referrals_sent,
  COUNT(CASE WHEN ir.status = 'completed' THEN 1 END) as referrals_completed,
  SUM(CASE WHEN ir.status = 'completed' THEN ir.reward_referrer END) / 100.0 as rewards_earned
FROM users u
JOIN invite_codes ic ON ic.creator_id = u.id
LEFT JOIN invite_redemptions ir ON ir.invite_code_id = ic.id
GROUP BY u.id
ORDER BY referrals_completed DESC
LIMIT 25

-- 3. Referral vs. organic retention comparison
SELECT
  CASE WHEN ir.id IS NOT NULL THEN 'referred' ELSE 'organic' END as acquisition_channel,
  COUNT(DISTINCT u.id) as total_users,
  COUNT(DISTINCT CASE WHEN s.status = 'active' THEN u.id END) as active_users,
  ROUND(AVG(s.mrr), 2) as avg_mrr,
  ROUND(AVG(s.created_at - u.created_at) / 86400, 0) as avg_days_to_churn_or_now
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id
LEFT JOIN invite_redemptions ir ON ir.referred_user_id = u.id
GROUP BY acquisition_channel
Enter fullscreen mode Exit fullscreen mode

Referral Program Optimization Checklist

  • [ ] Reward structure is two-sided (both referrer and referee benefit)
  • [ ] Reward value is 25-50% of one month's subscription value
  • [ ] Invite codes are easy to share (URL, copy button, email, social)
  • [ ] Fraud prevention checks are in place (same IP, velocity, self-referral)
  • [ ] Credit system handles concurrent spends correctly
  • [ ] Referral rewards are released only after condition is met (not immediately)
  • [ ] Referral analytics dashboard shows funnel and ROI
  • [ ] Email notifications sent to referrer when friend signs up
  • [ ] Email notifications sent to referrer when reward is credited
  • [ ] A/B testing framework is in place for reward amounts
  • [ ] Terms of Service cover referral fraud and reward revocation
  • [ ] Self-referral detection prevents users from gaming the system

Conclusion

A referral program is not a "set and forget" growth channel — it requires careful design, implementation, and ongoing optimization. The key principles are:

  1. Reward both sides of the transaction — the referrer and the referee should both feel like they won
  2. Delay referrer rewards until the referee takes a valuable action (pays, activates) to prevent fraud
  3. Build credit infrastructure first — a credit engine that handles referral rewards today can also handle support credits, beta tester rewards, and promotional giveaways tomorrow
  4. Monitor for fraud continuously — what starts as a growth channel can become a cost center without proper controls
  5. Measure referral LTV vs. organic LTV — if referred customers are not more valuable, your reward structure or targeting needs adjustment

When done right, referrals become your highest-quality, lowest-cost acquisition channel — and turn your customers into your most effective sales team.

Related Resources

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

Nice article. I’d also recommend implementing idempotency keys around reward processing. In distributed systems, duplicate webhook deliveries and retry storms are far more common than most developers expect, and referral rewards are essentially financial transactions.