Customer authentication
Customer accounts let people see their bookings and check out faster. They are separate from staff accounts: a customer token only opens the customer endpoints on this page, never admin endpoints.
Customer accounts are per tenant. The same email can have separate accounts at two tenants.
How it fits together
- The customer registers or logs in. Both return an access token and a refresh token.
- Your client sends
Authorization: Bearer <access_token>on the/api/v1/customers/me...endpoints. - When the access token expires (after 24 hours), the client exchanges the refresh token for a new pair.
- On logout, both tokens are revoked.
Every endpoint here shares the customers prefix, for example /api/v1/customers/login.
Response envelope
Unlike most of the API, customer endpoints wrap their result:
{
"success": true,
"data": {},
"message": "Registration successful"
}
data holds the result, and message is present on some endpoints. Errors use the usual error format.
Register
POST /api/v1/customers/register
x-tenant-id: your-tenant-id
Content-Type: application/json
{
"email": "customer@example.com",
"password": "<your-password>",
"firstName": "Aino",
"lastName": "Virtanen",
"phone": "+358401234567",
"marketingOptIn": false,
"smsOptIn": false,
"termsAccepted": true,
"privacyPolicyAccepted": true
}
| Field | Required | Description |
|---|---|---|
email | Yes | Unique within the tenant. Stored in lower case. |
password | Yes | At least 8 characters, with an upper-case letter, a lower-case letter and a number. |
termsAccepted | Yes | Must be true. |
privacyPolicyAccepted | Yes | Must be true. |
firstName, lastName | No | The customer's name. |
phone | No | Phone number, international format recommended. |
marketingOptIn | No | Marketing email consent. Default false. |
smsOptIn | No | SMS consent. Default false. |
termsVersion | No | The version of the terms the customer accepted. Default 1.0. |
The response is 201 with the tokens in data:
{
"success": true,
"data": {
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "9c2e...",
"refresh_token_expires_in": 7776000,
"customer": {
"id": "customer-id",
"email": "customer@example.com",
"firstName": "Aino",
"lastName": "Virtanen",
"emailVerified": false
}
},
"message": "Registration successful"
}
Sessiq emails a verification link. The customer is signed in straight away; emailVerified stays false until they click the link. Any failure, including an email that is already registered, returns 400 with the reason in detail.
Log in
POST /api/v1/customers/login
x-tenant-id: your-tenant-id
Content-Type: application/json
{ "email": "customer@example.com", "password": "<your-password>" }
The response is 200 with the same data as registration. Any failure returns 401: wrong email or password, a disabled account, or a locked account. After five failed attempts in a row, the account locks for 15 minutes.
Refresh the token
POST /api/v1/customers/auth/refresh
Content-Type: application/json
{ "refresh_token": "9c2e..." }
The response has the same data as login. Refresh tokens last 90 days and rotate: each refresh returns a new one and the old one stops working. An expired or reused refresh token returns 401. The customer must log in again.
Log out
POST /api/v1/customers/logout
Authorization: Bearer <access_token>
Revokes the access token and the refresh token.
Profile
| Endpoint | Description |
|---|---|
GET /api/v1/customers/me | The signed-in customer's profile. |
PUT /api/v1/customers/me | Update profile fields. |
PATCH /api/v1/customers/me/preferences | Update notification preferences. |
DELETE /api/v1/customers/me | Delete the account. |
All of them need Authorization: Bearer <access_token>.
GET /api/v1/customers/me returns:
{
"success": true,
"data": {
"id": "customer-id",
"email": "customer@example.com",
"firstName": "Aino",
"lastName": "Virtanen",
"phone": "+358401234567",
"emailVerified": true,
"locale": "fi",
"marketingOptIn": false,
"smsOptIn": false,
"termsAcceptedAt": "2026-03-01T10:00:00.000Z",
"privacyPolicyAcceptedAt": "2026-03-01T10:00:00.000Z",
"termsVersion": "1.0",
"createdAt": "2026-03-01T10:00:00.000Z"
}
}
PUT /api/v1/customers/me takes any of firstName, lastName, phone, locale, marketingOptIn and smsOptIn, and returns the updated profile. Fields you leave out are not changed. To change the email address, use Change email.
PATCH /api/v1/customers/me/preferences takes any of marketingOptIn, smsOptIn, bookingConfirmationEmail, bookingReminderEmail, cancellationNotification and preferredChannel.
DELETE /api/v1/customers/me needs the password in the body, { "password": "<your-password>" }. It deletes the account and anonymises the customer's data. It fails with 400 while the customer has active bookings.
My bookings
GET /api/v1/customers/me/bookings?status=confirmed&date_from=2026-06-01&date_to=2026-06-30
Authorization: Bearer <access_token>
All query parameters are optional. The list is not paginated.
| Parameter | Description |
|---|---|
status | Only bookings with this status. |
date_from | Only bookings starting on or after this date. |
date_to | Only bookings starting on or before this date. |
{
"success": true,
"data": {
"bookings": [
{
"id": "booking-id",
"productName": "Escape room",
"lifecycleStatus": "confirmed",
"paymentStatus": "paid",
"startsAt": "2026-06-12T15:00:00.000Z",
"endsAt": "2026-06-12T16:00:00.000Z",
"partySize": 4,
"currency": "EUR",
"grandTotal": "120.00",
"addons": []
}
]
}
}
The example is shortened. Each booking also has site, product and slot IDs, the price breakdown and the contact details. Amounts are decimal strings. Read lifecycleStatus and paymentStatus; the older status field is kept only for compatibility.
Claim a guest booking
A customer who booked without an account can attach that booking to their account afterwards:
POST /api/v1/customers/me/bookings/{bookingId}/link
Authorization: Bearer <access_token>
The booking's email must match the customer's email (403 if not). A booking already linked to another account returns 409.
Passwords
| Endpoint | Auth | Body |
|---|---|---|
POST /api/v1/customers/change-password | Bearer token | { "currentPassword": "...", "newPassword": "..." } |
POST /api/v1/customers/forgot-password | x-tenant-id | { "email": "..." } |
POST /api/v1/customers/reset-password | x-tenant-id | { "email": "...", "token": "...", "newPassword": "..." } |
POST /api/v1/customers/verify-password | Bearer token | { "password": "..." }. Returns data.valid, true or false. |
forgot-password emails a reset link and always returns success, so it can't be used to find out which emails have accounts. reset-password uses the token from that email. Use verify-password to confirm the customer's identity before a sensitive action in your UI.
Email verification
| Endpoint | Body |
|---|---|
POST /api/v1/customers/verify-email | { "email": "...", "token": "..." } |
POST /api/v1/customers/resend-verification | { "email": "..." } |
Both need x-tenant-id. The token comes from the verification email and is valid for 24 hours. resend-verification always returns success.
Change email
A change takes two steps, both with the customer's bearer token:
POST /api/v1/customers/change-emailwith{ "newEmail": "...", "password": "..." }. Sessiq emails a verification link to the new address.POST /api/v1/customers/confirm-email-changewith{ "token": "..." }from that email. The response is the updated profile.
The address changes only after step 2.
Rate limits
Every endpoint on this page has the strict limit for authentication endpoints: 5 requests per minute per client, per endpoint. See Rate limits.