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.

{“@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.

Scroll to Top