Skip to main content

Overview

The Platform API provides two types of invitations:
  1. Company Invites - Platform admin invites users to create new companies
  2. Member Invites - Company members invite users to join existing companies
Both use secure token-based flows with expiration and status tracking.

Company Invites

Purpose

Company invites allow platform administrators to invite specific users to create new companies. This is one of three ways to create a company:

Admin Invite

Admin creates invite → User accepts → User creates company

User Request

User requests → Admin approves → User creates company

Direct Creation

User with companies.create permission creates company directly

Company Invite Model

CompanyInvite Structure
{
  id: "uuid",
  email: "[email protected]",
  tokenHash: "bcrypt-hashed-token",
  status: "PENDING" | "USED" | "EXPIRED" | "REVOKED",
  
  issuedByAdminId: "admin-uuid",
  usedByUserId: "user-uuid" | null,
  createdCompanyId: "company-uuid" | null,
  
  expiresAt: "2024-01-15T00:00:00Z",
  usedAt: "2024-01-10T12:30:00Z" | null,
  revokedAt: null,
  
  createdAt: "2024-01-01T00:00:00Z",
  updatedAt: "2024-01-10T12:30:00Z"
}
Key Relationships:
  • issuedByAdmin: Platform admin who created the invite
  • usedByUser: User who accepted the invite (set when used)
  • createdCompany: Company created from this invite (1:1 relationship)

Company Invite Flow

1

Admin Creates Invite

Platform admin generates an invite for a specific email:
POST /api/admin/company-invites
{
  "email": "[email protected]"
}
The system:
  • Generates a secure 32-byte random token
  • Hashes the token with bcrypt (only hash is stored)
  • Sets expiration date (configurable, typically 7-14 days)
  • Returns the plain token to send to the user
2

User Receives Email

The plain token is sent to the user’s email with a registration link:
https://app.example.com/register?invite=abc123def456...
3

User Registers

User creates an account, providing the invite token:
POST /api/auth/register
{
  "email": "[email protected]",
  "fullName": "John Doe",
  "password": "secure-password",
  "inviteToken": "abc123def456..."
}
The system validates:
  • Token hash matches
  • Email matches invite email
  • Invite status is PENDING
  • Expiration date hasn’t passed
4

User Creates Company

After registration, the user creates their company:
POST /api/companies
{
  "name": "Acme Corporation",
  "slug": "acme-corp",
  "description": "..."
}
The system:
  • Creates the company
  • Updates invite: status: USED, usedAt, usedByUserId, createdCompanyId
  • Creates ACTIVE membership with Owner role for the user

Company Invite Statuses

Initial state when invite is created.
  • Invite can be used by the recipient
  • Token is valid (not expired)
  • Admin can revoke if needed

Member Invites

Purpose

Member invites allow authorized company members to invite users to join their company. This is the primary way to add new members to existing companies.

Member Invite Model

CompanyMemberInvite Structure
{
  id: "uuid",
  companyId: "uuid",
  email: "[email protected]",
  tokenHash: "bcrypt-hashed-token",
  status: "PENDING" | "USED" | "EXPIRED" | "REVOKED",
  inviteMessage: "Welcome to our team!" | null,
  
  issuedByUserId: "user-uuid",
  usedByUserId: "user-uuid" | null,
  defaultRoleId: "role-uuid" | null,
  
  expiresAt: "2024-01-15T00:00:00Z",
  usedAt: "2024-01-10T12:30:00Z" | null,
  revokedAt: null,
  
  createdAt: "2024-01-01T00:00:00Z",
  updatedAt: "2024-01-10T12:30:00Z"
}
Key Relationships:
  • company: The company the user is being invited to
  • issuedByUser: Company member who created the invite (requires permission)
  • usedByUser: User who accepted the invite
  • defaultRole: Role to assign when invite is accepted (falls back to company default role)
Unique Constraint:
@@unique([companyId, email, status], name: "unique_pending_invite")
This prevents multiple pending invites to the same email for the same company.

Member Invite Flow

1

Member Creates Invite

An authorized company member invites a user by email:
POST /api/companies/:companyId/member-invites
{
  "email": "[email protected]",
  "defaultRoleId": "member-role-uuid",
  "inviteMessage": "We'd love to have you join our team!"
}
Validation:
  • Issuer has permission to invite members
  • Email is not already a member
  • No pending invite exists for this email
  • Default role belongs to this company (if provided)
2

User Receives Notification

The invited user receives an email with a link:
https://app.example.com/invitations?token=xyz789...
If the user already has an account, they can also see the invite in their dashboard:
GET /api/invitations/pending

// Response
{
  "invitations": [
    {
      "id": "uuid",
      "company": { "name": "Acme Corp", "slug": "acme-corp" },
      "roles": [{ "name": "Member", "color": "#6B7280" }],
      "invitedAt": "2024-01-01T00:00:00Z"
    }
  ]
}
3

User Accepts or Declines

If accepting:
POST /api/invitations/:membershipId/accept
The system:
  • Updates membership: status: ACTIVE, activatedAt
  • Updates invite: status: USED, usedAt, usedByUserId
  • User can now access the company
