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.
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.