API Reference
This feature is currently being tested and isn't available to all salons yet. Want to take part? Get in touch.
This page is the technical reference for developers building an integration against the Salonnare API. It assumes you already know what an API key and a webhook are -- if you're looking for the plain-language explanation and the screens to create them, see API & Webhooks.
Every claim below is taken directly from the Salonnare server source. Where the code and this page ever disagree, trust a fresh look at the code -- this document can go stale, the code is what actually runs.
Authentication
Send your API key on every request in the X-API-Key header:
curl https://<your-salon>.salonnare.com/api/bookings \
-H "X-API-Key: sk_live_a1b2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Key format
A key looks like sk_live_<4 hex characters>_<48 hex characters>, for example:
sk_live_a1b2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Only the first 12 characters (sk_live_ plus the 4-character segment) are ever stored or shown again after creation. The remaining 48 characters are the actual secret. Salonnare stores a SHA-256 hash of the full key, never the key itself.
Getting a key
Keys and webhooks are managed by a salon admin under Settings -> API & Webhooks in the app. When you create a key you choose one of three permission levels:
| Permission | What it allows today |
|---|---|
read (default) | GET/HEAD/OPTIONS only. Any other method on any endpoint returns 403. |
write | Same as read in practice: no endpoint currently accepts a write request through an API key (see Read endpoints below -- all six routes are GET). |
full | Same as read/write today, for the same reason. |
In other words: write access does not exist yet, regardless of which permission level a key has. Don't build an integration that expects to create or change bookings, clients, services or staff through the API -- every route that accepts an API key is read-only, and a request that tries anything other than GET against any endpoint gets rejected before it reaches route logic.
The full key is shown exactly once, in the response of the create-key call, at the moment you create it. Store it immediately (a secrets manager, not a chat message or a spreadsheet) -- Salonnare cannot show it to you again. If you lose it, revoke it and create a new one.
Which salon a key talks to
The key itself determines the salon (tenant) -- not the hostname you call. You can send requests to your own salon's subdomain (https://<your-salon>.salonnare.com/api/...), which is the recommended, unambiguous way to do it, but the salon in the response is always the one the key belongs to, independent of which valid Salonnare host you happen to hit.
Authentication errors
| Situation | Status | Body |
|---|---|---|
No X-API-Key header and no Authorization: Bearer token | 401 | {"error": "Token ontbreekt.", "code": "unauthorized"} |
| Unknown, malformed or expired key | 401 | {"error": "Ongeldige API key"} |
| The key's salon is not active (e.g. suspended) | 403 | {"error": "Tenant niet actief"} |
Non-GET/HEAD/OPTIONS request with a read key | 403 | {"error": "Deze API-sleutel heeft alleen leesrechten."} |
Most error messages shown in this document are fixed Dutch strings and stay Dutch no matter what you send -- authentication errors from the API-key layer in particular are never translated. A smaller set of errors (the ones with an error_key field, like the not-found examples above) do follow an Accept-Language header on your request, for the 5 languages Salonnare supports (nl, en, de, fr, es). Don't rely on either behaviour: branch your integration on the HTTP status code (and code/error_key where present), never on the message text. Bodies always include error; several routes add code, and a few add error_key/error_params on top of that -- parse leniently and don't assume an exact, fixed key set.
Read endpoints
An API key grants read access to exactly six endpoints. No other route accepts an API key, no matter which permission level the key has:
| Method | Path | Returns |
|---|---|---|
GET | /api/bookings | List of appointments |
GET | /api/bookings/:id | One appointment |
GET | /api/crm/clients | List of clients |
GET | /api/crm/clients/:id | One client |
GET | /api/services | List of services (treatments) |
GET | /api/staff | List of staff members |
Unlike the rest of the Salonnare admin app, these six routes do not return the raw database row to an API key. Each object type goes through a fixed shaping function -- toApiClient, toApiStaff, toApiBooking, toApiService -- that only ever emits a hardcoded, versioned field list, in snake_case. Nothing outside that list is ever included, no matter what else the underlying record contains or gains in the future. Two of the four shapes are intentionally identical to their webhook counterpart, field for field: toApiClient matches the client.created/client.updated payload, and toApiBooking matches the booking payload including its services array (both use the same allowlist for the service rows). If you already parse Salonnare webhooks, the client and booking REST responses need no separate parser.
This filtering applies only to API-key traffic. A salon's own logged-in staff, calling the exact same URLs with a Bearer token instead of an X-API-Key header, still get the full, unfiltered record the app itself relies on (which does include internal fields these shapers deliberately drop). The difference is purely the access channel, not the endpoint or the data. This document only describes the X-API-Key shape -- that's the one your integration will see.
GET /api/bookings
Query parameters (all optional):
| Param | Format | Effect |
|---|---|---|
from, to | YYYY-MM-DD | Half-open date range on the appointment start time. |
date | YYYY-MM-DD | A single day (same effect as from=to=that day). |
source | string | Filter on how the booking was created (manual, online, google_reserve, ...). |
location_id | integer | Filter to one location. |
client_id | integer | Filter to one client's appointments. |
page | integer | Opt in to pagination (see below). Without it you get the full array. |
limit | integer | Page size when page is set. Default 50, max 200. |
Without ?page=, the response is a plain JSON array, capped at 2000 rows, sorted newest-first by default -- except it sorts oldest-first (chronologically) when you set both from and to, or when you set date. With ?page=N, the response is:
{
"items": [ /* same objects as below */ ],
"total": 134,
"page": 1,
"limit": 50,
"pages": 3
}
Every item, in both modes, is the output of toApiBooking():
{
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
}
That's the complete field set for this endpoint -- no client name, no room details, no appointment-level total. status is one of scheduled, provisional, completed, cancelled, no-show. Cancelled and no-show appointments are included in the list -- filter them out client-side on status if you only want active bookings. notes is always a string (empty string, not null, when there's nothing to show).
services is the appointment's treatments, in the order they're performed, and it's the same array the booking webhooks send. Per row: service_id (resolve the name against GET /api/services), staff_id (the employee for that specific treatment, which can differ from the appointment's staff_id), duration_min, and price -- the price as a decimal string, snapshotted when the appointment was made, so a later price change to the service does not rewrite history. An appointment always has at least one row.
GET /api/bookings/:id
No query parameters. Returns a single object in the exact same shape as the list item above:
{
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
}
There is no client name, no room and no treatment-package (cure) information here -- the API-key response deliberately stops at the eight fields above, even though the logged-in-user version of this endpoint returns a much richer object with per-line room detail, extra staff per treatment and the appointment's cure context. services carries only actual treatments: the transition rows the app uses for processing time (colour developing, for example) are left out, so the array matches the booking webhook exactly. Adding fields later is not a breaking change, but assume today's response is exactly this shape.
A non-numeric or non-positive :id returns 400 with {"error": "Ongeldig afspraak-ID.", "code": "bad_request"}. A valid but non-existent :id returns 404 with {"error": "Afspraak niet gevonden."}.
GET /api/crm/clients
Query parameters (all optional):
| Param | Format | Effect |
|---|---|---|
search | string | Matches against name, preferred name, email and phone. |
label_id | integer | Filter to clients that have this label assigned. |
page | integer | Opt in to pagination, same envelope as bookings above. |
limit | integer | Page size when page is set. Default 50, max 200. |
Without ?page=, the response is a plain JSON array sorted alphabetically (or by search relevance when search is set). Every item is the output of toApiClient():
{
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
}
That's the complete field set -- no address, no birthdate, no labels, no loyalty or no-show data, and none of the account/payment-provider fields (passwordHash, stripeCustomerId, and similar) that used to leak through here. search/label_id still filter which clients match; they don't add fields to what's returned about each one.
GET /api/crm/clients/:id
No query parameters. Returns a single object in the exact same shape as the list item above:
{
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
}
Intake form answers (intake_responses) are never part of this response for an API key, regardless of who's allowed to view them in the app -- the field simply isn't in the fixed list above.
A non-numeric or zero/NaN :id returns 400 with {"error": "Ongeldig klant-ID.", "code": "bad_request", "error_key": "server.errors.invalid_client_id"}. A valid but non-existent :id returns 404 with {"error": "Klant niet gevonden.", "code": "not_found", "error_key": "server.errors.not_found", "error_params": {"entity": "Klant"}}.
GET /api/services
Query parameters:
| Param | Format | Effect |
|---|---|---|
bookable | 1 or true | Only active (non-archived) services. Without it, archived services are included too. |
Response is always a plain JSON array (no pagination support on this endpoint). Every item is the output of toApiService():
[
{
"id": 41,
"name": "Knipbeurt",
"duration_min": 45,
"price": "45.00",
"category_id": 3,
"active": true
}
]
That's the complete field set -- no description, no article number, no VAT rate, no scheduling restrictions, and no category name/color (only the raw category_id, if you need the category's details, fetch it separately or maintain your own mapping).
GET /api/staff
No query parameters. Response is always a plain JSON array (no pagination support). Every item is the output of toApiStaff():
[
{
"id": 7,
"name": "Chris Medewerker",
"email": "chris@example.com",
"role": "staff"
}
]
That's the complete field set -- no iban/bic, no Stripe Connect status, no lockout/login-attempt state, no page-permission flags. If your integration needs payout details, that's not something an API key can read; it has to come from the salon directly.
There is deliberately no active field: the endpoint only ever returns the salon's active employees, so the field could never be anything but true. Don't wait for an active: false to phase someone out -- an employee who leaves simply stops appearing in this list, so treat "missing from the response" as the signal.
Rate limit
Every /api/* request, including API-key requests, is rate-limited to 200 requests per minute per salon and IP address combined. None of the six read endpoints has an additional, stricter limiter on top of this one.
When you exceed it, you get:
- Status
429 - Body:
{"error": "Te veel verzoeken, probeer het later opnieuw"} - Headers on every response (not just when limited):
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(Unix timestamp, seconds) - On the
429itself, an additionalRetry-Afterheader with the number of seconds to wait before trying again
Respect Retry-After rather than polling on a fixed interval -- it reflects the actual reset time of the window you hit.
Webhooks
Instead of polling, you can register a URL and have Salonnare push events to it as they happen. Webhooks are configured in the app under Settings -> API & Webhooks -> Webhooks, the same place API keys live.
Envelope
Every delivery is a POST with this JSON body:
{
"event": "booking.created",
"data": { /* event-specific payload, see below */ },
"timestamp": "2026-08-25T09:00:03.512Z"
}
Alongside the body, every delivery carries two headers: X-Webhook-Signature (see Verifying signatures) and X-Webhook-Event (the same value as event in the body).
Events
You can subscribe to any combination of six events. Read the "Fires when" column literally -- every one of these events fires only from a salon employee's action in the app, and each one has exactly one trigger point in the server:
| Event | Fires when |
|---|---|
booking.created | An employee creates an appointment in the app (POST /api/bookings) |
booking.updated | An employee edits an appointment in the app (PATCH/PUT /api/bookings/:id) -- including a status change to completed or no-show, since the app makes those through the same call |
booking.cancelled | An employee cancels an appointment in the app (DELETE /api/bookings/:id) |
payment.received | An employee completes a checkout in the POS (POST /api/pos/orders) |
client.created | An employee creates a client in the app (POST /api/crm/clients) |
client.updated | An employee edits a client in the app (PATCH/PUT /api/crm/clients/:id) |
What does not fire an event
This is the part that catches integrations out, so it's spelled out rather than implied. Appointments, clients and payments that come into being any other way produce no webhook at all. Concretely, nothing is sent when:
- a client books online through the salon's booking page, or cancels or confirms an appointment there herself;
- an appointment arrives through Google Reserve;
- a waitlist entry is converted into an appointment;
- a repeating series generates its appointments;
- a treatment-plan (
cure) series is created, or a session of one is cancelled; - a provisional appointment is cancelled automatically because its deposit deadline passed, or promoted to confirmed because the deposit was paid;
- an appointment is marked completed as a side effect of a POS checkout;
- a client creates herself on the booking page, signs up through Google/Microsoft/Apple/Facebook, or is created by an import;
- a client record changes through anything other than the client edit screen -- loyalty tier recalculation, store credit, intake form answers, a bounced email -- or is anonymised by a deletion request.
The practical consequence: you cannot use webhooks as a complete change feed. For a salon that takes online bookings, the majority of new appointments will never reach your endpoint. Reconcile periodically against GET /api/bookings with a from/to window and treat webhooks as a low-latency hint on top of that, not as the source of truth.
All six payloads go through a fixed allowlist of fields on the server -- only the fields listed below are ever sent, in snake_case, regardless of what else the underlying record contains. This is a deliberate contract: webhook payloads will not silently grow new fields from the database.
booking.created / booking.updated / booking.cancelled:
{
"event": "booking.created",
"data": {
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
},
"timestamp": "2026-08-25T09:00:03.512Z"
}
All three booking events send this exact shape, built from the stored appointment: same fields, same types, start_at/end_at always UTC. Two details worth knowing:
statusis the status at the moment the event is sent. Abooking.createdfor an appointment that needs a deposit therefore reportsprovisional, notscheduled, and abooking.cancelledreportscancelledrather than the status the appointment had before.- This is the same field set as
GET /api/bookings/:id,servicesarray included, so one parser covers both channels.
payment.received:
{
"event": "payment.received",
"data": {
"order_id": 9931,
"order_number": "2026-000482",
"total": 45,
"payment_method": "card",
"client_id": 312
},
"timestamp": "2026-08-25T09:12:47.001Z"
}
client.created / client.updated:
{
"event": "client.created",
"data": {
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
},
"timestamp": "2026-08-25T09:00:03.512Z"
}
Notice that this is the exact same field set as the GET /api/crm/clients response above -- toWebhookClient() and toApiClient() share the same allowlist, so a client looks identical whether you receive it via a webhook or fetch it via the API.
When a salon owner sends a manual test delivery from the Webhooks screen, you'll receive an envelope with "event": "test" and a small placeholder payload. It is not one of the six subscribable events -- have your handler ignore (not error on) event names it doesn't recognise.
Verifying signatures
Every delivery is signed with HMAC-SHA256 over the raw request body, using the webhook's secret. Like an API key, this secret is encrypted at rest and is shown in full only once: in the response of the create call, and again if the salon regenerates it via POST /api/developer/webhooks/:id/rotate-secret. GET/PUT on a webhook never include the secret, only a hasSecret boolean -- so if the salon has lost it, they (or you, on their behalf) have to regenerate it and update whatever verifies the signature on your end. The hex digest is sent in the X-Webhook-Signature header.
const crypto = require('crypto');
function verify(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
rawBody must be the exact bytes Salonnare sent on the wire -- not JSON.stringify(JSON.parse(rawBody)), and not any object you've since re-serialized. Re-serializing changes key order and whitespace, which changes the bytes, which changes the signature. This is the single most common integration mistake and it produces a signature that never matches, with no clear error message pointing at the cause. Read the body as a raw string/buffer before your framework parses it as JSON, and hash that.
crypto.timingSafeEqual throws (rather than returning false) when the two buffers differ in byte length, so a missing or malformed X-Webhook-Signature header will crash verify() above instead of just failing the check. Guard for that -- check the header is present and is a 64-character hex string before calling timingSafeEqual, or wrap the call in a try/catch.
Delivery behaviour
- One attempt, no retries. If your endpoint is down or errors, Salonnare does not queue or retry that delivery. Design your integration to tolerate an occasional missed event (for example, by periodically reconciling against
GET /api/bookingswith afrom/towindow) rather than assuming every event will arrive. - 10-second timeout. If your endpoint doesn't respond within 10 seconds, the delivery is treated as failed.
- Redirects are not followed. A
3xxresponse counts as a failed delivery, exactly like a4xx/5xx. Respond2xxfrom the exact URL you registered. - Auto-disable after 10 consecutive failures. Once a webhook has failed 10 times in a row (timeouts, non-2xx responses, and blocked/invalid URLs all count), Salonnare deactivates it automatically. The salon owner has to re-enable it from the Webhooks screen; re-enabling resets the failure counter.
- Delivery logs are available to the salon, not to your endpoint. A salon admin can inspect the last deliveries (status code, response body, duration) for a webhook via
GET /api/developer/webhooks/:id/logsin the app -- this call requires an admin login (not an API key), so it's for the salon to debug their own integration, not something your receiving service calls.
URL requirements
The webhook URL you register must be publicly reachable over the internet. Salonnare validates it both when you save it and again, freshly, right before every single delivery (to guard against a domain that resolves to something safe today and something internal tomorrow). A URL is rejected -- at save time with a 400, or at delivery time as a failed attempt -- when it is or resolves to:
- Anything other than
http:/https:(in production,https:only) localhost,127.0.0.1,0.0.0.0,::1,::or any bracketed IPv6 equivalent- An internal hostname (
server,db,mariadb,mysql,redis,docs,worker) - A literal private/loopback/link-local IP address (RFC 1918 ranges,
169.254.0.0/16,127.0.0.0/8, IPv6 ULAfc00::/7, IPv6 link-localfe80::/10) - An IPv6 address in one of the transition notations that can hide an IPv4 address in its lower bits: IPv4-mapped (
::ffff:0:0/96), IPv4-compatible (::/96), NAT64 (64:ff9b::/96) and 6to4 (2002::/16). These are blocked on the prefix, so every spelling of the embedded address is rejected --[::ffff:127.0.0.1],[::127.0.0.1]and[::7f00:1]alike. Ordinary global-unicast IPv6 is unaffected - A public hostname whose DNS record resolves to any of the above -- checked at delivery time, not just once at registration, specifically so a domain you point at an internal address later still gets blocked
Point the URL at a domain you control from the start; don't register a placeholder expecting to repoint the DNS to an internal address later, and don't rely on the save-time check alone -- delivery re-validates every time.