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.