Invitations API

Team member invitation endpoints for onboarding users to organizations with role-based access control and expiration management.

Updated Dec 29, 2025 Edit this page

Invitations API Reference

Overview

The Invitations API provides comprehensive endpoints for managing organization membership invitations. Organization owners and admins can invite users via email, and invited users can accept or decline invitations through secure token-based flows.

Invitations use SHA256-hashed tokens for security and expire after a configurable period (default: 7 days). The system prevents duplicate invitations and enforces organization member limits based on tier configuration.

Key Features

  • Email-based Invitations: Invite users by email address with role assignment
  • Token Security: SHA256-hashed invitation tokens for secure acceptance
  • Role-Based Access: Invite users as owner, admin, or member
  • Expiration Handling: Automatic expiration after 7 days
  • Duplicate Prevention: Prevents duplicate invitations to the same email
  • Member Limit Enforcement: Respects organization tier member limits
  • Invitation Management: List, cancel, and track invitation status
  • Email Link Support: Direct email link for one-click acceptance

Endpoints Summary

Method Path Description
POST /api/organizations/:org_slug/invitations Create invitation
GET /api/organizations/:org_slug/invitations List organization invitations
POST /api/organizations/:org_slug/invitations/:invitation_id Cancel invitation
GET /api/invitations List user’s pending invitations
POST /api/invitations/accept Accept invitation
POST /api/invitations/decline Decline invitation
GET /invitations/accept Email link redirect for acceptance

Organization Admin Endpoints

POST /api/organizations/:org_slug/invitations

Create a new invitation to join an organization. The invitation is sent via email (if SMTP is configured) and can be accepted by the invited user.

Authentication: Required (Organization Management JWT)

Permissions: Organization owner or admin

Path Parameters:

Parameter Type Description
org_slug string Organization slug

Request Body:

Field Type Required Description
email string Yes Email address of the user to invite
role string Yes Role to assign: owner, admin, or member

Request Headers:

Authorization: Bearer {jwt_token}
Content-Type: application/json

Example Request:

curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "newuser@example.com",
    "role": "member"
  }'

Response (200 OK):

{
  "invitation": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "org_id": "org-123",
    "email": "newuser@example.com",
    "role": "member",
    "invited_by": "user-456",
    "status": "pending",
    "token": "hashed-token-value",
    "expires_at": "2025-01-22T10:30:00Z",
    "created_at": "2025-01-15T10:30:00Z"
  },
  "inviter": {
    "id": "user-456",
    "email": "admin@acme.com",
    "created_at": "2024-01-01T00:00:00Z"
  },
  "token": "550e8400-e29b-41d4-a716-446655440000"
}

Response Fields:

Invitation Object:

  • id (string): Invitation unique identifier
  • org_id (string): Organization ID
  • email (string): Invited user’s email
  • role (string): Assigned role (owner, admin, or member)
  • invited_by (string): User ID of inviter
  • status (string): Invitation status (pending, accepted, rejected, cancelled)
  • token (string): Hashed token (for internal use)
  • expires_at (string): ISO 8601 expiration timestamp
  • created_at (string): ISO 8601 creation timestamp

Inviter Object:

  • id (string): Inviter’s user ID
  • email (string): Inviter’s email
  • created_at (string): Inviter’s account creation timestamp

Token Field:

  • token (string): Plaintext token for email links (only returned once on creation)

Error Responses:

  • 400 Bad Request: Invalid role, user already a member, or invitation already sent
    {
      "error": "User is already a member of this organization"
    }
    
    {
      "error": "Invitation already sent"
    }
    
    {
      "error": "Invalid role. Must be one of: owner, admin, member"
    }
    
  • 401 Unauthorized: Missing or invalid JWT token
  • 403 Forbidden: User is not an admin or owner
  • 404 Not Found: Organization not found

Important Notes:

  • The plaintext token is only returned once upon creation for use in email links
  • Valid roles are: owner, admin, member
  • Cannot invite users who are already members
  • Cannot create duplicate pending invitations for the same email
  • Invitations expire after 7 days by default
  • Member limit enforcement happens during invitation acceptance, not creation
  • A webhook event user.invited is published to configured webhooks

Email Integration: If SMTP is configured for the organization, an invitation email is automatically sent with:

  • Invitation details (organization name, inviter name, role)
  • Direct acceptance link: {BASE_URL}/invitations/accept?token={token}
  • Expiration information

GET /api/organizations/:org_slug/invitations

