An invitation system lets existing users invite new users via unique links or codes, tracking who invited whom and rewarding successful conversions. The engineering challenges are: generating unguessable unique codes, preventing abuse (one user spamming thousands of invites), enforcing single-use semantics, and attributing conversions accurately even when users sign up days after clicking the invite link. These problems appear in referral programs, team workspace onboarding, and closed-beta access flows.
Core Data Model
CREATE TABLE Invitation (
invite_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(16) NOT NULL UNIQUE, -- short, URL-safe, unguessable
inviter_id BIGINT NOT NULL REFERENCES User(id),
invitee_email VARCHAR(255), -- NULL for generic link invites
invite_type VARCHAR(20) NOT NULL DEFAULT 'email', -- 'email', 'link', 'code'
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, accepted, expired, revoked
max_uses INT NOT NULL DEFAULT 1, -- 1 for personal, N for team links
use_count INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
invitee_id BIGINT REFERENCES User(id), -- set when accepted
reward_issued BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_invite_code ON Invitation(code) WHERE status = 'pending';
CREATE INDEX idx_invite_inviter ON Invitation(inviter_id, created_at DESC);
CREATE INDEX idx_invite_email ON Invitation(invitee_email) WHERE status = 'pending';
-- Rate limiting: track invites sent per user per day
CREATE TABLE InviteRateLimit (
user_id BIGINT NOT NULL,
date DATE NOT NULL,
count INT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, date)
);
Code Generation
import secrets
import base64
def generate_invite_code(length: int = 10) -> str:
"""Generate a URL-safe, unguessable invite code."""
# secrets.token_bytes gives cryptographically secure random bytes
# base64 url-safe encoding, strip padding
raw = secrets.token_bytes(8) # 64 bits of entropy
code = base64.urlsafe_b64encode(raw).decode().rstrip('=')
return code[:length] # e.g., "aB3kR9xZmQ"
def create_invitation(inviter_id: int, invitee_email: str = None,
max_uses: int = 1, expires_hours: int = 72) -> dict:
# Rate limit: max 50 invites per user per day
today = date.today()
result = db.execute("""
INSERT INTO InviteRateLimit (user_id, date, count)
VALUES (%s, %s, 1)
ON CONFLICT (user_id, date) DO UPDATE
SET count = InviteRateLimit.count + 1
RETURNING count
""", [inviter_id, today])
if result['count'] > 50:
raise RateLimitError("Invite limit reached for today")
# Generate unique code (retry on collision, extremely rare)
for _ in range(5):
code = generate_invite_code()
try:
invite = db.fetchone("""
INSERT INTO Invitation (code, inviter_id, invitee_email, max_uses, expires_at)
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '%s hours')
RETURNING *
""", [code, inviter_id, invitee_email, max_uses, expires_hours])
return invite
except UniqueViolationError:
continue # code collision — retry
raise RuntimeError("Failed to generate unique invite code")
Invite Acceptance and Atomic Use Counting
def accept_invitation(code: str, new_user_id: int) -> dict:
with db.transaction():
# Lock the row to prevent concurrent acceptance
invite = db.fetchone("""
SELECT * FROM Invitation
WHERE code = %s AND status = 'pending'
AND (expires_at IS NULL OR expires_at > NOW())
FOR UPDATE
""", [code])
if not invite:
raise InvalidInviteError("Invite not found, expired, or already used")
if invite['use_count'] >= invite['max_uses']:
raise InviteExhaustedError("This invite link has reached its use limit")
# Check invitee hasn't already accepted an invite
existing = db.fetchone(
"SELECT 1 FROM Invitation WHERE invitee_id = %s", [new_user_id]
)
if existing:
raise DuplicateAcceptError("User already accepted an invitation")
# Atomically increment use_count
db.execute("""
UPDATE Invitation
SET use_count = use_count + 1,
invitee_id = CASE WHEN max_uses = 1 THEN %s ELSE invitee_id END,
status = CASE WHEN use_count + 1 >= max_uses THEN 'accepted' ELSE status END,
accepted_at = CASE WHEN use_count + 1 >= max_uses THEN NOW() ELSE accepted_at END
WHERE invite_id = %s
""", [new_user_id, invite['invite_id']])
# Issue reward to inviter (async, non-blocking)
if not invite['reward_issued']:
reward_queue.enqueue('issue_invite_reward', invite_id=str(invite['invite_id']))
Attribution and Reward Flow
def issue_invite_reward(invite_id: str):
"""Called by background worker. Idempotent."""
invite = db.fetchone("SELECT * FROM Invitation WHERE invite_id = %s", [invite_id])
if not invite or invite['reward_issued']:
return # already issued or invalid
# Verify the invitee completed the required action (e.g., first purchase)
invitee_qualified = check_invitee_qualification(invite['invitee_id'])
if not invitee_qualified:
return # invitee hasn't earned the reward yet -- retry later
with db.transaction():
# Idempotency guard
affected = db.execute("""
UPDATE Invitation SET reward_issued = TRUE
WHERE invite_id = %s AND reward_issued = FALSE
""", [invite_id])
if affected == 0:
return # already issued in a concurrent call
# Credit the inviter
db.execute("""
INSERT INTO Reward (user_id, amount, reason, reference_id)
VALUES (%s, 10.00, 'invite_reward', %s)
""", [invite['inviter_id'], invite_id])
db.execute("""
UPDATE UserAccount SET credit_balance = credit_balance + 10.00
WHERE user_id = %s
""", [invite['inviter_id']])
Key Interview Points
- Use
secrets.token_bytes(cryptographically secure), notrandom, for invite code generation. Predictable codes allow attackers to enumerate valid invites. - FOR UPDATE on acceptance prevents two concurrent requests from both accepting a single-use invite — the second request sees the already-incremented use_count and fails.
- Reward issuance must be idempotent — the UPDATE WHERE reward_issued=FALSE guard prevents double-credit even if the reward job runs twice.
- Rate limit invites per user: without limits, a single account can generate millions of codes for spam or SEO manipulation. Use the InviteRateLimit table with daily counts.
- Link invites (max_uses > 1) need a different model than personal invites (max_uses=1): the invitee_id column should be NULL for link invites until use_count reaches max_uses.
- Track invite attribution even for delayed signups: store the invite code in a cookie when the link is clicked; retrieve it during registration even if the user signs up days later.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you generate an unguessable invite code and why does security matter?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a cryptographically secure random number generator (Python’s secrets module, Java’s SecureRandom, Node’s crypto.randomBytes). A 10-character URL-safe base64 code from 8 random bytes has 64 bits of entropy — 2^64 possible values. An attacker trying to guess valid codes at 1,000 requests/second would need trillions of years to find one. By contrast, a 6-digit numeric code (1 million possibilities) can be brute-forced in minutes. Security matters because invite codes grant access without requiring authentication — a guessable code means anyone can register without being invited, defeating the purpose of access control or reward attribution.”}},{“@type”:”Question”,”name”:”How do you prevent a single user from sending thousands of spam invites?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Maintain a per-user, per-day invite counter in a table (user_id, date, count). Use an atomic upsert: INSERT INTO InviteRateLimit (user_id, date, count) VALUES (%s, TODAY, 1) ON CONFLICT DO UPDATE SET count = count + 1 RETURNING count. If the returned count exceeds the daily limit (e.g., 50), reject the request with HTTP 429. This approach requires no additional Redis infrastructure and is transactionally consistent. Additionally: require email verification before a user can send invites (prevents throwaway accounts from bulk-sending), and rate-limit by IP as a secondary signal to catch coordinated abuse across multiple accounts.”}},{“@type”:”Question”,”name”:”How do you handle invite attribution when a user signs up days after clicking the link?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When the invite link is clicked, set a cookie in the browser: Set-Cookie: invite_code=aB3kR9xZmQ; Max-Age=604800; SameSite=Lax (7 days). During registration, the server reads the invite_code cookie and associates it with the new account in the POST /register handler. This bridges the gap between click and signup — common in SaaS products where users click on Friday and sign up Monday. Edge cases: (1) Multiple invite links clicked — last-click attribution (last cookie set wins). (2) Cookie cleared or different browser — no attribution; acceptable fallback is no reward. (3) Incognito mode — cookies persist within the session but not across sessions. Document attribution logic clearly in analytics.”}},{“@type”:”Question”,”name”:”How do you prevent the same user from accepting multiple invites?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Add a uniqueness check before accepting: SELECT 1 FROM Invitation WHERE invitee_id=%s. If a row exists, the user has already accepted an invite — reject with an error. Additionally, index invitee_id and add a partial unique index if each user should only ever accept one invite in their lifetime: CREATE UNIQUE INDEX idx_one_accept_per_user ON Invitation(invitee_id) WHERE invitee_id IS NOT NULL. This database constraint enforces the rule even if application code has a bug. For multi-use link invites (team invite links), the per-user constraint still applies — each user can only accept once, but multiple users can accept the same link.”}},{“@type”:”Question”,”name”:”How do you issue rewards reliably without double-crediting?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The reward issuance must be idempotent: UPDATE Invitation SET reward_issued=TRUE WHERE invite_id=%s AND reward_issued=FALSE. This returns 0 affected rows if the reward was already issued, making it safe to retry. The reward credit (updating user balance) must be in the same database transaction as setting reward_issued=TRUE — if the transaction commits, both happen together; if it rolls back, neither happens. Never issue the reward synchronously in the accept flow — if the reward service is slow, it delays registration. Enqueue a background job that: (1) checks invitee qualification (first purchase, email verified), (2) executes the atomic idempotent update, (3) retries if the qualification check fails (poll until the invitee meets the criteria).”}}]}
Invitation system and referral program design is discussed in Airbnb system design interview questions.
Invitation system and referral attribution design is covered in Uber system design interview preparation.
Invitation system and referral reward design is discussed in Coinbase system design interview guide.