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 identifierorg_id(string): Organization IDemail(string): Invited user’s emailrole(string): Assigned role (owner, admin, or member)invited_by(string): User ID of inviterstatus(string): Invitation status (pending, accepted, rejected, cancelled)token(string): Hashed token (for internal use)expires_at(string): ISO 8601 expiration timestampcreated_at(string): ISO 8601 creation timestamp
Inviter Object:
id(string): Inviter’s user IDemail(string): Inviter’s emailcreated_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 token403 Forbidden: User is not an admin or owner404 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.invitedis 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 token403 Forbidden: User is not an admin or owner404 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 token403 Forbidden: User is not an admin or owner404 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.revokedis 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:
- Token is hashed using SHA256 and looked up in database
- Invitation validation:
- Must be in
pendingstatus - Must not be expired
- Must pass team member limit check
- Must be in
- User account is found or created using the invitation email
- Membership is created with the specified role
- Invitation status is updated to
accepted - A webhook event
invitation.acceptedis published
Team Limit Enforcement:
- Member count is checked against organization tier limits
- Default free tier limit: 5 members
- Organization-specific overrides:
max_usersfield - 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:
- Token is hashed using SHA256 and looked up in database
- Invitation validation:
- Must be in
pendingstatus - Expiration is checked but allows declining expired invitations
- Must be in
- Invitation status is updated to
rejected - A webhook event
invitation.declinedis 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:
- User receives invitation email with link
- User clicks link:
GET /invitations/accept?token=ABC123 - Backend redirects to:
/invitations/accept?token=ABC123(frontend route) - Frontend displays invitation details and acceptance UI
- User confirms acceptance
- Frontend calls
POST /api/invitations/acceptwith token - Backend processes acceptance and creates membership
Integration:
- Configure your frontend router to handle the
/invitations/acceptroute - 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>