Invitation System Low-Level Design: Referral Codes, Attribution, and Rewards

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), not random, 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.

Scroll to Top