Back to Blog

SaaS Team Invitations: Invite Flow, Token Expiry, Role Assignment, and Email Delivery

Build a production SaaS team invitation system: secure invitation tokens with expiry, role assignment at accept time, email delivery with Resend, invitation management UI, and edge cases like existing users and seat limits.

Viprasol Tech Team
December 19, 2026
13 min read

Team invitations look simple but have a surprising number of edge cases: What if the invited email already has an account? What if the inviter leaves the team before the invite is accepted? What happens if you're at your seat limit? What if the invite token expires and the user clicks an old link? Getting these right is the difference between a polished onboarding experience and a confused user.

This post covers the full implementation: database schema, invitation creation with seat limit enforcement, cryptographically secure tokens with expiry, email delivery with Resend, accept flow for new and existing users, and invitation management UI.

1. Database Schema

CREATE TABLE team_invitations (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID        NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  invited_by      UUID        NOT NULL REFERENCES users(id),
  
  email           TEXT        NOT NULL,
  role            TEXT        NOT NULL DEFAULT 'member'
                              CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  
  -- Secure token (stored as hash, sent as raw)
  token_hash      TEXT        NOT NULL UNIQUE,
  
  status          TEXT        NOT NULL DEFAULT 'pending'
                              CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
  
  -- Expiry
  expires_at      TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
  
  -- Accept metadata
  accepted_by     UUID        REFERENCES users(id),
  accepted_at     TIMESTAMPTZ,
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  -- One pending invite per email per tenant
  UNIQUE (tenant_id, email, status) DEFERRABLE INITIALLY DEFERRED,
  
  INDEX idx_invitations_token (token_hash),
  INDEX idx_invitations_tenant (tenant_id, status)
);

-- Automatic expiry via scheduled job (or check at access time)
-- No trigger needed โ€” check status + expires_at at validation time

2. Invitation Service

// src/services/invitations/invitation.service.ts
import { randomBytes, createHash } from 'crypto';
import { db } from '../../lib/db';
import { sendInvitationEmail } from './invitation.email';

const INVITATION_EXPIRY_DAYS = 7;
const MAX_SEATS_PER_PLAN: Record<string, number> = {
  free: 1,
  starter: 5,
  pro: 25,
  enterprise: Infinity,
};

export interface CreateInvitationInput {
  tenantId: string;
  invitedByUserId: string;
  email: string;
  role: 'admin' | 'member' | 'viewer';
}

