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
email 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
email 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
email 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

  1. 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
      })
    });
    
  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)
  3. User Clicks Link - Frontend loads invitation details:
    const invitation = await fetch(`/public/v2/invitations/${token}`);
    if (invitation.can_be_accepted) {
      // Show registration form
    }
    
  4. 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

  1. Check Permissions: Verify user can invite to the client account
  2. Validate Emails: Use proper email validation before sending
  3. Handle Duplicates: System automatically cancels existing pending invitations
  4. Show Progress: Indicate email sending status to users
  5. Auto-refresh: Update invitation lists after actions
  6. Expiry Warnings: Show expiry dates prominently
  7. 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 email fails with invitation.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