List all invitations for an organization with pagination support. Includes invitation details and inviter information.

Authentication: Required (Organization Management JWT)

Permissions: Organization owner or admin

Path Parameters:

Parameter Type Description
org_slug string Organization slug

Query Parameters:

Parameter Type Required Default Description
page integer No 1 Page number (minimum: 1)
limit integer No 50 Results per page (1-100)
status string No - Filter by status (pending, accepted, rejected, cancelled)

Request Headers:

Authorization: Bearer {jwt_token}

Example Request:

curl -X GET "https://sso.example.com/api/organizations/acme-corp/invitations?page=1&limit=20" \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Response (200 OK):

[
  {
    "invitation": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "email": "newuser@example.com",
      "role": "member",
      "status": "pending",
      "token": "hashed-token",
      "expires_at": "2025-01-22T10:30:00Z",
      "created_at": "2025-01-15T10:30:00Z"
    },
    "inviter": {
      "id": "user-456",
      "email": "admin@acme.com",
      "created_at": "2024-01-01T00:00:00Z"
    }
  },
  {
    "invitation": {
      "id": "660e8400-e29b-41d4-a716-446655440111",
      "email": "another@example.com",
      "role": "admin",
      "status": "accepted",
      "token": "hashed-token",
      "expires_at": "2025-01-20T08:15:00Z",
      "created_at": "2025-01-13T08:15:00Z"
    },
    "inviter": {
      "id": "user-789",
      "email": "owner@acme.com",
      "created_at": "2023-12-01T00:00:00Z"
    }
  }
]

Response: Array of invitation objects with inviter details

Error Responses:

  • 401 Unauthorized: Missing or invalid JWT token
  • 403 Forbidden: User is not an admin or owner
  • 404 Not Found: Organization not found

Pagination:

  • Default limit: 50 invitations per page
  • Maximum limit: 100 invitations per page
  • Page numbers start at 1
  • Results are ordered by creation date (newest first)

POST /api/organizations/:org_slug/invitations/:invitation_id

Cancel a pending invitation. Only pending invitations can be cancelled.

Authentication: Required (Organization Management JWT)

Permissions: Organization owner or admin

Path Parameters:

Parameter Type Description
org_slug string Organization slug
invitation_id string Invitation ID

Query Parameters: None

Request Body: None

Request Headers:

Authorization: Bearer {jwt_token}

Example Request:

curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Response: 200 OK with empty JSON object {}

Error Responses:

  • 401 Unauthorized: Missing or invalid JWT token
  • 403 Forbidden: User is not an admin or owner
  • 404 Not Found: Invitation not found or already processed
    {
      "error": "Invitation not found or already processed"
    }
    

Important Notes:

  • Only pending invitations can be cancelled
  • Accepted, rejected, or expired invitations cannot be cancelled
  • Cancelled invitations cannot be reactivated (create a new invitation instead)
  • A webhook event invitation.revoked is published to configured webhooks

User Endpoints

GET /api/invitations

List all pending invitations for the authenticated user across all organizations.

Authentication: Required (any valid JWT)

Permissions: Any authenticated user

Query Parameters: None

Request Headers:

Authorization: Bearer {jwt_token}

Example Request:

curl -X GET https://sso.example.com/api/invitations \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Response (200 OK):

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@example.com",
    "role": "member",
    "token": "hashed-token",
    "expires_at": "2025-01-22T10:30:00Z",
    "created_at": "2025-01-15T10:30:00Z",
    "organization": {
      "slug": "acme-corp",
      "name": "Acme Corporation"
    }
  },
  {
    "id": "660e8400-e29b-41d4-a716-446655440111",
    "email": "user@example.com",
    "role": "admin",
    "token": "hashed-token",
    "expires_at": "2025-01-23T14:20:00Z",
    "created_at": "2025-01-16T14:20:00Z",
    "organization": {
      "slug": "techstart",
      "name": "TechStart Inc"
    }
  }
]

Response Fields:

  • Array of invitation objects with organization details
  • Only pending (not yet accepted/rejected/cancelled) invitations are returned
  • Only invitations for the authenticated user’s email address are returned
  • Expired invitations are excluded from results

Error Responses:

  • 401 Unauthorized: Missing or invalid JWT token

Use Cases:

  • Display pending invitations in user dashboard
  • Show organization join requests
  • Allow users to accept/decline multiple invitations

POST /api/invitations/accept

Accept a pending invitation using the invitation token. Creates a membership and optionally creates a user account if it doesn’t exist.