If declining:
POST /api/invitations/:membershipId/decline
The system:
  • Deletes the membership record
  • Updates invite: status: REVOKED, revokedAt

Direct Member Addition

Internally, members can also be added directly without the traditional invite email flow:
POST /api/companies/:companyId/members/invite
{
  "userId": "existing-user-uuid",
  "position": "Senior Developer",
  "department": "Engineering"
}
This creates a membership with status: INVITED and sends a real-time notification:
// SSE event sent to the user
{
  "event": "invitation:new",
  "data": {
    "id": "membership-uuid",
    "company": { "name": "Acme Corp", "slug": "acme-corp" },
    "roles": [{ "name": "Member", "color": "#6B7280" }],
    "invitedAt": "2024-01-01T00:00:00Z"
  }
}

Invitation Security

Token Generation

Both invite types use the same secure token pattern:
import crypto from 'crypto';
import bcrypt from 'bcryptjs';

// 1. Generate cryptographically secure random token
const plainToken = crypto.randomBytes(32).toString('hex');
// Result: "a1b2c3d4e5f6..." (64 characters)

// 2. Hash token before storing
const tokenHash = await bcrypt.hash(plainToken, 10);
// Result: "$2a$10$..." (bcrypt hash)

// 3. Store only the hash
await prisma.companyInvite.create({
  data: {
    email: '[email protected]',
    tokenHash, // Never store plain token
    // ...
  }
});

// 4. Send plain token to user (via email, etc.)
return plainToken; // This is the only time we have access to it
Security Benefits:
  • Plain tokens never stored in database
  • Database breach doesn’t expose usable tokens
  • Tokens are 256-bit entropy (2^256 possibilities)
  • Bcrypt is slow and resistant to brute-force

Token Validation

When a user provides a token, validation follows these steps:
import bcrypt from 'bcryptjs';

const validateInvite = async (plainToken: string, email: string) => {
  // 1. Find invite by email
  const invite = await prisma.companyMemberInvite.findFirst({
    where: { email, status: 'PENDING' }
  });
  
  if (!invite) {
    throw new Error('Invite not found');
  }
  
  // 2. Check expiration
  if (new Date() > invite.expiresAt) {
    throw new Error('Invite has expired');
  }
  
  // 3. Verify token hash
  const isValid = await bcrypt.compare(plainToken, invite.tokenHash);
  
  if (!isValid) {
    throw new Error('Invalid invite token');
  }
  
  return invite;
};

Expiration Policy

Expiration dates are configurable via environment variables:
.env
# Company invite expiration (days)
COMPANY_INVITE_EXPIRY_DAYS=14

# Member invite expiration (hours)
MEMBER_INVITE_EXPIRY_HOURS=168  # 7 days
Automatic Cleanup: Expired invites should be cleaned up periodically via a cron job:
// Update expired invites
await prisma.companyInvite.updateMany({
  where: {
    status: 'PENDING',
    expiresAt: { lt: new Date() }
  },
  data: { status: 'EXPIRED' }
});

await prisma.companyMemberInvite.updateMany({
  where: {
    status: 'PENDING',
    expiresAt: { lt: new Date() }
  },
  data: { status: 'EXPIRED' }
});

Real-Time Notifications

When a member invite is created, the system sends a real-time notification via Server-Sent Events (SSE):
import { sseManager } from '@/common/services/sse.manager';

// Send notification to invited user
sseManager.sendToUser(userId, 'invitation:new', {
  id: membership.id,
  company: {
    id: company.id,
    name: company.name,
    slug: company.slug,
    logo: company.logo
  },
  roles: [{ id: role.id, name: role.name, color: role.color }],
  invitedAt: new Date().toISOString()
});
The client can listen for these events:
const eventSource = new EventSource('/api/sse');

eventSource.addEventListener('invitation:new', (event) => {
  const invitation = JSON.parse(event.data);
  // Show notification to user
  showNotification(
    `You've been invited to join ${invitation.company.name}`
  );
});

Best Practices

Validate Permissions

Always verify that the user creating an invite has the appropriate permission (e.g., members.invite).

Check for Duplicates

Before creating a member invite, verify that the user isn’t already a member and doesn’t have a pending invite.

Set Reasonable Expiration

Balance security (shorter expiration) with user convenience (longer expiration). 7 days is typically appropriate.

Provide Clear Messaging

Include helpful context in invite emails: who invited them, what company, what role they’ll have.

Handle Edge Cases

What happens if a user declines, then is re-invited? Or if an invite expires while the user is registering?

Audit Invite Actions

Log who created, accepted, declined, and revoked invites for security and compliance tracking.

Clean Up Expired Invites

Run periodic jobs to update expired invites and optionally delete old invites after 90+ days.

Rate Limit Invites

Prevent abuse by limiting how many invites a user can send per day/hour.

Multi-Tenancy

Learn about company creation and the tenant model

Memberships

Understand how invitations create memberships

RBAC

See how default roles are assigned during invitation

Build docs developers (and LLMs) love