Skip to main content

Invite Lifecycle

User-facing reference for the team-invite lifecycle: how an invite is created, delivered, accepted, resent, and expired across the surfaces that touch it.

The lifecycle rules are locked by ADR-023 (expiry, token rotation, self-service resend). This doc is the user-/operator- facing view; ADR-023 is the authoritative spec and the source of truth for any behaviour described here.

Surfacesโ€‹

The same invite lifecycle is reached from three places:

  • AM onboarding โ€” /ops/clients/[id]/setup/step-4: an Account Manager invites the first client admin while standing up a new client.
  • Client self-service โ€” /client/settings/team: a client org_admin / owner invites and manages their own colleagues.
  • Ops โ€” /ops/team: a SuperAdmin manages invites/members across any org.

All three drive the same API endpoints and the same invites row lifecycle.

State machineโ€‹

An invite is one row in invites (scoped to org_id + email):

                 POST /orgs/:orgId/invites
(none) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ pending
โ”‚
POST /auth/accept-invite-by-token โ”‚ accepted โ†’ User row active
pending โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚ (used_at set)
โ”‚
7 days elapse (expires_at < now) โ”‚ expired
pending โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚ (link dead; row remains for audit)
  • Expiry: 7 days, hardcoded. INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000 (api/src/auth/invite-lifecycle.ts). Long enough for "I'll get to it tomorrow", short enough that a leaked link doesn't grant access forever. (ADR-023 D1.)
  • Acceptance is one-shot. Delivery of the invite UUID to the recipient's mailbox is the email-ownership proof, so accepting does not demand a second OTP.

Creating / re-sending an invite (admin & AM)โ€‹

POST /orgs/:orgId/invites (org admin or SuperAdmin).

  • Idempotent. If a pending invite already exists for that email in the org (used_at IS NULL, expires_at > now()), a second call does not create a duplicate row. It instead rotates the token (new UUID), resets expires_at to now + 7 days, appends a metadata.history[] entry, increments metadata.resendCount, and re-emails โ€” returning the same row id. So pressing "Invite" twice never produces two pending invites. (ADR-023 D2.)
  • Re-inviting an already-active user โ†’ 400. If the email already maps to an active User, the call is rejected ("User is already a member. Use /ops/members to manage their role.") rather than minting a stray pending invite or accidentally resetting the user's role. (ADR-023 D5.)
  • Token rotation on every resend. Each resend issues a fresh token and invalidates the previous link โ€” so a forwarded/leaked email is cut the moment a new link is sent. The row id, createdBy, org_id/email/platform_role/org_role, and the metadata.history[] audit trail are preserved. (ADR-023 D3.)

Self-service resend (the invited user)โ€‹

POST /auth/invites/resend โ€” public, body { email }. Removes the "I lost the invite email โ†’ admin ping-pong" loop. Guarded three ways (ADR-023 D4):

  1. Enumeration-safe. Always returns 200 { ok: true } whether or not the email matches a pending invite โ€” it can't be used as an oracle to discover invited addresses.
  2. Rate-limited. 3 / minute per IP.
  3. No new attack surface. It only resends an already-issued invite (same recipient, same role); it cannot create a new invite, change the recipient, or change the role. Same blast radius as an admin pressing "resend".

The expired/invalid accept page (/accept-invite?token=โ€ฆ) surfaces a small "request a new link" form on terminal errors (404 not found / 400 expired/used) that posts to this endpoint, so a dead link is no longer a dead end. (ADR-023 D6.) A user who genuinely changed email addresses still cannot self-resend to the new address โ€” changing the recipient is admin-only by design.

Accepting an inviteโ€‹

  • POST /auth/accept-invite-by-token โ€” the current one-shot accept (no second OTP); invite UUID delivery is the proof of email ownership (#1250).
  • GET /invites/validate?token=โ€ฆ โ€” public; returns the org name + invitee email, and powers both the accept page and the resend form's messaging.
  • POST /auth/accept-invite/:token and POST /auth/send-invite-otp / POST /auth/verify-invite-otp โ€” legacy password-flow / OTP-flow endpoints kept live for in-flight emails during the deprecation window; do not build new flows on them.

Revoking / removingโ€‹

There is no dedicated "revoke this pending invite" endpoint. A pending invite is neutralized by:

  • Auto-expiry โ€” the link dies after 7 days with no action needed.
  • Token rotation โ€” any resend cuts the previously-issued link.
  • Member removal โ€” DELETE /orgs/:orgId/members/:userId (org admin) removes the member; use this to pull access for someone invited by mistake.

Audit (invites.metadata)โ€‹

Every resend appends to an append-only log (ADR-023 D7), capped at 50 entries (oldest evicted):

type InviteMetadata = {
resendCount?: number; // total resends (admin + user combined)
lastResentAt?: string; // ISO timestamp
history?: Array<{
at: string; // ISO timestamp
by: "admin" | "user"; // who triggered the resend
actorId?: string; // requester id if admin
tokenSuffix: string; // last 8 chars of token โ€” debug correlation
}>;
};

The audit story per invite is "invite N was sent at T1, T2, T3 to email@x".