Referral System
Account-centric referral program: each account owns one active short code, and both sides earn credits when the configured trigger fires. Refunds and lost chargebacks automatically claw the credits back. The system is feature-gated via the REFERRAL_ENABLED environment variable (server-only, no NEXT_PUBLIC_ prefix) and surfaces as 404 when disabled — invisible until you turn it on.
Feature Gate
Set REFERRAL_ENABLED=true in your environment to enable the full user dashboard, signup banner, tracking redirect, and admin console. The flag is read once inside config/referral.ts and exposed as referralConfig.enabled / isReferralEnabled(). Components and routes must import the helper — never read process.env directly.
Configuration (config/referral.ts)
| Field | Purpose |
|---|---|
enabled | Env-driven (REFERRAL_ENABLED=true) |
trigger | on_signup | on_first_purchase | on_first_subscription |
rewards.referrer.credits | Credits granted to the inviter when the trigger fires |
rewards.referred.credits | Credits granted to the invitee when the trigger fires |
code.length / code.alphabet | Code format (unambiguous-character alphabet, no 0/O, 1/I) |
code.pattern | Regex used to validate cookies and query params |
limits.maxApplicationsPerIp | Rolling 24-hour rate limit per hashed IP |
cookie.name / cookie.maxAgeDays | httpOnly attribution cookie (30 days default) |
Trigger Semantics
| Trigger | Fires On | Best For |
|---|---|---|
on_signup | Immediately after attribution | B2C growth, freemium |
on_first_purchase | Any paid event — credit pack, license, or first paid invoice | Hybrid billing |
on_first_subscription | First paid subscription invoice only | Subscription-only SaaS |
Attribution Flow
1. Visitor hits /?ref=ABCD1234 or /[locale]/refer/ABCD1234
│
▼
2. Middleware / route — validates regex, sets httpOnly bsk_ref cookie
(Secure, SameSite=Lax, 30-day window) — no DB call in hot path
│
▼
3. User signs up via magic link → onboarding completes
│
▼
4. Onboarding form fires POST /api/referral/attribute (fire-and-forget).
Server reads bsk_ref cookie, resolves user's personal account,
calls apply_referral_code RPC, clears cookie.
│
▼
5. referrals row inserted (status='pending' by default, or 'qualified'
immediately if trigger = on_signup).
│
▼
6. When configured trigger fires (Stripe webhook), qualify_referral RPC
calls add_credits for both accounts. Refunds / lost disputes call
reverse_referral, which uses decrement_credits per side.Database Schema
Two tables, both row-level-security protected with SELECT scoped by membership on the referrer account. Writes go exclusively through SECURITY DEFINER RPCs (no INSERT/UPDATE/DELETE policies exposed to clients).
| Object | Purpose |
|---|---|
referral_codes | One active code per account (partial unique indexes on code WHERE active and account_id WHERE active) |
referrals | The attribution graph: CHECK (referrer_account_id ≠ referred_account_id) + UNIQUE(referred_account_id) — one referral per account, lifetime |
referral_status enum | pending, qualified, rewarded, reversed, rejected |
ip_hash / user_agent_hash | Salted SHA-256 (REFERRAL_SALT), never raw PII — used for fraud heuristics |
credit_source enum | Extended with 'referral' value |
SECURITY DEFINER RPCs
Every RPC runs REVOKE EXECUTE FROM authenticated, anon; GRANT TO service_role; with an auth.role() defense-in-depth check inside the body. Credit movements go through add_credits / decrement_credits only — the referral RPCs never touch credits_balance directly.
| Function | Purpose |
|---|---|
generate_referral_code | Idempotent — returns active code or generates a new one with collision-retry |
apply_referral_code | Attribution + all fraud guards (self-referral, same-owner, one-per-lifetime, IP rate-limit) |
qualify_referral | Calls add_credits both sides with source='referral' + metadata { referral_id, role, trigger } |
reverse_referral | Per-side guarded decrement_credits — an insufficient balance on one side doesn't abort the other (details written to metadata) |
referral_admin_stats | Single-query aggregate for the admin overview (no N+1) |
Stripe Webhook Integration
Billing hooks call maybeQualifyOnPurchase(accountId, eventType) from core/billing/mutations.ts after every credit-granting hook. Refunds reverse referrals from the refund path, and lost-dispute handling in core/billing/risk-events.ts calls reverseReferralForAccount(accountId, reason). All referral hooks are try/catch-wrapped so transient referral failures never break billing.
| Webhook Event | Referral Hook |
|---|---|
checkout.session.completed (credit pack) | maybeQualifyOnPurchase(..., 'credit_pack') |
checkout.session.completed (license) | maybeQualifyOnPurchase(..., 'license') |
invoice.paid (first paid invoice) | maybeQualifyOnPurchase(..., 'invoice') |
charge.refunded | reverseReferralForAccount(...) |
charge.dispute.closed (status = lost) | reverseReferralForAccount(...) |
User Dashboard
The user dashboard at /private-dashboard/referrals is a Server Component that fetches the code, stats, and referrals list in parallel via Promise.all. Only the share card is 'use client' (clipboard + Web Share API). A signup banner reads the bsk_ref cookie server-side and tells the invitee how many credits they'll receive.
Admin Console
The admin panel lives under /admin-dashboard/referrals with four pages: overview (KPI grid + top-10 referrers from the aggregate RPC, cached 60s), filterable list with cursor pagination, detail page with the full timeline plus linked credit_transactions and admin_logs audit trail, and a codes management table with a deactivate action. Every admin mutation (reverse / reject / deactivate) writes an admin_logs row with { actor_user_id, action, target_id, details: { reason, before } }.
API Endpoints
| Endpoint | Security | Purpose |
|---|---|---|
GET /api/referral/code | authenticated | Lazy-create active code |
GET /api/referral/stats | authenticated | Dashboard counters (cached 5 min) |
GET /api/referral/list | authenticated | Cursor-paginated referrals |
POST /api/referral/apply | authenticated + CSRF + strict rate limit | Manual "I have a code" flow |
POST /api/referral/attribute | authenticated + CSRF + strict rate limit | Cookie-based, called from onboarding |
GET /[locale]/refer/[code] | public + relaxed rate limit | Tracking redirect — sets cookie, 302 to home |
GET /api/admin/referrals/stats | admin | Aggregate KPIs + top referrers |
GET /api/admin/referrals | admin | Filterable, cursor-paginated list |
GET /api/admin/referrals/[id] | admin | Detail + audit trail |
POST /api/admin/referrals/[id]/reverse | admin + CSRF | Clawback with reason |
POST /api/admin/referrals/[id]/reject | admin + CSRF | Pending-only transition |
GET /api/admin/referral-codes | admin | Codes with usage counts |
POST /api/admin/referral-codes/[id]/deactivate | admin + CSRF | Deactivate code |
Environment Variables
| Variable | Purpose |
|---|---|
REFERRAL_ENABLED | Server-only feature gate (no NEXT_PUBLIC_ prefix). Set to 'true' to enable. Default false. Read only via isReferralEnabled() / referralConfig.enabled in config/referral.ts — never process.env.REFERRAL_ENABLED elsewhere. |
REFERRAL_SALT | Server-only random salt used to hash visitor IP + User-Agent before storage. Minimum 16 characters; 64-hex is recommended. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Read only via getReferralSalt() exported from config/referral.ts — never raw process.env.REFERRAL_SALT outside that file. See .env.example for the canonical declaration. |
Security Invariants
- Self-referral blocked at the DB level (
CHECKconstraint) and at the RPC level (same-owner check onaccounts.owner_user_id) - One referral per account for life (
UNIQUE(referred_account_id)) - One active code per account (partial unique index on
account_id WHERE active) - IP + User-Agent stored as SHA-256 hashes salted with
REFERRAL_SALT— never raw PII - Credits move only through
add_credits/decrement_creditsRPCs; every mutation lands acredit_transactionsrow with metadata{ referral_id, role }for audit - Refunds and lost disputes automatically claw back granted credits
- Every admin mutation writes an
admin_logsentry - Feature gate returns 404 (not 403) so the surface is invisible when disabled
- CSRF enforced on every state-changing endpoint via
apiSecurity.*(Double Submit Cookie)