Authentication: Public (no authentication required)

Permissions: None (token-based)

Request Body:

Field Type Required Description
token string Yes Invitation token (UUID format)

Request Headers:

Content-Type: application/json

Example Request:

curl -X POST https://sso.example.com/api/invitations/accept \
  -H "Content-Type: application/json" \
  -d '{
    "token": "550e8400-e29b-41d4-a716-446655440000"
  }'

Response: 200 OK with empty JSON object {}

Error Responses:

  • 400 Bad Request: Invalid, expired, or already processed token
    {
      "error": "Invitation has expired"
    }
    
    {
      "error": "Invitation not found or already processed"
    }
    
    {
      "error": "Team limit reached"
    }
    
  • 500 Internal Server Error: Database error

Acceptance Flow:

  1. Token is hashed using SHA256 and looked up in database
  2. Invitation validation:
    • Must be in pending status
    • Must not be expired
    • Must pass team member limit check
  3. User account is found or created using the invitation email
  4. Membership is created with the specified role
  5. Invitation status is updated to accepted
  6. A webhook event invitation.accepted is published

Team Limit Enforcement:

  • Member count is checked against organization tier limits
  • Default free tier limit: 5 members
  • Organization-specific overrides: max_users field
  • Tier-specific limits: OrganizationTier.default_max_users
  • Invitation is rejected if limit is reached

User Creation:

  • If user with invitation email doesn’t exist, a new user account is created
  • User can then log in using OAuth or password (if configured)
  • User inherits the role specified in the invitation

Important Notes:

  • Token can only be used once
  • Expired invitations cannot be accepted
  • Team member limits are enforced at acceptance time
  • Transaction ensures atomic membership creation

POST /api/invitations/decline

Decline a pending invitation using the invitation token.

Authentication: Public (no authentication required)

Permissions: None (token-based)

Request Body:

Field Type Required Description
token string Yes Invitation token (UUID format)

Request Headers:

Content-Type: application/json

Example Request:

curl -X POST https://sso.example.com/api/invitations/decline \
  -H "Content-Type: application/json" \
  -d '{
    "token": "550e8400-e29b-41d4-a716-446655440000"
  }'

Response: 200 OK with empty JSON object {}

Error Responses:

  • 400 Bad Request: Invalid or already processed token
    {
      "error": "Invitation not found or already processed"
    }
    
    {
      "error": "Invitation has expired"
    }
    
  • 500 Internal Server Error: Database error

Decline Flow:

  1. Token is hashed using SHA256 and looked up in database
  2. Invitation validation:
    • Must be in pending status
    • Expiration is checked but allows declining expired invitations
  3. Invitation status is updated to rejected
  4. A webhook event invitation.declined is published

Important Notes:

  • Token can only be used once
  • Declining an invitation is permanent (cannot be undone)
  • Expired invitations can still be declined
  • No user account or membership is created

GET /invitations/accept

Email link redirect endpoint for one-click invitation acceptance. This endpoint handles invitation acceptance via email links.

Authentication: Public (no authentication required)

Permissions: None (token-based)

Query Parameters:

Parameter Type Required Description
token string Yes Invitation token from email

Example Request:

# User clicks link in email:
https://sso.example.com/invitations/accept?token=550e8400-e29b-41d4-a716-446655440000

Response: 301 Permanent Redirect to /invitations/accept?token={token}

Purpose: This is a convenience endpoint for email links. It redirects to the frontend application’s invitation acceptance page, which then calls the POST /api/invitations/accept endpoint with the token.

Flow:

  1. User receives invitation email with link
  2. User clicks link: GET /invitations/accept?token=ABC123
  3. Backend redirects to: /invitations/accept?token=ABC123 (frontend route)
  4. Frontend displays invitation details and acceptance UI
  5. User confirms acceptance
  6. Frontend calls POST /api/invitations/accept with token
  7. Backend processes acceptance and creates membership

Integration:

  • Configure your frontend router to handle the /invitations/accept route
  • Extract token from query parameters
  • Display invitation details (organization name, role, etc.)
  • Provide “Accept” and “Decline” buttons
  • Call the appropriate API endpoint based on user action

Invitation Lifecycle

Status Flow

pending � accepted (successful acceptance)
pending � rejected (user declined)
pending � cancelled (admin cancelled)
pending � expired (expiration date passed)

Status Descriptions

pending:

  • Initial state when invitation is created
  • User can accept or decline
  • Admin can cancel
  • Automatically expires after 7 days

