Designing a Referral and Invite System: Double-Sided Rewards, Fraud Prevention, and Attribution Tracking at Scale
A production guide to referral system design: invite code generation, double-sided reward mechanics, cross-device attribution, fraud prevention with device fingerprinting and abuse ring detection, idempotent reward ledgers, and tradeoffs across invite codes, referral links, in-app sharing, and affiliate models.
Your growth team wants a referral program. It sounds straightforward: generate a code, share it, credit the referrer when someone signs up. You ship a prototype in a day. Then you get your first batch of fraudulent self-referrals, discover that attribution breaks across devices, and watch someone cycle through disposable email addresses to farm rewards. What felt like a weekend project becomes a multi-week infrastructure effort.
This article covers the full architecture: code generation, double-sided reward mechanics, cross-session attribution, fraud prevention, idempotent ledger design, and the tradeoffs between different referral models. The goal is to give you a design you can actually put in production.
Invite Code Generation and Management
The simplest approach is a random alphanumeric code stored against a user ID. The devil is in the details.
import { randomBytes } from "crypto";
interface ReferralCode {
code: string;
userId: string;
createdAt: Date;
expiresAt: Date | null;
useCount: number;
maxUses: number | null;
}
function generateReferralCode(length = 8): string {
// Use a URL-safe character set, drop visually ambiguous chars (0, O, I, l)
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = randomBytes(length);
return Array.from(bytes)
.map((b) => alphabet[b % alphabet.length])
.join("");
}
async function createReferralCode(
userId: string,
db: Database
): Promise<ReferralCode> {
for (let attempt = 0; attempt < 5; attempt++) {
const code = generateReferralCode();
try {
return await db.referralCodes.insertOne({
code,
userId,
createdAt: new Date(),
expiresAt: null,
useCount: 0,
maxUses: null,
});
} catch (err) {
if (isUniqueConstraintViolation(err)) continue;
throw err;
}
}
throw new Error("Failed to generate unique referral code after 5 attempts");
}
A few decisions worth making explicitly:
One code per user vs. multiple codes. One code is simpler and makes attribution unambiguous. Multiple codes per user (for different campaigns or channels) let you measure where referrals originate but add surface area for abuse. Start with one.
Code length. An 8-character code from a 32-character alphabet gives roughly 1 trillion combinations. At 10 million users you still have negligible collision probability. Shorter codes are friendlier for manual entry (QR codes are fine at any length).
Expiry and max-use caps. Consumer apps usually issue codes with no expiry. B2B affiliate programs often use time-bounded or single-use codes for tighter control. Encode this in the schema from day one, even if you ship with null values.
Store codes with a unique index. Never derive codes from user IDs or emails; that makes them guessable.
Double-Sided Reward Mechanics
The double-sided model gives both the referrer and the new user something of value. The asymmetry in what each side receives is where you make design decisions.
type RewardType = "credit" | "discount_pct" | "discount_fixed" | "feature_unlock";
interface RewardRule {
id: string;
campaignId: string;
referrerReward: {
type: RewardType;
value: number;
currency?: string;
};
refereeReward: {
type: RewardType;
value: number;
currency?: string;
};
triggerEvent: "signup" | "first_purchase" | "subscription_activated";
rewardCapPerReferrer: number | null; // null = unlimited
rewardWindowDays: number; // days after signup the trigger must occur
}
The triggerEvent field is critical. Triggering on signup is easy to fraud. Triggering on first_purchase or subscription_activated dramatically reduces abuse because it requires real economic activity. The tradeoff is conversion rate: more friction for the referred user means fewer completed referrals.
The rewardWindowDays creates a time boundary. If the referred user has to make a purchase within 30 days to unlock the reward, you avoid crediting referrers for users who signed up months ago and only converted much later under a different campaign.
Timing the credit. You have two options:
- Credit immediately on trigger event, mark as clawback-eligible for N days.
- Hold the reward in a pending state, release after N days if no refund or fraud signal arrives.
Option 2 is cleaner for financial integrity. It avoids the awkward process of debiting accounts after users have already spent credits.
Attribution Tracking Across Sessions and Devices
A user clicks a referral link on their phone, gets distracted, opens a laptop three days later, and completes signup. You need to attribute that conversion to the referrer.
The standard approach is a layered attribution chain:
interface AttributionRecord {
sessionId: string;
referralCode: string | null;
referralLinkId: string | null;
firstSeenAt: Date;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
deviceFingerprint: string;
ipAddress: string;
userAgent: string;
convertedUserId: string | null;
convertedAt: Date | null;
}
On the link click, write an attribution record to your store (Redis works well for short-lived sessions; write to a persistent store for longer windows). Set a cookie with the attribution data. On signup, look up the attribution in this order:
- Referral code explicitly entered by the user.
- Referral code from a cookie set when they clicked a link.
- Referral code from a server-side session lookup by device fingerprint or IP, within a configurable window (24-72 hours is typical).
Fingerprint-based lookup is a fuzzy fallback, not a primary signal. Use it to recover attribution when cookies are cleared, not as your main mechanism.
async function resolveAttribution(
req: SignupRequest,
store: AttributionStore
): Promise<string | null> {
// 1. Explicit code typed in the signup form
if (req.referralCode) {
const valid = await store.validateCode(req.referralCode);
if (valid) return req.referralCode;
}
// 2. Code from cookie set on link click
const cookieCode = req.cookies["ref_code"];
if (cookieCode) {
const cookieAttribution = await store.getByCode(cookieCode);
if (cookieAttribution && isWithinWindow(cookieAttribution.firstSeenAt)) {
return cookieCode;
}
}
// 3. Device fingerprint fallback (last 72 hours)
const fp = computeFingerprint(req);
const fpAttribution = await store.getByFingerprint(fp, hoursAgo(72));
if (fpAttribution) return fpAttribution.referralCode;
return null;
}
For mobile, use deep links (Universal Links on iOS, App Links on Android) with the referral code embedded. The link opens the app directly, and the attribution data travels in the link parameters rather than through a browser cookie.
Fraud Prevention
Referral programs are high-value targets. The common attack vectors are self-referral, coordinated abuse rings, disposable email farming, and device fingerprint spoofing.
Self-Referral Detection
The basic check: verify the referrer’s account details do not match the referee’s. Match on email, phone number, payment method fingerprint (if available), and IP address at signup.
async function detectSelfReferral(
referrerId: string,
newUser: NewUserSignup,
db: Database
): Promise<boolean> {
const referrer = await db.users.findById(referrerId);
// Exact email match (catches simple cases)
if (referrer.email === newUser.email) return true;
// Same-device fingerprint as referrer's signup
const referrerSignup = await db.signupEvents.findByUserId(referrerId);
if (referrerSignup.deviceFingerprint === newUser.deviceFingerprint) return true;
// Same IP at signup (flag for review, not automatic reject — shared NAT is real)
if (referrerSignup.ipAddress === newUser.ipAddress) {
await db.flagForReview(newUser.id, "same_ip_as_referrer");
}
return false;
}
IP matching alone is too aggressive; corporate networks and households share IPs. Flag it for human review rather than auto-rejecting.
Abuse Ring Detection
Abuse rings create clusters of accounts that refer each other. The signal is a dense subgraph in the referral relationship graph. You do not need full graph analysis at small scale; a simpler heuristic works:
async function detectAbuseRing(
referralCode: string,
db: Database
): Promise<RiskScore> {
const referrals = await db.referrals.findByCode(referralCode, {
window: days(30),
});
const recentCount = referrals.length;
const uniqueIps = new Set(referrals.map((r) => r.ipAddress)).size;
const uniqueDevices = new Set(referrals.map((r) => r.deviceFingerprint)).size;
const avgAccountAge = average(referrals.map((r) => r.accountAgeHours));
// High referral volume with low IP/device diversity is a strong signal
const ipDensity = recentCount / Math.max(uniqueIps, 1);
const deviceDensity = recentCount / Math.max(uniqueDevices, 1);
let score = 0;
if (recentCount > 20) score += 30;
if (ipDensity > 3) score += 30;
if (deviceDensity > 2) score += 20;
if (avgAccountAge < 24) score += 20; // accounts created recently
return { score, flags: { recentCount, ipDensity, deviceDensity, avgAccountAge } };
}
A score above a threshold (say, 60) puts the referrer in a hold state: their pending rewards are frozen pending manual review. Do not auto-revoke; legitimate power users can generate real bursts of referrals.
Disposable Email Filtering
Maintain a blocklist of disposable email domains (there are open-source lists with 100K+ entries). Check on signup. Also check for email patterns with random-looking prefixes (regex heuristics catch some generators).
import disposableDomains from "disposable-email-domains";
function isDisposableEmail(email: string): boolean {
const domain = email.split("@")[1]?.toLowerCase();
if (!domain) return true;
return disposableDomains.includes(domain);
}
This is a cat-and-mouse game. Supplement with a DNS-based email verification service that checks whether the MX record points to a real mail server.
Device Fingerprinting
A browser fingerprint combines user agent, screen resolution, timezone, canvas rendering hash, WebGL renderer string, installed fonts (via CSS), and audio context. This is imperfect (fingerprints can collide or be spoofed), but it raises the cost of creating multiple accounts significantly.
Use a library like FingerprintJS for the client-side collection. Store the fingerprint hash at signup and check it against existing accounts before issuing rewards.
Reward Ledger Design with Idempotent Credits
Every credit operation must be idempotent. Webhooks retry, networks fail, and users complain about missing credits in ways that tempt engineers to re-run jobs manually. Without idempotency, you double-credit users.
interface LedgerEntry {
id: string; // UUID
userId: string;
type: "credit" | "debit" | "hold" | "release" | "expire";
amount: number;
currency: string;
status: "pending" | "confirmed" | "cancelled";
idempotencyKey: string; // unique per business event
referralId: string | null;
createdAt: Date;
confirmedAt: Date | null;
metadata: Record<string, unknown>;
}
async function creditReferralReward(
referralId: string,
userId: string,
amount: number,
currency: string,
db: Database
): Promise<LedgerEntry> {
const idempotencyKey = `referral_reward:${referralId}:${userId}`;
// Check if this credit has already been issued
const existing = await db.ledger.findByIdempotencyKey(idempotencyKey);
if (existing) return existing;
return await db.ledger.insertOne({
id: crypto.randomUUID(),
userId,
type: "credit",
amount,
currency,
status: "pending",
idempotencyKey,
referralId,
createdAt: new Date(),
confirmedAt: null,
metadata: {},
});
}
The idempotencyKey encodes the business event, not the retry attempt. The same referral cannot produce two ledger entries for the same user. Run this inside a database transaction that also updates the referral record status to rewarded, so the two writes are atomic.
For the balance view, compute it from ledger entries rather than storing a mutable balance field. This gives you a complete audit trail and makes disputes trivial to investigate.
async function getBalance(userId: string, currency: string, db: Database): Promise<number> {
const entries = await db.ledger.findByUser(userId, { currency, status: "confirmed" });
return entries.reduce((sum, e) => {
return e.type === "credit" || e.type === "release"
? sum + e.amount
: sum - e.amount;
}, 0);
}
Webhook-Based Event Tracking
Referral rewards often depend on events that happen in a different service (payment processor, subscription system). Use internal webhooks (or a message queue) to decouple attribution from reward issuance.
interface ReferralEvent {
eventType: "signup" | "purchase_completed" | "subscription_activated" | "refund_issued";
userId: string;
orderId?: string;
amount?: number;
currency?: string;
occurredAt: Date;
idempotencyKey: string;
}
async function handleReferralEvent(
event: ReferralEvent,
referralService: ReferralService
): Promise<void> {
const referral = await referralService.findPendingByUserId(event.userId);
if (!referral) return;
const rule = await referralService.getActiveRule(referral.campaignId);
if (event.eventType !== rule.triggerEvent) return;
const withinWindow = isWithinDays(referral.createdAt, rule.rewardWindowDays);
if (!withinWindow) {
await referralService.expireReferral(referral.id);
return;
}
await referralService.issueRewards(referral, rule, event.idempotencyKey);
}
For refund_issued, reverse the ledger entries by debiting the credited amount. Mark those entries with a reference to the refund event so the audit trail is complete.
Tradeoffs Comparison
| Model | Attribution Accuracy | Fraud Surface | User Friction | Analytics Depth | Best For |
|---|---|---|---|---|---|
| Invite codes | High (explicit entry) | High (shareable, farmable) | Medium (must type code) | Low (no click tracking) | Early-stage, low volume |
| Referral links | High (cookie + param) | Medium (link spoofing possible) | Low (one click) | High (UTM, click data) | Consumer apps, email campaigns |
| In-app sharing (contact import) | Very high (device-verified) | Low (tied to real contacts) | Low (native share sheet) | Medium (no click path) | Mobile-first, social products |
| Partner/affiliate | Low (coupon overlap common) | High (coupon stacking, self-use) | Very low | Very high (affiliate dashboards) | B2B, large publisher networks |
Referral links with a persistent cookie and a code fallback cover most consumer use cases. In-app sharing (using the iOS/Android share sheet with a deep link) is harder to abuse because the invite goes to a real contact on a verified device.
Affiliate programs need their own fraud stack: blacklisted domains, CPA cap enforcement, conversion delay rules to filter trial abuse.
Production Considerations
Timing windows are a product decision, not an engineering one. A 7-day window for first purchase pushes users to act quickly; a 30-day window improves conversion but increases fraud exposure. Instrument both conversion rate and fraud rate by window length before locking in a value.
Reward caps per referrer. Without a cap, a single power user could theoretically generate unbounded liability. Set a soft cap that triggers a review queue rather than a hard cutoff. Legitimate advocates exist, and you do not want to penalize them.
Analytics. Track referral funnel stages separately: code issued, link clicked, signup started, signup completed, trigger event fired, reward issued. A drop between signup completed and trigger event tells you the conversion window is too tight. A spike between link clicked and signup started suggests your landing page or signup form is losing users.
A/B testing incentive amounts. Treat the reward rule as an experiment parameter. Assign users to reward variants at code generation time and keep the assignment stable (do not let a user’s reward tier change mid-campaign). Use the referral ID as the experiment unit, not the user ID, to avoid contamination when a user refers multiple people.
Ledger currency precision. Store monetary amounts in the smallest currency unit (cents for USD, pence for GBP) as integers. Floating-point arithmetic in financial ledgers is a predictable source of off-by-one errors at scale.
Reward expiry. Credits with no expiry become a balance sheet liability. Most programs set a 12-month rolling expiry on unredeemed credits. Run a nightly job that moves expired pending entries to expired status and sends users a reminder 30 days before.
Monitoring. Alert on: reward issuance rate per hour (sudden spikes indicate an exploit), fraud score distribution (a shift toward high scores means a new attack pattern is emerging), and referral-to-active-user ratio over 7 and 30 days (a healthy referral program should produce retained users, not just signups).
A well-designed referral system is a growth lever with a real cost: every double-sided reward has a margin impact, and every fraud ring that slips through compounds that cost. The engineering work is not glamorous, but idempotent ledgers, careful attribution chains, and layered fraud signals are what separate a referral program that scales from one that gets shut down by finance after the first abuse wave.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.