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’.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent invite token enumeration attacks?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A sequential invite token (token=1001, 1002, 1003) lets an attacker iterate through all tokens and redeem them. A UUID v4 has 122 bits of entropy — brute-forcing is computationally infeasible, but UUIDs are well-known and there are public UUID collision tools. secrets.token_hex(32) generates 256 bits of cryptographically random hex — the strongest practical option. Additionally: (1) never expose token IDs in logs, analytics, or error messages; (2) rate-limit /invite/redeem to 10 requests per minute per IP; (3) log all redemption attempts (success and failure) with IP address; (4) alert on more than 5 failed redemption attempts from a single IP in 60 seconds. Failed redemptions are not errors to hide — they are security signals to monitor.”}},{“@type”:”Question”,”name”:”How do you handle an invite to an email address that later signs up without using the invite link?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A user receives an invite to their work email, ignores it, and signs up through the normal registration flow a week later. Their invite is now dangling — status=pending but the user exists. On signup: check if the new user’s email matches any pending invite in the context they’re being invited to. If yes, auto-redeem the invite: call _add_member(new_user_id, invite.context_type, invite.context_id, invite.role) and set invite.status=’accepted’. This creates a smooth experience — the user who sent the invite sees their colleague in the workspace without anyone having to manually resend or re-accept. Implementation: add a post-signup hook that queries WHERE invitee_email = new_user.email AND status=’pending’ AND expires_at > NOW() and redeems matching invites.”}},{“@type”:”Question”,”name”:”How do you limit the number of pending invites a user can send to prevent abuse?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Without limits, a bad actor could send 10,000 invite emails to spam recipients using your transactional email sender. Limits: (1) per-context invite cap: a workspace can have at most 50 pending invites outstanding at a time; (2) per-user hourly rate: an individual user can send at most 20 invites per hour; (3) daily cap: 50 invites per user per day. Implementation: SELECT COUNT(*) FROM Invite WHERE invited_by=%s AND created_at > NOW()-INTERVAL ‘1 hour’ AND status=’pending’. If > hourly limit, return 429 Too Many Requests. For link invites (single URL, multiple redeemers): count each link as one invite regardless of how many people redeem it. The invite cap prevents the link from being used by more than max_uses people — set max_uses on link invites as well.”}},{“@type”:”Question”,”name”:”What should happen when an invited user’s email domain changes (corporate rebranding)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A user was invited as alice@oldcompany.com. The company rebrands to newcompany.com and Alice’s email becomes alice@newcompany.com. The invite now has a mismatched invitee_email. Solutions: (1) Update the invite when the admin notifies you of the domain change: UPDATE Invite SET invitee_email=’alice@newcompany.com’ WHERE invitee_email=’alice@oldcompany.com’ AND status=’pending’. This requires an admin action. (2) Allow domain-level invite matching: if the invite’s invitee_email domain matches the user’s new email domain and the username part matches, allow redemption. This is looser — implement with caution. (3) Don’t rely on email-level matching for domain-rebrand scenarios: instead, send a new invite. The old invite expires naturally. The simplest policy: expired invites are regenerated by the inviter — this is the most secure default.”}},{“@type”:”Question”,”name”:”How do you implement bulk invites (inviting 100 people at once from a CSV)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Bulk invite from a CSV: parse emails, validate format, deduplicate, and process in a background job. Never process 100 invite rows synchronously in a single HTTP request — the user would wait for 100 DB inserts + 100 email sends. Implementation: (1) accept the CSV via file upload; (2) parse and validate emails (regex + domain format check) immediately and return a preview (valid: 87, duplicates: 8, invalid: 5); (3) on confirmation, enqueue a bulk_invite job with the email list and context; (4) the worker calls create_invite() for each email, catching DuplicateInviteError silently; (5) when complete, send a summary to the inviter: "87 invites sent, 8 already had pending invites, 5 had invalid email formats." Rate-limit bulk invites more aggressively: one bulk invite operation per user per hour, max 500 emails per bulk operation.”}}]}
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.