accepted:

  • User has accepted the invitation
  • Membership has been created
  • Cannot be undone (must remove member instead)

rejected:

  • User has declined the invitation
  • No membership created
  • Cannot be undone (must create new invitation)

cancelled:

  • Admin has cancelled the invitation
  • User can no longer accept
  • Cannot be undone (must create new invitation)

expired:

  • Invitation expiration date has passed
  • User can no longer accept (but can decline)
  • Must create new invitation to re-invite

Security

Token Hashing

Invitation tokens are hashed using SHA256 before storage for security:

Plaintext Token (stored in email): 550e8400-e29b-41d4-a716-446655440000
Hashed Token (stored in DB):        SHA256(plaintext_token)

Security Benefits:

  • Database compromise doesn’t expose usable tokens
  • Tokens cannot be used if database is leaked
  • One-way hashing prevents token recovery

Token Generation:

// Plaintext token (shown to user once)
const token = crypto.randomUUID();

// Hash for storage
const tokenHash = crypto
  .createHash('sha256')
  .update(token)
  .digest('hex');

Token Verification:

// Hash provided token
const providedHash = crypto
  .createHash('sha256')
  .update(providedToken)
  .digest('hex');

// Compare with stored hash
if (providedHash === storedHash) {
  // Valid token
}

Expiration

Default Expiration: 7 days from creation

Expiration Constant:

const INVITATION_EXPIRY_DAYS: i64 = 7;

Expiration Calculation:

let expires_at = Utc::now() + ChronoDuration::days(INVITATION_EXPIRY_DAYS);

Expiration Check:

let expires_at = chrono::DateTime::parse_from_rfc3339(&invitation.expires_at)
    .ok()
    .map(|dt| dt.with_timezone(&Utc))
    .unwrap_or_else(Utc::now);

if expires_at < Utc::now() {
    return Err(AppError::BadRequest("Invitation has expired".to_string()));
}

Best Practices

For Organization Admins:

  • Only invite users with legitimate need for access
  • Assign the minimum required role (principle of least privilege)
  • Review and cancel unused pending invitations regularly
  • Monitor invitation acceptance rates
  • Use descriptive organization names for clarity in emails

For Security:

  • Never share invitation tokens in public channels
  • Tokens should only be sent via secure email
  • Implement rate limiting on invitation endpoints
  • Monitor for suspicious invitation patterns
  • Set up alerts for failed acceptance attempts

For Integration:

  • Always verify invitation tokens before acceptance
  • Handle expired invitations gracefully in UI
  • Provide clear error messages for invalid tokens
  • Log invitation events for audit trails
  • Test email delivery before production use

Error Responses

All endpoints follow the standard error format:

400 Bad Request

{
  "error": "User is already a member of this organization",
  "error_code": "BAD_REQUEST",
  "timestamp": "2025-01-15T10:30:00Z"
}

401 Unauthorized

{
  "error": "Missing or invalid Authorization header",
  "error_code": "UNAUTHORIZED",
  "timestamp": "2025-01-15T10:30:00Z"
}

403 Forbidden

{
  "error": "Organization admin or owner role required",
  "error_code": "FORBIDDEN",
  "timestamp": "2025-01-15T10:30:00Z"
}

404 Not Found

{
  "error": "Invitation not found or already processed",
  "error_code": "NOT_FOUND",
  "timestamp": "2025-01-15T10:30:00Z"
}

Complete Workflow Example

Admin Invites User

# 1. Admin creates invitation
curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations \
  -H "Authorization: Bearer {admin_jwt}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "newuser@example.com",
    "role": "member"
  }'

# Response includes plaintext token for email
{
  "invitation": { ... },
  "inviter": { ... },
  "token": "550e8400-e29b-41d4-a716-446655440000"
}

User Receives Email

Email contains:

  • Invitation details (organization name, role, inviter)
  • Direct acceptance link: https://sso.example.com/invitations/accept?token=550e8400-e29b-41d4-a716-446655440000
  • Expiration date

User Accepts Invitation

# Option 1: Click email link (redirects to frontend)
# User clicks: https://sso.example.com/invitations/accept?token=ABC123
# Frontend displays invitation details
# Frontend calls accept API

# Option 2: Direct API call
curl -X POST https://sso.example.com/api/invitations/accept \
  -H "Content-Type: application/json" \
  -d '{
    "token": "550e8400-e29b-41d4-a716-446655440000"
  }'

# Success: User is now a member

Admin Monitors Invitations

