Skip to content
SaaS4Builders
Teams

Invitations

Team invitation flow: token-based security, invitation lifecycle, email notifications, public and protected API endpoints, and error handling.

Team members join a tenant through an invitation system. Invitations are token-based, email-verified, and support both existing users (who accept while logged in) and new users (who register and accept in a single step). The system enforces seat quotas, prevents duplicates, and protects against race conditions.


Invitation Flow

The complete invitation process follows these steps:

  1. An admin creates an invitation via POST /api/v1/tenant/{tenantId}/team/invitations with the recipient's email and desired role.
  2. The system validates the request — checks the inviter's team.invite permission, verifies no duplicate invitation or existing membership, and confirms the seat quota allows a new member. A cache lock prevents race conditions from concurrent requests.
  3. A token is generated — a 64-character hex string. The SHA-256 hash is stored in the database; the plain token is never persisted.
  4. An email notification is queued containing an accept URL with the plain token. The email is localized to the invitation's locale.
  5. The recipient clicks the link — the frontend loads invitation details via GET /api/v1/invitations/{token}.
  6. Acceptance happens through one of two paths:
    • Existing user (logged in): POST /api/v1/invitations/{token}/accept — attaches the user to the tenant and assigns the role.
    • New user (not registered): POST /api/v1/invitations/{token}/accept-with-registration — creates the user account, attaches them to the tenant, assigns the role, and returns authentication tokens.
  7. The TeamMemberAdded event is dispatched, triggering seat quantity sync with Stripe if the tenant has a seat-based plan.
When a new user accepts via registration, the system does not create a personal tenant for them (unlike normal registration). The invited user joins the inviter's tenant directly.

Token Security

Invitation tokens are managed through the InvitationToken value object, which enforces secure generation, storage, and verification:

backend/app/Domain/Team/ValueObjects/InvitationToken.php
final readonly class InvitationToken implements Stringable
{
    private const TOKEN_LENGTH = 64;

    public static function generate(): self
    {
        $plain = bin2hex(random_bytes(self::TOKEN_LENGTH / 2));

        return new self($plain, hash('sha256', $plain));
    }

    public static function fromPlain(string $plainToken): self
    {
        if (strlen($plainToken) !== self::TOKEN_LENGTH || ! ctype_xdigit($plainToken)) {
            throw new InvalidArgumentException('Invalid token format');
        }

        return new self($plainToken, hash('sha256', $plainToken));
    }

    public function matches(string $plainToken): bool
    {
        return hash_equals($this->hashedToken, hash('sha256', $plainToken));
    }
}

Key security properties:

  • 64-character hex tokens (32 random bytes) provide 256 bits of entropy
  • SHA-256 hashing before database storage — a database compromise does not reveal usable tokens
  • Timing-safe comparison via hash_equals() prevents timing attacks
  • Plain tokens exist only in email links and in-memory during generation — they are never stored
The plain token is never persisted in the database. If the invitation email is lost, use the resend endpoint to generate a new token.

Invitation Lifecycle

Invitations move through a defined set of states:

backend/app/Domain/Team/Enums/InvitationStatus.php
enum InvitationStatus: string
{
    case PENDING = 'pending';
    case ACCEPTED = 'accepted';
    case REVOKED = 'revoked';
    case EXPIRED = 'expired';

    public function isPending(): bool
    {
        return $this === self::PENDING;
    }

    public function isTerminal(): bool
    {
        return in_array($this, [self::ACCEPTED, self::REVOKED, self::EXPIRED], true);
    }
}

State Transitions

FromToTrigger
pendingInvitation created
pendingacceptedUser accepts the invitation
pendingrevokedAdmin revokes the invitation
pendingexpiredExpiry date reached (checked at acceptance time)
expiredpendingAdmin resends the invitation (new token, reset expiry)

Once an invitation reaches accepted or revoked, it cannot transition to any other state. The expired state is the only terminal state that can be reversed — via the resend endpoint, which generates a new token and resets the expiry.


Expiration and Resend

Invitation expiry is configurable via environment variable:

