Invite System Low-Level Design: Token Generation, Link Invites, Redemption, and Race Condition Prevention

Invite System Low-Level Design

An invite system lets existing users bring new members into a product — a workspace, a project, an event. It must handle invite creation with expiry, email delivery, token-based redemption, and the special case of inviting someone who doesn’t have an account yet (they sign up and are automatically onboarded into the invited context). This design covers the full invite lifecycle from creation through redemption and revocation.

Core Data Model

CREATE TABLE Invite (
    invite_id      BIGSERIAL PRIMARY KEY,
    token          VARCHAR(64) UNIQUE NOT NULL,   -- random, URL-safe, 32 bytes hex
    context_type   VARCHAR(50) NOT NULL,          -- 'workspace', 'project', 'event'
    context_id     BIGINT NOT NULL,
    invited_by     BIGINT NOT NULL,               -- user_id of inviter
    invitee_email  VARCHAR(255),                  -- NULL for link-based invites
    invitee_user_id BIGINT,                       -- set when a registered user is invited
    role           VARCHAR(50) NOT NULL DEFAULT 'member',
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',
        -- pending, accepted, declined, expired, revoked
    max_uses       SMALLINT NOT NULL DEFAULT 1,   -- 1 = single-use; 0 = unlimited (link invite)
    use_count      SMALLINT NOT NULL DEFAULT 0,
    expires_at     TIMESTAMPTZ NOT NULL,
    accepted_at    TIMESTAMPTZ,
    accepted_by    BIGINT,                         -- user_id who accepted
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX ON Invite(invitee_email, status) WHERE invitee_email IS NOT NULL;
CREATE INDEX ON Invite(context_type, context_id, status);
CREATE INDEX ON Invite(expires_at) WHERE status='pending';

Create Invite

import secrets, datetime

INVITE_EXPIRY_DAYS = 7
LINK_INVITE_EXPIRY_DAYS = 30

def create_invite(context_type: str, context_id: int, invited_by: int,
                  invitee_email: str = None, role: str = 'member',
                  max_uses: int = 1) -> dict:
    """
    Create an invite. invitee_email=None creates a shareable link invite (max_uses=0).
    Returns the invite dict including the token URL.
    """
    # Check inviter has permission to invite in this context
    _assert_can_invite(invited_by, context_type, context_id)

    # Prevent duplicate pending invites to the same email in the same context
    if invitee_email:
        existing = db.fetchone("""
            SELECT invite_id FROM Invite
            WHERE invitee_email=%s AND context_type=%s AND context_id=%s AND status='pending'
              AND expires_at > NOW()
        """, (invitee_email, context_type, context_id))
        if existing:
            raise DuplicateInviteError(f"Pending invite already exists for {invitee_email}")

    token = secrets.token_hex(32)  # 64 hex chars, 256-bit entropy
    expiry_days = LINK_INVITE_EXPIRY_DAYS if max_uses == 0 else INVITE_EXPIRY_DAYS
    expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=expiry_days)

    # Check if invitee already has an account
    invitee_user_id = None
    if invitee_email:
        user = db.fetchone("SELECT user_id FROM User WHERE email=%s", (invitee_email,))
        invitee_user_id = user['user_id'] if user else None

    invite = db.fetchone("""
        INSERT INTO Invite
            (token, context_type, context_id, invited_by, invitee_email,
             invitee_user_id, role, max_uses, expires_at)
        VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
        RETURNING *
    """, (token, context_type, context_id, invited_by, invitee_email,
          invitee_user_id, role, max_uses, expires_at))

    if invitee_email:
        _send_invite_email(invite)

    return invite

Redeem Invite

def redeem_invite(token: str, redeeming_user_id: int) -> dict:
    """
    Accept an invite. The redeeming user is added to the context with the invite's role.
    Handles: registered user accepting email invite, new user who just signed up via invite link.
    """
    invite = db.fetchone("""
        SELECT * FROM Invite WHERE token=%s FOR UPDATE
    """, (token,))

    if not invite:
        raise InviteNotFoundError("Invalid invite token")
    if invite['status'] != 'pending':
        raise InviteAlreadyUsedError(f"Invite is {invite['status']}")
    if invite['expires_at']  0 and new_use_count >= invite['max_uses']:
        new_status = 'accepted'  # fully consumed

    db.execute("""
        UPDATE Invite
        SET use_count=use_count+1,
            status=%s,
            accepted_at=NOW(),
            accepted_by=%s
        WHERE invite_id=%s
    """, (new_status, redeeming_user_id, invite['invite_id']))

    # For link invites: keep status=pending if max_uses=0 or uses remain
    if invite['max_uses'] == 0 or new_use_count < invite['max_uses']:
        db.execute(
            "UPDATE Invite SET status='pending' WHERE invite_id=%s", (invite['invite_id'],)
        )

    return {'context_type': invite['context_type'], 'context_id': invite['context_id'],
            'role': invite['role']}

def _add_member(user_id, context_type, context_id, role):
    """Add user as member of context. Implementation varies by context_type."""
    if context_type == 'workspace':
        db.execute("""
            INSERT INTO WorkspaceMember (workspace_id, user_id, role)
            VALUES (%s,%s,%s) ON CONFLICT DO NOTHING
        """, (context_id, user_id, role))
    elif context_type == 'project':
        db.execute("""
            INSERT INTO ProjectMember (project_id, user_id, role)
            VALUES (%s,%s,%s) ON CONFLICT DO NOTHING
        """, (context_id, user_id, role))

Key Design Decisions

  • Token entropy: secrets.token_hex(32) generates 256 bits of randomness — brute-forcing 2^256 tokens is computationally infeasible. Never use sequential IDs or UUIDs as invite tokens; they’re guessable or enumerable.
  • FOR UPDATE on token lookup: prevents a race condition where two concurrent requests redeem the same single-use link. The first request holds the row lock and commits; the second reads the updated status=’accepted’ and raises InviteAlreadyUsedError.
  • Link invites (max_uses=0): a shareable link invite has no use limit and a longer expiry. After each redemption, status is reset to ‘pending’ — the link remains active. Track individual acceptances via accepted_by on each redemption event. To revoke a link invite, set status=’revoked’.
  • Expiry cleanup: a nightly job sets status=’expired’ for past-due invites: UPDATE Invite SET status=’expired’ WHERE expires_at < NOW() AND status=’pending’.

Invite system and team collaboration design is discussed in Atlassian system design interview questions.

Invite system and professional network growth design is covered in LinkedIn system design interview preparation.

Invite system and host referral design is discussed in Airbnb system design interview guide.

Scroll to Top