# List all invitations
curl -X GET https://sso.example.com/api/organizations/acme-corp/invitations \
  -H "Authorization: Bearer {admin_jwt}"

# Cancel unused invitation
curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations/{invitation_id} \
  -H "Authorization: Bearer {admin_jwt}"

Integration Examples

Frontend Invitation Acceptance Page

// React example
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';

function AcceptInvitationPage() {
  const [searchParams] = useSearchParams();
  const token = searchParams.get('token');
  const [invitation, setInvitation] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Fetch invitation details (you'd need to create this endpoint)
    // or decode from JWT token if you implement that
  }, [token]);

  const handleAccept = async () => {
    try {
      const response = await fetch('/api/invitations/accept', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      });

      if (response.ok) {
        // Redirect to login or dashboard
        window.location.href = '/dashboard';
      } else {
        const data = await response.json();
        setError(data.error);
      }
    } catch (err) {
      setError('Failed to accept invitation');
    }
  };

  const handleDecline = async () => {
    try {
      const response = await fetch('/api/invitations/decline', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      });

      if (response.ok) {
        window.location.href = '/';
      }
    } catch (err) {
      setError('Failed to decline invitation');
    }
  };

  return (
    <div>
      <h1>Invitation to Join Organization</h1>
      {error && <div className="error">{error}</div>}
      {invitation && (
        <div>
          <p>You've been invited to join <strong>{invitation.organization.name}</strong></p>
          <p>Role: {invitation.role}</p>
          <button onClick={handleAccept}>Accept</button>
          <button onClick={handleDecline}>Decline</button>
        </div>
      )}
    </div>
  );
}

Email Template

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Invitation to Join {{organization_name}}</title>
</head>
<body>
  <h1>You're Invited!</h1>

  <p>{{inviter_name}} ({{inviter_email}}) has invited you to join <strong>{{organization_name}}</strong> as a {{role}}.</p>

  <p>
    <a href="{{base_url}}/invitations/accept?token={{token}}"
       style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
      Accept Invitation
    </a>
  </p>

  <p>Or copy and paste this link into your browser:</p>
  <p>{{base_url}}/invitations/accept?token={{token}}</p>

  <p><small>This invitation expires on {{expires_at}}.</small></p>

  <p><small>If you don't want to join this organization, you can safely ignore this email.</small></p>
</body>
</html>

Admin Dashboard Component

// Vue.js example
<template>
  <div class="invitations-manager">
    <h2>Pending Invitations</h2>

    <form @submit.prevent="createInvitation">
      <input v-model="newInvite.email" placeholder="Email" required>
      <select v-model="newInvite.role" required>
        <option value="member">Member</option>
        <option value="admin">Admin</option>
        <option value="owner">Owner</option>
      </select>
      <button type="submit">Send Invitation</button>
    </form>

    <table>
      <thead>
        <tr>
          <th>Email</th>
          <th>Role</th>
          <th>Status</th>
          <th>Expires</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="invitation in invitations" :key="invitation.invitation.id">
          <td>{{ invitation.invitation.email }}</td>
          <td>{{ invitation.invitation.role }}</td>
          <td>{{ invitation.invitation.status }}</td>
          <td>{{ formatDate(invitation.invitation.expires_at) }}</td>
          <td>
            <button
              v-if="invitation.invitation.status === 'pending'"
              @click="cancelInvitation(invitation.invitation.id)">
              Cancel
            </button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      invitations: [],
      newInvite: { email: '', role: 'member' }
    };
  },
  async mounted() {
    await this.loadInvitations();
  },
  methods: {
    async loadInvitations() {
      const response = await fetch(
        `/api/organizations/${this.orgSlug}/invitations`,
        { headers: { Authorization: `Bearer ${this.token}` } }
      );
      this.invitations = await response.json();
    },
    async createInvitation() {
      const response = await fetch(
        `/api/organizations/${this.orgSlug}/invitations`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${this.token}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(this.newInvite)
        }
      );

      if (response.ok) {
        this.newInvite = { email: '', role: 'member' };
        await this.loadInvitations();
      }
    },
    async cancelInvitation(invitationId) {
      const response = await fetch(
        `/api/organizations/${this.orgSlug}/invitations/${invitationId}`,
        {
          method: 'POST',
          headers: { Authorization: `Bearer ${this.token}` }
        }
      );

      if (response.ok) {
        await this.loadInvitations();
      }
    },
    formatDate(dateStr) {
      return new Date(dateStr).toLocaleDateString();
    }
  }
};
</script>