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:
- Pre-qualified leads: The referrer only invites people who would genuinely benefit
- Social onboarding: New users have a built-in "buddy" who helps them get started
- 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
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;
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}` }
}
)
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>
)
}
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 }
}
)
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 }
}
)
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
}
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 }
}
)
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
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:
- Reward both sides of the transaction — the referrer and the referee should both feel like they won
- Delay referrer rewards until the referee takes a valuable action (pays, activates) to prevent fraud
- Build credit infrastructure first — a credit engine that handles referral rewards today can also handle support credits, beta tester rewards, and promotional giveaways tomorrow
- Monitor for fraud continuously — what starts as a growth channel can become a cost center without proper controls
- 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.
Top comments (1)
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.