backend/config/team.php
return [
    'invitation_expires_days' => env('TEAM_INVITATION_EXPIRES_DAYS', 7),
    // ...
];

Expiration is checked at acceptance time, not by a background job. An invitation with expires_at in the past is treated as expired when someone tries to accept it.

Resending an Invitation

The resend operation (POST /api/v1/tenant/{tenantId}/team/invitations/{id}/resend):

  1. Generates a new token (the old token becomes invalid)
  2. Resets the expires_at to a new window from the current time
  3. Sets the status back to pending (if it was expired)
  4. Queues a new email with the new accept URL
  5. Optionally updates the invitation's locale

Resend is available for invitations in pending or expired status. Accepted and revoked invitations cannot be resent.


Security Validations

The system performs comprehensive validation at both invitation creation and acceptance:

At Creation Time

ValidationExceptionCode
Inviter has team.invite permissioninsufficientPermissionsINSUFFICIENT_PERMISSIONS
Email is not already a team memberalreadyMemberALREADY_MEMBER
User is not in another tenant (V1 constraint)userBelongsToAnotherTenantUSER_BELONGS_TO_ANOTHER_TENANT
No valid pending invitation for this emailalreadyInvitedALREADY_INVITED
Seat quota allows a new memberseatLimitReachedSEAT_LIMIT_REACHED

At Acceptance Time

ValidationExceptionCode
Token matches a valid invitationinvitationNotFoundINVITATION_NOT_FOUND
Invitation is not already acceptedinvitationAlreadyAcceptedINVITATION_ALREADY_ACCEPTED
Invitation is not revokedinvitationAlreadyRevokedINVITATION_REVOKED
Invitation is not expiredinvitationExpiredINVITATION_EXPIRED
User's email matches invitation emailemailMismatchEMAIL_MISMATCH
No existing account (registration path)userAlreadyExistsACCOUNT_ALREADY_EXISTS

Race Condition Protection

Invitation creation uses a cache lock to prevent duplicate invitations from concurrent requests:

backend/app/Application/Team/Actions/InviteTeamMember.php
$lock = Cache::lock("invite:{$data->tenantId}:{$data->email}", 5);

if (! $lock->get()) {
    throw TeamException::generic(__('team.exceptions.concurrent_invitation'));
}

try {
    // Validation + creation inside DB transaction
} finally {
    $lock->release();
}

The lock key is composed of the tenant ID and email address, with a 5-second timeout. This ensures that even if two admins click "invite" simultaneously for the same email, only one invitation is created.


API Endpoints

Public Endpoints (No Authentication)

These endpoints are accessible without authentication, using only the invitation token. They are rate-limited to 30 requests per minute.

MethodPathDescription
GET/api/v1/invitations/{token}View invitation details (tenant name, inviter, role, status)
POST/api/v1/invitations/{token}/accept-with-registrationCreate a new user account and accept the invitation

GET response:

{
  "data": {
    "id": "9e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
    "email": "jane@example.com",
    "role": "admin",
    "status": "pending",
    "expires_at": "2026-04-02T12:00:00.000000Z",
    "is_expired": false,
    "is_valid": true,
    "tenant": {
      "id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
      "name": "Acme Corp"
    },
    "inviter": {
      "id": 1,
      "name": "John Doe"
    },
    "created_at": "2026-03-26T12:00:00.000000Z",
    "updated_at": "2026-03-26T12:00:00.000000Z"
  }
}

Accept with registration request:

{
  "name": "Jane Smith",
  "email": "jane@example.com",
  "password": "securepassword",
  "password_confirmation": "securepassword"
}

The response includes the newly created user, the invitation, the tenant, and authentication tokens (access + refresh).

Authenticated Endpoint (No Tenant Context)

MethodPathDescription
POST/api/v1/invitations/{token}/acceptAccept invitation as the currently logged-in user

The authenticated user's email must match the invitation email. The response includes the invitation and tenant data.

Protected Endpoints (Auth + Tenant Context)

These endpoints require authentication (auth:sanctum) and tenant context (tenant.resolve, tenant.member, onboarding.complete).

