Dashboard

How to add team invites to an AI-built app

Invites belong in their own table, not on the user row. The data model, the acceptance transaction, and the three race conditions that only appear with two users.

Steve Jefferson
Steve Jefferson
Developer Advocate
17 October 20261 min read

The mistake that breaks most team invite flows is storing the invite on the user row. You create a user with a null password, email them a link, and now you have a half-real account that shows up in your member list, counts against your seat limit, and cannot be cleanly deleted. Invites belong in their own table, separate from users, with their own lifecycle.

Here is a team invite flow that survives contact with real users, including the three race conditions that bite once more than one person is clicking.

The data model

Two tables, not one. Members links a user to a team. Invites records an intent that has not been accepted yet.

sql
teams
  id, name, created_at

team_members
  id, team_id, user_id, role, joined_at
  unique (team_id, user_id)

team_invites
  id, team_id, email (lowercased), role,
  token_hash, invited_by_user_id,
  created_at, expires_at,
  accepted_at, accepted_user_id, revoked_at
  unique (team_id, email) where accepted_at is null and revoked_at is null

Four details in there matter more than they look:

  • Store a hash of the token, not the token. The raw token goes in the email and nowhere else. If your database leaks, hashed invite tokens cannot be replayed. This is the same reasoning as password storage and it is routinely skipped on invites.

  • Lowercase the email on write. Otherwise Sam@example.com and sam@example.com are two invites to the same person, and your unique constraint does nothing.

  • Partial unique index. Partial indexes give one open invite per email per team, while still allowing a fresh invite after an old one was revoked or accepted. A plain unique constraint blocks re-inviting someone who left.

  • expires_at, always. An invite link with no expiry is a permanent credential sitting in an inbox somebody will eventually lose control of. Seven days is a reasonable default.

The flow, step by step

  1. Validate before creating. Is the email already a member of this team? Is there an open invite? Does the inviter have permission to invite at the role requested? Return a clear answer for each, because the generic failure is the one that generates support mail.

  2. Generate a random token, at least 32 bytes from a cryptographic random source. Store the hash, keep the raw value in memory just long enough to build the link.

  3. Send the email with the raw token in the URL. Adding email sending to an AI-built app covers getting this delivered rather than filtered.

  4. On click, look up by token hash. Check not expired, not revoked, not already accepted. Show the team name before asking anyone to sign in, so the person knows what they are joining.

  5. Accept inside a transaction. Insert the team_members row and stamp accepted_at on the invite together, or neither.

  6. Redirect into the team, not to a generic dashboard. The person clicked a link about a specific team and should land in it.

The three races that break it

Each of these is invisible with one tester and reproducible with two.

The invite accepted twice

The user double-clicks, or the email client prefetches the link, and two accept requests arrive at once. Both read accepted_at as null, both insert a member row. You get a duplicate member, or a constraint error shown to the user as a crash.

The fix is not a check in application code. It is the unique constraint on (team_id, user_id) plus an update that only succeeds once:

sql
update team_invites
set accepted_at = now(), accepted_user_id = $1
where id = $2 and accepted_at is null and revoked_at is null
  and expires_at > now()
returning id;

-- zero rows returned means someone else got there first.
-- Treat that as success if the member row exists, not as an error.

The email that does not match

You invite sam@example.com. Sam signs in with a Google account whose address is sam.jones@example.com. Do you let them in?

Pick a policy and be explicit about it, because both answers are defensible. Strict matching is safer and generates support tickets. Allowing a mismatch is friendlier and means an invite link forwarded to anyone works for anyone. The middle position that works well in practice: allow the mismatch, record which address actually accepted, and show the inviter that it differed. The link is the credential and it was sent to the right inbox.

The user who is already a member

Someone invites a person who already joined via another invite. The flow should recognise it on acceptance, not only on creation, because membership can change between the two. Accepting an invite for a team you are already in should land you in the team with a note, not throw.

The two acceptance paths you have to handle

A person clicking an invite link is either signed in or not, and the two paths diverge more than you expect.

Signed out is the easy case: show the team name, offer sign-up and sign-in, and carry the token through whichever they choose. The token has to survive the round trip, which means it goes in the redirect URL or a short-lived cookie, not in memory.

Signed in is where it goes wrong. The person may be signed in as somebody else, which happens constantly with shared machines and forwarded invites. Do not silently accept the invite as the current session. Show which account is about to join and offer to switch. An invite accepted by the wrong account is tedious to unpick, because you now have a member row for a person who should not be in the team, and the real invitee has a spent token.

What the invite email should contain

Three things, and they are all about reducing support load rather than design:

  • Who invited them, by name. An invite from an unrecognised product with no human attached reads as phishing and gets deleted.

  • Which team, spelled out. People belong to several workspaces and cannot guess which one this is.

  • When it expires, as a date. Not expires in seven days, which is wrong the moment the mail sits unread.

Roles, and keeping them boring

Invites carry a role, which means your invite endpoint is a permission-granting endpoint. Two rules keep this out of trouble: nobody can invite at a role above their own, and the last owner of a team cannot be removed or demoted. That second one prevents the orphaned team that only support can fix.

If you have not set up roles yet, do that before invites rather than after. Role-based access control in an AI-built app covers the model, and invites then become a thin layer on top. Doing it the other way round means a migration.

What to build next, and what not to

Resend and revoke are worth building immediately, because the first support request is always one of the two. Resend should rotate the token rather than re-send the old one. Revoke is a timestamp, not a delete, so you keep the audit trail.

Skip, for now: bulk CSV invites, domain-based auto-join, and nested teams. Each is reasonable later and each adds a case to every query you have just written. Seat limits are worth adding only when you charge per seat.

One thing worth adding early is an audit record of who invited whom and when. Adding an audit log is cheap at this stage and awkward to backfill.

Common questions

Should an invite create a user account?

No. Keep invites in their own table and create the user only on acceptance. Pre-creating users gives you half-real accounts in your member list and seat counts.

Seven days is a sensible default. The link is a credential, so an invite that never expires is a permanent access token sitting in an inbox. Make resending easy and short expiry costs nothing.

What if the invited person signs in with a different email?

Decide deliberately. Allowing it is friendlier and means the link alone grants access. Record the address that accepted and surface the mismatch to the inviter so the discrepancy is visible.

How do I stop an invite being accepted twice?

A conditional update that only matches when accepted_at is null, plus a unique constraint on team and user. Do not rely on reading then writing, because that is the race itself.

Do I need to tell AI to do all this?

Mostly yes. Ask for the invite table separate from users, hashed tokens, a partial unique index on open invites, and acceptance in a transaction. Sketching the tables first helps, and prompting AI to design a database schema covers how to get a model to argue with your design before writing it.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.