How to use the Salon Booking OS REST API
14 min read
Salon Booking OS by OsDotâ„¢ has a versioned REST API at /api/v1, for the customer app and for integrations. The complete contract is an OpenAPI 3.1.0 document, openapi.json, generated from the application’s own definitions. Its operations are listed by surface — with who may call each one and how it refuses — in OPERATIONS.md. This chapter explains them.
What works in this version #
- Without signing in: the public catalogue and free times; the website’s public content and its contact form; the shop’s catalogue and a visitor’s cart (see The shop).
- Online booking follows the owner’s choice. It works while the salon takes bookings online (Front desk → Online booking → Take bookings online; off on a new site, see Booking online). While it is off, an online booking or a hold answers
403 online_booking_closed. - One-time codes need the site’s e-mail. A guest’s booking, a guest’s shop checkout and a client’s sign-in all start with a code sent by e-mail. Until the site’s e-mail is set up (see Messages, reminders, text messages and push),
POST /auth/codesanswers503 email_unavailable(“Codes cannot be sent: this site has no e-mail delivery set up yet.”), which is not worth retrying. Without a code, an online booking answers403 identity_required. - Signed-in clients: their account and sessions, their own visits, their orders and paying them, the shop (their own cart, checkout, orders and returns), and their data and message choices — the same as Privacy and my data (see Clients and their data).
- Staff: every staff operation from the administration area itself, for each member of the team who signs in, within their role’s permissions (see Your team’s sign-ins).
- Not yet possible: calls from an app. No app is registered, so app sign-in answers
403 client_unknown. The site’s owner registers an app with a console command (How an app signs in); the Salon app is registered when it ships. - Google sign-in from an app needs a registered app and Google set up under System → Google sign-in. Without the Google setup, its operations answer
403 identity_unavailable.
Base address #
https://<your-salon-host>/api/v1 — a host-name root on HTTPS (plain HTTP only on a local development host). Use the site’s own address: another host name answers 400 host_mismatch, naming the right one in details.expected_origin.
- Request bodies are JSON (
Content-Type: application/json), one JSON object of at most 1 MiB; a write without a body may leave out the Content-Type. Two uploads send raw bytes instead (a media part and the account’s picture). Another body type answers415, naming the accepted types. - Public identifiers are UUIDs. Booking references are 8-character codes.
- Every answer carries
X-Request-IDandCache-Control: private, no-store. Quote the request id in a support request; never send tokens, passwords or codes.
Who may call #
| Caller | How |
|---|---|
| Anonymous | No credentials, for the catalogue, free times, the shop’s catalogue, code requests and manage links. An app sends X-Platform-Client: native; a page of this site sends its CSRF token on writes (see Browsers). Limited per network address. |
| Cart token | A visitor’s shop cart: POST /shop/carts answers its token (ct_…) once; send it in X-Cart-Token. |
| Code grant | A guest who verified a one-time code sends X-Code-Grant: cg_… (never in a URL) for the draft named in the body (draft_ref): one booking, one hold or one shop order per grant. Needs the site’s e-mail (the code is e-mailed). |
| Client token | A signed-in client, after a one-time code sent by e-mail (POST /auth/codes with purpose: sign_in, then POST /auth/codes/verify). An app receives tokens; a browser receives a web session. |
| Staff session | The administration area: the web session with CSRF protection. |
| Staff token | An app acting for a team member (see How an app signs in). |
| Manage link | A client without an account: the link’s token in the request body (never in a URL), for that one appointment. |
How an app signs in #
An app is identified by a client_id that the site’s owner registers and enables from the server’s command line: php bin/cake.php auth client add (and list, enable, disable). This version registers none (every app sign-in answers 403 client_unknown; a registered app that is switched off answers 403 client_disabled); the Salon app is registered when it ships. Then:
POST /api/v1/auth/loginwithX-Platform-Client: nativeand{"email": "…", "password": "…", "client_id": "…", "device_label": "…"}(the label is optional). Withoutclient_id:422 missing_field; a wrong e-mail address or password:401 invalid_credentials. The answer carries an access token (at_…) and a refresh token (rt_…).- Call with
Authorization: Bearer at_…andX-Platform-Client: native, and never with this site’s own sign-in or CSRF cookie — such a call is refused (400 ambiguous_authentication, naming the cookie); other cookies are ignored. - An access token lasts 15 minutes. On
401 token_expired, callPOST /api/v1/auth/refreshonce with the refresh token and theclient_id, then retry. A refresh token works once; sending it again ends the whole sign-in (refresh_replay). - Sign in again when the answer says the sign-in is over —
session_ended(signed out),session_expired(its 30 days are up),session_revoked(the account changed),invalid_token— or when the refresh is refused.
A web session (the administration area) ends after 120 idle minutes, each request extending it, and at most 12 hours after signing in.
Re-confirming the password. Changing the website’s brand or business profile, publishing a legal page, or changing an account’s state or roles needs a sign-in less than 10 minutes old; otherwise the answer is 403 reauthentication_required, naming the endpoint and the window. Call POST /api/v1/auth/reauthenticate with the password, then repeat the request. A confirmation in the administration area does not count for API calls.
Browsers #
- Same origin only. Pages of other websites are refused (
403 origin_denied, naming this site’s address and the reason), and there are no CORS headers. From another website use the booking widget (while the salon takes bookings online) or call the API from your own server as a native client. - CSRF on writes. A page of this site sends the header
X-CSRF-Tokenwith the value of its<meta name="csrf-token">tag (the CSRF cookie cannot be read by scripts). Otherwise:403 csrf_failed, with the reason (cookie_missing,cookie_invalidortoken_mismatch).
Envelope #
- Success:
{"data": …, "meta": {"request_id": "<uuid>", "server_now": "<UTC>"}}. A204has no body. - Error:
{"error": {"code": "<code>", "message": "<safe text>", "field_errors": {…}, "retryable": false, "details": {…}}, "meta": {…}}.detailsappears only when there is something to add — for example the next free times afterconflict.slot_taken.
A transport success is not a business outcome: a hold, for example, becomes a booking only when its payment is confirmed.
Errors #
| HTTP | Meaning | Codes |
|---|---|---|
| 400 | Malformed request | idempotency_key_required, invalid_json, ambiguous_authentication, native_refresh_required, native_client_required, invalid_request, host_mismatch |
| 401 | Not signed in, or the credential is not valid | authentication_required (no credentials), invalid_credentials (a wrong e-mail address or password), token_expired (refresh once), session_ended, session_expired, session_revoked, invalid_token (sign in again), invalid_refresh_token, refresh_replay, challenge_mismatch |
| 403 | Not allowed | forbidden, email_unverified, csrf_failed, origin_denied, https_required, client_unknown, client_disabled, identity_required, identity_unavailable, online_booking_closed, code_sign_in_disabled, password_sign_up_disabled, second_step_required, password_required, account_unavailable, reauthentication_required, demo_disabled |
| 404 | Not found — also an object outside the caller’s scope | not_found, link_invalid, cart_not_found |
| 405 | Method not allowed — the Allow header lists the methods | method_not_allowed |
| 409 | Conflict | idempotency_conflict, state_conflict (also a stale version), already_applied, conflict.slot_taken, policy.window_closed, policy.reschedule_limit, deposit_required, deposit_not_due, rule.soft_requires_override, rule.hard_violation, limit.items, limit.holds, limit.future_bookings, grant_used, identity_conflict, identity_link_required |
| 413 / 415 | Body too large or of the wrong type | request_too_large, unsupported_media_type |
| 422 | Input not valid | unknown_fields, missing_field, invalid_object, invalid_text, invalid_integer, invalid_boolean, invalid_identifier, invalid_choice, invalid_items, invalid_timestamp, invalid_cursor, cursor_scope_changed, invalid_challenge, code_invalid, channel_unavailable, validation_failed (with field_errors) |
| 428 | Version missing | version_required (see Versions) |
| 429 | Too many requests | rate_limited, with Retry-After: the seconds to wait |
| 500 | Internal | internal_error — quote the request id; worth a retry only for a read, or with an Idempotency-Key |
| 503 | Unavailable | email_unavailable (a code or an e-mail link while the site’s e-mail is not set up: not worth retrying until it is), delivery_failed (once e-mail is set up: a code could not be sent), unavailable, maintenance (with Retry-After) |
Unknown fields are refused, not ignored. The sign-in and account operations name the field of an input error; some others do not yet: check the request against the operation’s schema in the document.
identity_requiredcovers every way an online booking can lack a usable identity: no grant and no signed-in client; a grant that is malformed, expired, made for another draft, or already used; a contact that does not repeat the verified address; a signed-in client whose e-mail address is not verified.online_booking_closed: the salon does not take bookings online at the moment — for a guest with a grant and for a signed-in client alike; nothing is booked. Once the owner turns Take bookings online on again, the same grant can book.conflict.slot_takenis also the answer for a start in the past, inside the minimum notice or beyond the booking horizon.deposit_required: the visit needs a deposit, which is paid online. Such a visit is held (POST /booking/online/holds) while the client pays, not booked directly — unless the owner lets paying at the salon replace a required deposit.deposit_not_dueis the reverse: a hold for a visit with nothing to pay before it.cart_not_found: theX-Cart-Tokennames no cart (unknown, or expired).
Versions #
The website editors’ operations and the account’s profile and picture are versioned. Send the record’s version quoted in If-Match (If-Match: "3"). Without it: 428 version_required. If the record changed meanwhile: 409 state_conflict — read it again, re-apply the change and send the new version.
Idempotency #
Every booking write requires an Idempotency-Key header matching ^[A-Za-z0-9._:-]{8,128}$ (a UUID is recommended): desk and phone bookings, walk-ins, moves, reassignments, cancellations, status changes, reopen and revert, the manage-link commands, online bookings and holds. So do every creation by the website editors’ operations, and a staff member’s recorded payment and refund. A missing or malformed key answers 400 idempotency_key_required. The shop’s operations take no Idempotency-Key.
Receipts are kept for 48 hours per caller and command. Repeating a request with the same key and payload answers the stored result; the same key with another payload answers 409 idempotency_conflict. A repeated online booking answers its appointment with "replayed": true and no manage link; a repeated manage-link move answers 409 already_applied.
Paging #
Lists use cursors: ?page_size=…&next_cursor=… (1–100, default 20). The answer carries next_cursor, which is null on the last page. Cursors are signed and bound to the list and the caller; a forged one answers 422 invalid_cursor.
Formats #
| Value | Format |
|---|---|
| Instant | UTC in ISO 8601 with Z. |
| Local date | YYYY-MM-DD in the salon’s time zone, where an operation says “date” (for example the day of a free-times query). |
| Time zone | A canonical IANA name, such as "America/New_York". |
| Time steps | Starts and durations are on a 5-minute grid. |
| Money | Minor units as a string of digits ("6500") with its ISO 4217 currency; never a float. |
| Phone | E.164 ("+14155550123"). The staff operations refuse any other form. |
| Address | Free-form lines; no mandatory state or postal code. |
Limits #
- Anonymous calls: per network address. Signed-in calls: per account.
- One-time codes: per contact per minute, hour and day, and per network address per hour.
- Manage-link lookups and holds have their own limits.
- Every limit answers
429 rate_limitedwithRetry-After: the seconds left before the limit allows the request again.
The shop #
The shop’s operations are in the public and client surfaces (see The shop for the pages they match).
| Operation | For |
|---|---|
GET /shop/catalogue | the categories, the products on sale with their variants, prices and stock, and whether collecting at the salon is offered |
GET /shop/products/{slug} | one product on sale, with its variants and pictures |
POST /shop/carts | a new cart: a visitor gets its token once; a signed-in client gets their own cart and no token |
GET /shop/cart | the cart: its items, their prices now, and anything that can no longer be bought as it is |
PUT /shop/cart/items/{variant} | set an item’s units ({"quantity": n}, 0 to 99) |
DELETE /shop/cart/items/{variant} | take an item out of the cart |
POST /shop/cart/quote | the cart priced as its order will be: the ways to receive it for an address (each with its key), a discount_code, the taxes and the total |
POST /shop/checkout | place the order: contact (name and e-mail address), delivery (a way’s key from the quote), the address for a delivery, a discount_code |
GET /shop/orders | a signed-in client’s orders, newest first — including those placed as a guest with the account’s verified e-mail address |
GET /shop/orders/{id} | one of the client’s orders; another account’s order answers 404 |
POST /shop/orders/{id}/returns | ask to return items of a collected or delivered order, within the return window: lines (each order line and how many) and a reason |
- A visitor sends the cart’s token in
X-Cart-Tokenon every cart call and on the checkout. A signed-in client’s cart is the account’s own and needs no token. - The checkout’s answer carries the order — its units held — and
pay_url, the order’s payment page. A signed-in client pays throughPOST /commerce/orders/{id}/checkout(online), orPOST /commerce/orders/{id}/offlineto pay in person where the salon offers it. - A guest’s checkout needs a code grant, as an online booking does: a code with
purpose: bookingand adraft_ref, thenX-Code-Grantwith the samedraft_refin the checkout’s body. - The shop has no staff operations: the team works on shop orders, stock and returns in the administration area.
Surfaces #
The operations are grouped in three surfaces, recorded in the document as info.x-salon-surfaces (133 operations; some belong to two surfaces):
| Surface | For | Operations |
|---|---|---|
| Public | guests on the website, the widget and the app | 32 — the catalogue, staff names (never contacts), home visits, free times, codes, the online quote, hold and booking, manage links, the payment methods offered, the website’s content and its contact form, the shop’s catalogue, a visitor’s cart and a guest’s checkout |
| Client | signed-in clients | 52 — sign-in and tokens, the account, its sessions, profile, picture and deletion request (and what the salon keeps), the client’s own visits, their orders and paying them (a visit’s deposit from the app), their data download and message choices, the shop: their own cart, checkout, orders and returns, and the app’s device for push and its inbox |
| Staff | owner, manager and staff, each operation behind its permission | 77 — the calendar, My day, appointments and their commands, the catalogue and schedules, the media library, orders, payments and refunds, the website editors’ operations, and the account’s own data download and message choices |
Scope. With See every booking … a staff caller reaches every appointment; with See own bookings … only appointments with a service of the staff member linked to the account. Anything else answers 404, for reads and for writes alike. A client reaches only their own visits and orders: another account’s answers 404.
Your own data. GET /me/data-export answers the account’s own data as one document (osdot.client-data.v1), as Privacy and my data downloads it. GET /me/contact-preferences lists the account’s own addresses and numbers with the kinds of message each may stop (by default, the reminders before a visit); PATCH /me/contact-preferences stops or allows one kind for one of them.
Switches. The document records the configuration’s switches in x-osdot-switches. Public password sign-up is off: POST /auth/register is not in the document and answers 403 password_sign_up_disabled. Client code sign-in is on (codeSignIn); its codes need the site’s e-mail.
Versioning #
Changes within /api/v1 are additive only. A breaking change gets a new major path.