Invitations
Invitations are used to invite new users to join a client account (company) in Snapbooks. The invitation system handles the complete workflow from sending invitation emails to user account creation and role assignment.
Key Features
- Multi-language Support: Automatic email templates in English and Norwegian
- User-friendly Role Names: Displays “Accountant” instead of cryptic codes like “AA”
- Secure Token System: Each invitation has a unique, secure token with 7-day expiration
- Complete Workflow: Email sending, user registration, and account setup in one flow
- Permission Control: Role-based access with proper validation
Endpoints
| Method | Endpoint | Description | Authentication |
|---|---|---|---|
| POST | /api/v2/invitations | Create a new invitation | Required |
| GET | /api/v2/invitations | List invitations | Required |
| GET | /api/v2/invitations/{id} | Get invitation details | Required |
| DELETE | /api/v2/invitations/{id} | Cancel an invitation | Required |
| GET | /public/v2/invitations/{token} | Get invitation by token | Public |
| POST | /public/v2/invitations/{token}/accept | Accept invitation | Public |
Authenticated Endpoints
Create Invitation
POST /api/v2/invitations
Creates a new invitation and optionally sends an email to the invited user.
Request Body
{
"email": "john@example.com",
"client_account_id": 123,
"role_id": 2
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address of the user to invite | |
| client_account_id | integer | Yes | ID of the client account to invite user to |
| role_id | integer | Yes | ID of the role to assign (2=AA, 3=CA, 4=BK, 5=EM) |
Headers
| Header | Required | Description | Example |
|---|---|---|---|
| Accept-Language | No | Language preference for email template | "no" (Norwegian) or "en" (English) |
Role Codes & Display Names
| Role ID | Code | English | Norwegian | Account Type | Notes |
|---|---|---|---|---|---|
| 2 | AA | Accountant | Autorisert regnskapsfører | Provider only | Full accounting access for accounting firms |
| 3 | CA | Client Account Owner | Kontoeier | All accounts | Account owner with admin rights |
| 4 | BK | Bookkeeper | Regnskapsfører | All accounts | Bookkeeping tasks |
| 5 | EM | Employee | Ansatt | All accounts | Basic employee access |
Provider Accounts: Companies with industrial classification 69.2xx (accounting/auditing firms)
Regular Accounts: All other company types
Response (201 Created)
client_account, role, and invited_by are only included when requested via ?with=client_account,role,invited_by; shown below for illustration.
{
"id": 456,
"created_at": "2023-10-15T14:30:00Z",
"updated_at": "2023-10-15T14:30:00Z",
"email": "john@example.com",
"client_account_id": 123,
"role_id": 2,
"status": "PENDING",
"expires_at": "2023-10-22T14:30:00Z",
"invited_by_id": 789,
"accepted_by_id": null,
"accepted_at": null,
"is_expired": false,
"is_pending": true,
"can_be_accepted": true,
"client_account": {
"id": 123,
"display_name": "ACME Corp AS",
"unique_name": "acme-corp"
},
"role": {
"id": 2,
"name": "AA"
},
"invited_by": {
"id": 789,
"first_name": "Admin",
"last_name": "User",
"email": "admin@acme.com"
}
}
List Invitations
GET /api/v2/invitations
Lists invitations with optional filtering and pagination.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_account_id | integer | No | Filter by client account ID. When omitted, the list is scoped to every client account you can access; an account you cannot access returns 403 |
| status | string | No | Filter by status (PENDING, ACCEPTED, EXPIRED, CANCELLED) |
| page | integer | No | Page number (default: 1) |
| per_page | integer | No | Number of items per page (default: 100) |
Example Request
GET /api/v2/invitations?client_account_id=123&status=PENDING&per_page=50
Response (200 OK)
client_account and role are only included when requested via ?with=client_account,role; shown below for illustration.
{
"data": [
{
"id": 456,
"email": "john@example.com",
"status": "PENDING",
"expires_at": "2023-10-22T14:30:00Z",
"is_expired": false,
"can_be_accepted": true,
"client_account": {
"id": 123,
"display_name": "ACME Corp AS"
},
"role": {
"id": 2,
"name": "AA"
}
}
],
"meta": {
"records": 1,
"per_page": 50,
"page": 1,
"pages": 1
}
}
Get Invitation
GET /api/v2/invitations/{id}
Gets detailed information about a specific invitation.
The relations below are only included when requested via ?with=client_account,role,invited_by,accepted_by, and a relation with nothing to show (an invitation nobody has accepted) is left out of the response rather than returned as null.
Response (200 OK)
{
"id": 456,
"created_at": "2023-10-15T14:30:00Z",
"email": "john@example.com",
"status": "PENDING",
"expires_at": "2023-10-22T14:30:00Z",
"is_expired": false,
"is_pending": true,
"can_be_accepted": true,
"client_account": {
"id": 123,
"display_name": "ACME Corp AS",
"unique_name": "acme-corp"
},
"role": {
"id": 2,
"name": "AA"
},
"invited_by": {
"id": 789,
"first_name": "Admin",
"last_name": "User",
"email": "admin@acme.com"
}
}
Cancel Invitation
DELETE /api/v2/invitations/{id}
Cancels a pending invitation. Only the user who sent it, or someone with an active AA or CA role on the target client account, may cancel it (invitation.not_authorized_to_cancel otherwise). An already accepted invitation cannot be cancelled (invitation.cannot_cancel_accepted).
Response (200 OK)
Returns the updated invitation with status set to “CANCELLED”.
Public Endpoints
These endpoints do not require authentication and are used in the invitation acceptance flow.
Get Invitation by Token
GET /public/v2/invitations/{token}
Gets invitation details using the secure token from the email link.
Response (200 OK)
{
"id": 456,
"email": "john@example.com",
"status": "PENDING",
"expires_at": "2023-10-22T14:30:00Z",
"is_expired": false,
"can_be_accepted": true,
"client_account": {
"id": 123,
"display_name": "ACME Corp AS"
},
"role": {
"id": 2,
"name": "AA"
}
}
Accept Invitation
POST /public/v2/invitations/{token}/accept
Accepts an invitation. This is the final step in the invitation workflow. If no user exists with the invited email, a new account is created. If a user already exists with that email, the existing account is reused instead — password is verified against that account’s current password rather than used to set one.
Request Body
{
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"password": "SecurePassword123!"
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| first_name | string | Yes | User’s first name. Ignored when reusing an existing account |
| last_name | string | Yes | User’s last name. Ignored when reusing an existing account |
| string | Yes | Must match the invited email address (case-insensitive) — the invitation cannot be accepted under a different email | |
| password | string | Yes | For a new account: the password to set (must meet security requirements). For an existing account: the account’s current password, verified before acceptance proceeds |
Response (200 OK)
{
"message": "Invitation accepted successfully",
"invitation": {
"id": 456,
"status": "ACCEPTED",
"accepted_at": "2023-10-16T10:15:00Z",
"client_account": {
"id": 123,
"display_name": "ACME Corp AS"
},
"role": {
"id": 2,
"name": "AA"
}
},
"user": {
"id": 999,
"created_at": "2023-10-16T10:15:00Z",
"first_name": "John",
"last_name": "Doe",
"profile_image_url": null,
"last_login": null,
"identities": [
{
"type": "email",
"value": "john@example.com",
"verified": true
}
]
},
"tokens": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer"
}
}
The refresh token is set as an httpOnly refresh_token cookie, not returned in the JSON body.
Data Model
Invitation Attributes
| Attribute | Type | Description |
|---|---|---|
| id | integer | The unique ID of the invitation |
| created_at | datetime | When the invitation was created |
| updated_at | datetime | When the invitation was last updated |
| string | The email address that was invited | |
| client_account_id | integer | The ID of the client account |
| role_id | integer | The ID of the role to assign |
| status | string | Current status (PENDING, ACCEPTED, EXPIRED, CANCELLED) |
| expires_at | datetime | When the invitation expires (7 days from creation) |
| invited_by_id | integer | The ID of the user who sent the invitation |
| accepted_by_id | integer | The ID of the user who accepted (null if not accepted) |
| accepted_at | datetime | When the invitation was accepted (null if not accepted) |
Computed Properties
| Property | Type | Description |
|---|---|---|
| is_expired | boolean | Whether the invitation has passed its expiry date |
| is_pending | boolean | Whether the invitation status is PENDING |
| can_be_accepted | boolean | Whether the invitation can currently be accepted |
Relationships
| Relationship | Type | Description |
|---|---|---|
| client_account | ClientAccount | The client account the user is invited to |
| role | Role | The role that will be assigned |
| invited_by | User | The user who sent the invitation |
| accepted_by | User | The user who accepted the invitation |
Frontend Integration Guide
Complete Invitation Flow
- Admin Sends Invitation
const response = await fetch('/api/v2/invitations', { method: 'POST', headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json', 'Accept-Language': 'no' // Norwegian email template }, body: JSON.stringify({ email: 'newuser@company.com', client_account_id: 123, role_id: 2 }) }); - User Receives Email with beautiful template containing:
- Company name and role (displayed as “Regnskapsfører” not “AA”)
- Invitation link with secure token
- Expiration date (7 days)
- User Clicks Link - Frontend loads invitation details:
const invitation = await fetch(`/public/v2/invitations/${token}`); if (invitation.can_be_accepted) { // Show registration form } - User Accepts - Creates account and logs in:
const result = await fetch(`/public/v2/invitations/${token}/accept`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name: 'John', last_name: 'Doe', email: 'john@example.com', password: 'SecurePass123!' }) }); // User is now logged in with tokens localStorage.setItem('access_token', result.tokens.access_token);
Team Management UI Examples
Invitation List Component:
// Load invitations for a client account
const invitations = await fetch('/api/v2/invitations?client_account_id=123&status=PENDING');
// Display with user-friendly role names
invitations.data.forEach(invitation => {
const roleDisplay = getRoleDisplayName(invitation.role.name, 'EN');
console.log(`${invitation.email} - ${roleDisplay}`);
});
function getRoleDisplayName(code, lang = 'EN') {
const roles = {
EN: { AA: 'Accountant', CA: 'Client Owner', BK: 'Bookkeeper', EM: 'Employee' },
NO: { AA: 'Autorisert regnskapsfører', CA: 'Kontoeier', BK: 'Regnskapsfører', EM: 'Ansatt' }
};
return roles[lang][code] || code;
}
Cancel Invitation:
const cancelInvitation = async (invitationId) => {
const response = await fetch(`/api/v2/invitations/${invitationId}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
if (response.ok) {
// Remove from UI or refresh list
refreshInvitationList();
}
};
Error Handling
Errors use the standard v2 envelope ({"error": {"code", "message", "request_id"}}) — see Error Handling. Branch on code, not on the status alone: several of the codes below share a status.
Creating an invitation
| Code | Status | Description |
|---|---|---|
common.validation_error |
400 | Missing body, or a missing email / client_account_id / role_id |
invitation.invalid_email |
400 | The email address is not valid |
invitation.sa_role_not_assignable |
400 | The System Administrator role cannot be handed out through an invitation |
invitation.aa_role_requires_provider_account |
400 | The AA role is only available on provider client accounts |
common.forbidden |
403 | No access to the client account named in the body |
invitation.not_authorized_to_invite |
403 | Only Accountants (AA) and Client Account Owners (CA) can send invitations |
invitation.client_account_not_found |
404 | The client account does not exist |
invitation.role_not_found |
404 | The role does not exist |
invitation.already_has_access |
409 | The invited email already has active access to this client account. An existing user without access to this account can still be invited |
Reading and cancelling
| Code | Status | Description |
|---|---|---|
common.forbidden |
403 | No access to the invitation’s client account |
invitation.not_authorized_to_cancel |
403 | Not the inviter, and no active AA / CA role on the account |
common.not_found |
404 | No invitation with that id, or the token is unknown |
invitation.not_found |
404 | The invitation disappeared between the access check and the cancel |
invitation.cannot_cancel_accepted |
409 | The invitation has already been accepted |
Accepting an invitation
| Code | Status | Description |
|---|---|---|
common.validation_error |
400 | Missing body, or a missing first_name / last_name / email / password |
invitation.invalid_email |
400 | The submitted email address is not valid |
invitation.invalid_password |
400 | The invited email belongs to an existing account and password did not match it. 400 rather than 401, so the web app does not read it as an expired session |
invitation.weak_password |
400 | The new account’s password does not meet the password requirements |
invitation.email_mismatch |
403 | The submitted email is not the invited address |
invitation.contract_not_approved |
403 | The invitation hangs off an accountant’s engagement request the client account has not approved yet |
invitation.invalid_token |
404 | The token is unknown |
invitation.expired |
410 | Past the expiration date |
invitation.already_accepted |
409 | The invitation has already been accepted |
invitation.cancelled |
409 | The invitation was cancelled |
invitation.cannot_be_accepted |
409 | The invitation is in some other state that cannot be accepted |
Example Error Response:
{
"error": {
"code": "invitation.expired",
"message": "Invitation has expired",
"request_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
}
Multi-language Support
The system automatically selects email templates based on the language parameter:
- English (
"EN"): Clean, professional invitation emails - Norwegian (
"NO", "NB", "NN"): Localized Norwegian content with proper role translations
Templates include company branding with the distinctive peach color (rgb(255, 209, 190)) and responsive design.
Best Practices
- Check Permissions: Verify user can invite to the client account
- Validate Emails: Use proper email validation before sending
- Handle Duplicates: System automatically cancels existing pending invitations
- Show Progress: Indicate email sending status to users
- Auto-refresh: Update invitation lists after actions
- Expiry Warnings: Show expiry dates prominently
- Role Clarity: Always display user-friendly role names
Notes
- Invitations expire after 7 days and cannot be extended
- An invitation can only be accepted under the address it was sent to; submitting a different
emailfails withinvitation.email_mismatch(the comparison is case-insensitive) - The system sends both HTML and plain text email versions
- Email sending failures don’t block invitation creation (graceful degradation)
- All dates are returned in ISO 8601 format with UTC timezone
- Role assignments become active immediately upon acceptance