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.
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
| Component | Monthly cost | Notes |
|---|---|---|
| Resend (free tier) | $0 | 3,000 emails/mo |
| Resend (Pro) | $20/mo | 50,000 emails/mo |
| Engineering cost | 1โ2 weeks | Full invite system |
| Seat limit enforcement | Included | Prevents overuse |
See Also
- SaaS User Permissions: RBAC, ABAC, and OPA Integration
- SaaS Onboarding Checklist: Interactive UI and Completion Rewards
- SaaS Audit Logging: Immutable Trails and SOC2 Compliance
- Stripe Webhook Handling: Signature Verification and Idempotency
- SaaS Email Sequences: Transactional System and Drip Campaigns
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.