export async function createInvitation(input: CreateInvitationInput): Promise<{
  invitationId: string;
  token: string;
}> {
  const { tenantId, invitedByUserId, email, role } = input;
  const normalizedEmail = email.toLowerCase().trim();

  // 1. Check seat limit
  const account = await db.account.findUnique({
    where: { id: tenantId },
    include: {
      _count: { select: { members: { where: { status: 'active' } } } },
      subscription: { select: { plan: true } },
    },
  });

  if (!account) throw new Error('Account not found');

  const maxSeats = MAX_SEATS_PER_PLAN[account.subscription?.plan ?? 'free'];
  const pendingCount = await db.teamInvitation.count({
    where: { tenantId, status: 'pending' },
  });
  const totalOccupied = account._count.members + pendingCount;

  if (totalOccupied >= maxSeats) {
    throw new Error(`Seat limit reached (${maxSeats} seats on ${account.subscription?.plan} plan)`);
  }

  // 2. Check if already a member
  const existingMember = await db.member.findFirst({
    where: { tenantId, user: { email: normalizedEmail }, status: 'active' },
  });
  if (existingMember) {
    throw new Error(`${normalizedEmail} is already a member of this team`);
  }

  // 3. Revoke any existing pending invite for this email
  await db.teamInvitation.updateMany({
    where: { tenantId, email: normalizedEmail, status: 'pending' },
    data: { status: 'revoked' },
  });

  // 4. Generate cryptographically secure token
  const rawToken = randomBytes(32).toString('hex'); // 64-char hex string
  const tokenHash = createHash('sha256').update(rawToken).digest('hex');

  const expiresAt = new Date(Date.now() + INVITATION_EXPIRY_DAYS * 86_400_000);

  // 5. Create invitation
  const invitation = await db.teamInvitation.create({
    data: {
      tenantId,
      invitedBy: invitedByUserId,
      email: normalizedEmail,
      role,
      tokenHash,
      expiresAt,
    },
  });

  // 6. Send invitation email
  const inviter = await db.user.findUnique({
    where: { id: invitedByUserId },
    select: { name: true, email: true },
  });

  await sendInvitationEmail({
    to: normalizedEmail,
    inviterName: inviter?.name ?? 'A teammate',
    teamName: account.name,
    role,
    inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/accept?token=${rawToken}`,
    expiresAt,
  });

  return { invitationId: invitation.id, token: rawToken };
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

3. Email with Resend

// src/services/invitations/invitation.email.ts
import { Resend } from 'resend';
import { InvitationEmailTemplate } from '../../emails/InvitationEmail';

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendInvitationEmailInput {
  to: string;
  inviterName: string;
  teamName: string;
  role: string;
  inviteUrl: string;
  expiresAt: Date;
}

export async function sendInvitationEmail(input: SendInvitationEmailInput): Promise<void> {
  await resend.emails.send({
    from: 'Viprasol <invites@viprasol.com>',
    to: input.to,
    subject: `${input.inviterName} invited you to join ${input.teamName}`,
    react: InvitationEmailTemplate(input),
    // Fallback plain-text
    text: [
      `${input.inviterName} has invited you to join ${input.teamName} as ${input.role}.`,
      '',
      `Accept your invitation: ${input.inviteUrl}`,
      '',
      `This invitation expires on ${input.expiresAt.toLocaleDateString('en-US', { dateStyle: 'long' })}.`,
    ].join('\n'),
  });
}
// src/emails/InvitationEmail.tsx (React Email template)
import {
  Html, Head, Body, Container, Section,
  Text, Button, Hr, Preview,
} from '@react-email/components';

interface Props {
  inviterName: string;
  teamName: string;
  role: string;
  inviteUrl: string;
  expiresAt: Date;
}

export function InvitationEmailTemplate({ inviterName, teamName, role, inviteUrl, expiresAt }: Props) {
  return (
    <Html>
      <Head />
      <Preview>{inviterName} invited you to {teamName}</Preview>
      <Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f9fafb' }}>
        <Container style={{ maxWidth: '600px', margin: '0 auto', padding: '40px 20px' }}>
          <Section style={{ backgroundColor: '#fff', borderRadius: '12px', padding: '40px' }}>
            <Text style={{ fontSize: '24px', fontWeight: 'bold', color: '#111827', marginTop: 0 }}>
              You've been invited to {teamName}
            </Text>
            <Text style={{ color: '#6b7280', lineHeight: '1.6' }}>
              <strong>{inviterName}</strong> has invited you to join <strong>{teamName}</strong> as a{' '}
              <strong>{role}</strong>.
            </Text>
            <Button
              href={inviteUrl}
              style={{
                backgroundColor: '#2563EB',
                color: '#fff',
                padding: '12px 24px',
                borderRadius: '8px',
                fontWeight: '600',
                textDecoration: 'none',
                display: 'inline-block',
                marginTop: '16px',
              }}
            >
              Accept invitation
            </Button>
            <Hr style={{ borderColor: '#e5e7eb', margin: '24px 0' }} />
            <Text style={{ fontSize: '12px', color: '#9ca3af' }}>
              This invitation expires on {expiresAt.toLocaleDateString('en-US', { dateStyle: 'long' })}.
              If you weren't expecting this, you can ignore this email.
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

4. Accept Flow

// src/app/invite/accept/route.ts (or Server Action)
import { createHash } from 'crypto';
import { db } from '../../../lib/db';
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';

export async function acceptInvitation(token: string): Promise<{
  success: boolean;
  error?: string;
}> {
  const tokenHash = createHash('sha256').update(token).digest('hex');

  const invitation = await db.teamInvitation.findUnique({
    where: { tokenHash },
    include: { account: { select: { name: true, id: true } } },
  });

  // Validate invitation
  if (!invitation) {
    return { success: false, error: 'Invalid invitation link' };
  }
  if (invitation.status === 'accepted') {
    return { success: false, error: 'This invitation has already been accepted' };
  }
  if (invitation.status === 'revoked') {
    return { success: false, error: 'This invitation has been revoked' };
  }
  if (invitation.expires_at < new Date()) {
    // Mark as expired
    await db.teamInvitation.update({
      where: { id: invitation.id },
      data: { status: 'expired' },
    });
    return { success: false, error: 'This invitation has expired. Please ask for a new one.' };
  }

  const session = await getServerSession();

  // Case A: User is already logged in
  if (session?.user) {
    // Verify email matches (optional โ€” some products allow cross-email accept)
    if (session.user.email !== invitation.email) {
      return {
        success: false,
        error: `This invitation was sent to ${invitation.email}. Please log in with that email.`,
      };
    }

    await joinTeam(session.user.id, invitation);
    return { success: true };
  }

  // Case B: Invited email already has an account โ†’ redirect to login
  const existingUser = await db.user.findUnique({
    where: { email: invitation.email },
    select: { id: true },
  });

  if (existingUser) {
    // Store token in cookie so we can complete after login
    redirect(`/login?callbackUrl=/invite/accept?token=${token}&email=${invitation.email}`);
  }

  // Case C: New user โ†’ redirect to register with email pre-filled
  redirect(`/register?email=${encodeURIComponent(invitation.email)}&token=${token}`);
}

async function joinTeam(
  userId: string,
  invitation: Invitation & { account: { id: string } }
): Promise<void> {
  await db.$transaction([
    // Add to team
    db.member.upsert({
      where: { userId_tenantId: { userId, tenantId: invitation.tenantId } },
      create: { userId, tenantId: invitation.tenantId, role: invitation.role, status: 'active' },
      update: { role: invitation.role, status: 'active' },
    }),
    // Mark invitation accepted
    db.teamInvitation.update({
      where: { id: invitation.id },
      data: {
        status: 'accepted',
        acceptedBy: userId,
        acceptedAt: new Date(),
      },
    }),
  ]);
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

5. Invitation Management UI (Server Component)

// src/app/settings/team/page.tsx
import { db } from '../../../lib/db';
import { getServerSession } from 'next-auth';
import { revokeInvitation } from './actions';
import { Mail, Clock, CheckCircle, XCircle } from 'lucide-react';

export default async function TeamPage() {
  const session = await getServerSession();

  const [members, invitations] = await Promise.all([
    db.member.findMany({
      where: { tenantId: session!.user.tenantId, status: 'active' },
      include: { user: { select: { name: true, email: true, avatarUrl: true } } },
      orderBy: { createdAt: 'asc' },
    }),
    db.teamInvitation.findMany({
      where: {
        tenantId: session!.user.tenantId,
        status: 'pending',
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    }),
  ]);

  return (
    <div className="space-y-8">
      <section>
        <h2 className="text-lg font-semibold text-gray-900">Team members ({members.length})</h2>
        <div className="mt-4 divide-y divide-gray-100 rounded-lg border border-gray-200">
          {members.map((m) => (
            <div key={m.id} className="flex items-center gap-3 px-4 py-3">
              <div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-sm font-medium text-blue-700">
                {m.user.name?.[0]?.toUpperCase() ?? '?'}
              </div>
              <div className="flex-1">
                <p className="text-sm font-medium text-gray-900">{m.user.name}</p>
                <p className="text-xs text-gray-500">{m.user.email}</p>
              </div>
              <span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 capitalize">
                {m.role}
              </span>
            </div>
          ))}
        </div>
      </section>

      {invitations.length > 0 && (
        <section>
          <h2 className="text-lg font-semibold text-gray-900">Pending invitations</h2>
          <div className="mt-4 divide-y divide-gray-100 rounded-lg border border-gray-200">
            {invitations.map((inv) => (
              <div key={inv.id} className="flex items-center gap-3 px-4 py-3">
                <Mail className="h-4 w-4 text-gray-400 flex-shrink-0" />
                <div className="flex-1">
                  <p className="text-sm font-medium text-gray-900">{inv.email}</p>
                  <p className="text-xs text-gray-500">
                    Expires {new Date(inv.expiresAt).toLocaleDateString()}
                  </p>
                </div>
                <span className="rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-700 capitalize">
                  {inv.role}
                </span>
                <form action={revokeInvitation.bind(null, inv.id)}>
                  <button type="submit" className="text-xs text-red-500 hover:text-red-700">
                    Revoke
                  </button>
                </form>
              </div>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}

Cost Reference

ComponentMonthly costNotes
Resend (free tier)$03,000 emails/mo
Resend (Pro)$20/mo50,000 emails/mo
Engineering cost1โ€“2 weeksFull invite system
Seat limit enforcementIncludedPrevents overuse

See Also


Working With Viprasol

Building team collaboration for your SaaS product and need an invitation system that handles existing users, expired tokens, seat limits, and role assignment correctly? We implement the full invite flow โ€” secure tokens, Resend email templates, accept flow for new and returning users, and invitation management UI โ€” in 1โ€“2 weeks.

Talk to our team โ†’ | See our web development services โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.