MethodPathDescription
GET/api/v1/tenant/{tenantId}/team/invitationsList invitations (optionally filter with ?pending_only=true)
POST/api/v1/tenant/{tenantId}/team/invitationsCreate a new invitation
DELETE/api/v1/tenant/{tenantId}/team/invitations/{id}Revoke a pending invitation
POST/api/v1/tenant/{tenantId}/team/invitations/{id}/resendResend with a new token

Create invitation request:

{
  "email": "new-member@example.com",
  "role": "member",
  "expires_in_days": 14
}

The role field accepts any valid web-guard role name (built-in or custom) except owner. The expires_in_days field is optional (defaults to 7, max 30).


Email Notification

When an invitation is created or resent, the system queues a localized email via TeamInvitationMail:

  • The email is sent through Laravel's queue system (ShouldQueue) for reliability
  • The template includes the tenant name, inviter name, assigned role, accept URL, and expiry date
  • The accept URL format is: {FRONTEND_URL}/{locale}/invitation/accept?token={plainToken}
  • Email locale is set per invitation (defaults to en, supports fr, es, it)
  • The email is sent outside the database transaction — if the invitation record is created but the queue dispatch fails, the invitation still exists and can be resent

The notification contract (InvitationNotifierInterface) is bound to InvitationNotifier in the TeamServiceProvider, keeping the domain layer independent of mail infrastructure.

If the email queue dispatch fails, the invitation is still created in the database. Use the resend endpoint to retry delivery with a fresh token.

Error Codes

All team-related errors follow the standard API error format with a code field for programmatic handling:

CodeHTTP StatusDescription
INVITATION_NOT_FOUND404Token does not match any invitation
INVITATION_EXPIRED410Invitation past its expiry date
INVITATION_ALREADY_ACCEPTED410Invitation has already been used
INVITATION_REVOKED410Invitation was revoked by an admin
EMAIL_MISMATCH403Authenticated user's email does not match invitation
INSUFFICIENT_PERMISSIONS403User lacks the team.invite permission
ACCOUNT_ALREADY_EXISTS409Registration path but email already has an account
ALREADY_MEMBER409Invitee is already a tenant member
ALREADY_INVITED409A valid pending invitation already exists for this email
SEAT_LIMIT_REACHED422Tenant's seat quota is full
USER_BELONGS_TO_ANOTHER_TENANT409User already belongs to a different tenant (V1 constraint)
INVALID_TOKEN_FORMAT400Token is not a valid 64-character hex string

Frontend Integration

The frontend provides a complete invitation management experience through dedicated composables and components:

Composables

  • useInvitations() — Facade over the team store providing enriched invitations with isRevocable and isResendable flags, plus permission checks
  • useTeamActions() — Mutation composable for invite(), revokeInvitation(), and resendInvitation() with error code extraction
  • useInvitationAccept() — Standalone composable for the public accept page, managing a state machine (loadinginvalid / accept-button / registration-form / wrong-account)

Components

  • TeamInvitationForm — Email + role form with client-side Zod validation and error code → i18n key mapping
  • TeamInvitationList — Displays invitations with status badges, action buttons (resend/revoke), and confirmation modals
  • TeamInviteModal — Modal wrapper around the form with an optional billing notice for seat-based plans

Accept Page State Machine

The useInvitationAccept composable manages the public invitation accept flow through a state machine:

StateConditionUI
loadingInvitation loading or auth not yet initializedLoading spinner
invalidInvitation not found, expired, revoked, or acceptedError message with specific reason
accept-buttonAuthenticated user with matching email"Accept Invitation" button
wrong-accountAuthenticated but email doesn't matchWarning with logout prompt
registration-formNot authenticatedRegistration form

V1 Constraints

In V1, each user can only belong to one tenant. Both invitation creation and acceptance enforce this — a user who already belongs to a tenant cannot be invited to another. When a new user registers via invitation acceptance, no personal tenant is created for them (unlike the normal registration flow where a personal tenant is automatically provisioned).

These constraints simplify permission management and tenant isolation for the initial release. The many-to-many data model (tenant_user pivot) is already in place for future multi-tenant support.