On this page
Getting started Keys & security Conventions ErrorsPartner API
Connect a hotel PMS, a planning tool or your own software to a salon on massageplanner.app: create guest passes at check-in, book on behalf of guests, keep the catalog and the team's hours in sync.
https://<salon>.massageplanner.app/api/v1Getting started
- Get a key from the salon. The salon owner creates it in their dashboard under API keys, ticks the permissions (scopes) your system needs and hands you the key once. It starts with
mp_live_. - Make your first call. Read the salon's massage menu — it needs the
catalog:readscope.
The same call in JavaScript and Python:curl https://weedoo.massageplanner.app/api/v1/services \ -H "Authorization: Bearer mp_live_YOUR_KEY"// Node.js 18+ (fetch is built in) const res = await fetch('https://weedoo.massageplanner.app/api/v1/services', { headers: { Authorization: 'Bearer ' + process.env.MP_API_KEY }, }); const { services } = await res.json();# Python 3, requests import os, requests r = requests.get('https://weedoo.massageplanner.app/api/v1/services', headers={'Authorization': 'Bearer ' + os.environ['MP_API_KEY']}) services = r.json()['services'] - Read the answer. Every response is a JSON object; lists are wrapped in a named key.
{ "services": [ { "extern_id": "classic-60", "name": "Classic Massage", "description": "Full-body relaxation with flowing Swedish strokes", "details": null, "duration_min": 60, "price_cents": 6500, "active": true, "sort_order": 0, "service_required_items": [] } ] } - Then follow a flow. Hotel check-in → guest pass; concierge booking → bookings; central catalog or planning system → catalog and staff. Typical flows shows the calls in order.
weedoo with the salon's own subdomain. Each salon is a separate system with its own keys; a key never works on another salon.Keys & security
Every request carries the key in the Authorization header. Nothing else is needed — no session, no signing.
Authorization: Bearer mp_live_…
Scopes
A key only does what the salon ticked when creating it. Ask for the smallest set that covers your integration; a call outside the key's scopes answers 403 insufficient_scope and names the scope it needed.
| Scope | Unlocks |
|---|---|
guest-passes:read | Look up a guest pass |
guest-passes:write | Create, change, revoke a pass, resend its email |
bookings:read | List bookings in a date range |
bookings:write | Create, cancel, approve bookings |
catalog:read | Read services, items, rooms |
catalog:write | Create and change services, items, rooms and their links |
staff:read | Read the team, their skills, shifts and time off |
staff:write | Create and change team members, skills, shifts and time off |
How the key is protected on our side
- The key is shown to the salon once, at creation; we store only a hash and cannot show it again. Lost it? The salon creates a new one and revokes the old one — revocation is immediate.
- Keys are 192 bits of randomness; there is no way to guess one.
- The dashboard shows when each key was last used, so the salon notices a key that should be quiet.
- The API is HTTPS only and has no CORS headers: a browser page cannot call it. That is deliberate — the key belongs on your server, never in an app or web page that ships to users.
- A key only reaches the salon that issued it. There is no cross-salon access of any kind.
- Rate limits: 300 requests a minute per calling address (checked before the key is even looked up) and 600 a minute per key. Over it, you get
429 rate_limitedwith aRetry-Afterheader. A nightly sync of a few hundred products fits comfortably; spread anything larger over a minute or two.
What we ask of you
- Keep the key in a secret store or environment variable, not in source code, tickets or chat.
- One key per system (PMS, planning tool, your own app), each with its own scopes — so one can be revoked without stopping the others.
- Treat two response fields as secrets:
booking_urlof a guest pass (whoever has the link can book as the guest) andmanage_tokenof a booking (whoever has it can cancel the booking). Show them to the guest, log them nowhere. - Rotate a key you suspect: ask the salon for a new one, switch, then have the old one revoked.
Conventions
- Your ids. You address every resource by your own identifier,
extern_id, which you supply when you create it (up to 255 characters, unique per resource type within the salon). Use the id your system already has — a reservation number, a product code, an employee number. Reusing one answers409 extern_id_conflict, which makes retries safe: the resource exists, carry on. - Time. ISO 8601 with an offset, for example
2026-09-14T08:00:00+02:00or2026-09-14T06:00:00Z. Answers come back with offset+00:00. The salon's own timezone decides what "the day" is for availability. - Money. Integer cents in the salon's currency:
6500is €65.00. The API never takes payment; prices are shown to guests and settled at the salon. - PATCH changes only the fields you send. PUT on a list (skills, required items, fixed items, restrictions) replaces the whole list.
- DELETE on a catalog resource or a team member deactivates it: it disappears from the booking page, history stays, and
PATCH {"active": true}brings it back. Only time off and shifts are really deleted. - Lists are not paginated; bookings and shifts take a
from/torange instead. Keep ranges to a few weeks. - Encoding. Send
Content-Type: application/json; bodies are UTF-8. Names and descriptions may contain any characters.
Errors
Errors are JSON with an error code you can switch on. Validation errors add a detail that names the field:
HTTP 400
{
"error": "invalid_request",
"detail": {
"formErrors": [],
"fieldErrors": { "guest_email": ["Invalid email"] }
}
}
| Status | error | Meaning · what to do |
|---|---|---|
| 400 | invalid_request | The body or a parameter is wrong; detail says which. A referenced extern_id that does not exist (service, staff, room, item) also lands here, with the id in detail. |
| 400 | reservation_ref_required | The salon accepts bookings with a reservation number only. Send reservation_ref, or book through a guest pass. |
| 400 | guest_pass_outside_stay | The appointment is not between the pass's valid_from and valid_until. |
| 400 | guest_pass_party_mismatch | A couple pass books two people, any other pass one; expected tells you which. |
| 401 | invalid_api_key | Header missing, key malformed or revoked. Check the header, then ask the salon whether the key still exists. |
| 403 | insufficient_scope | The key lacks the scope in required. The salon adds it to the key (or creates a new key). |
| 403 | plan_feature | The salon's plan does not include the partner API. Every current plan (Studio, Enterprise) does; this answers only for a legacy plan. |
| 403 | guest_pass_invalid | The pass in guest_pass_extern_id is revoked, expired or used up. |
| 404 | not_found | No resource with that extern_id (or booking number). A cancel or approve on a booking that is not in the right status answers this too. |
| 409 | extern_id_conflict | You already created a resource with this extern_id. Safe to ignore on a retry. |
| 409 | slot_unavailable | Nobody qualified is free, no suitable room, the time is in the past or outside opening hours. Offer another time. |
| 409 | guest_pass_free_exceeded | The pass has fewer free massages left than the booking needs; remaining says how many. |
| 400 / 409 | overlapping_shifts | Shifts of one person overlap each other (400) or an existing shift (409). Touching boundaries such as 12:00/12:00 are fine. |
| 409 | shyfter_managed | This person's hours come from Shyfter; change them there. |
| 429 | rate_limited | Over the allowance: 300 requests a minute per calling address, 600 a minute per key (scope says which). Wait for Retry-After seconds and retry; spread bulk syncs out. |
| 500 | internal | Something broke on our side and the salon's operator has been alerted. Retry after a moment; nothing half-done is left behind. |
Guest passes guest-passes:readguest-passes:write
A guest pass is a personal booking link for one hotel guest (or couple). On that link the guest's details are pre-filled, the reservation number is locked in, and the salon knows who is booking. The link works from the moment you create it — guests can book before they arrive — until valid_until (checkout). Appointments themselves can only fall between valid_from (check-in) and valid_until.
/guest-passescreate a pass; emails the guest their link + QR code/guest-passes/{extern_id}read a pass, including how many free massages are used/guest-passes/{extern_id}change any field, e.g. extend valid_until on late checkout/guest-passes/{extern_id}/revokekill the link, e.g. early checkout/guest-passes/{extern_id}/emailsend the guest their link + QR againFields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | Your id for this pass, e.g. the check-in or reservation id |
guest_namerequired | string | Shown on the booking page and to the therapist |
guest_emailrequired | Receives the link + QR and later the booking confirmations | |
guest_phone | string | Optional |
reservation_ref | string | Your reservation number; copied onto every booking made with the pass |
valid_fromrequired | timestamp | Check-in: first moment an appointment may start |
valid_untilrequired | timestamp | Checkout: the link stops working and no appointment may start after it |
included_in_package | boolean | Default false. Marks the stay as a package; see below |
hide_pricing | boolean | Default false. No prices at all on the guest's page |
is_couple | boolean | Default false. Opens the page in couple mode; the pass then books two people |
free_selection | integer 0–20 | Default 0. Massages the guest may book for free through the link |
send_email | boolean | Create only. false = do not email the guest; your system delivers booking_url itself |
Example: create a pass at check-in
POST /api/v1/guest-passes
Authorization: Bearer mp_live_…
Content-Type: application/json
{
"extern_id": "pms-checkin-49821",
"guest_name": "Nesrin Yilmaz",
"guest_email": "nesrin@example.com",
"guest_phone": "+32 470 00 00 00",
"reservation_ref": "HTL-2026-777",
"valid_from": "2026-09-05T00:00:00Z",
"valid_until": "2026-09-08T23:59:59Z"
}
Response 201. emailed tells you whether the guest got the email; booking_url is the link to show or print:
{
"guest_pass": {
"extern_id": "pms-checkin-49821",
"guest_name": "Nesrin Yilmaz",
"guest_email": "nesrin@example.com",
"guest_phone": "+32 470 00 00 00",
"reservation_ref": "HTL-2026-777",
"valid_from": "2026-09-05T00:00:00+00:00",
"valid_until": "2026-09-08T23:59:59+00:00",
"revoked_at": null,
"included_in_package": false,
"hide_pricing": false,
"is_couple": false,
"free_selection": 0,
"free_used": 0,
"used_at": null,
"created_at": "2026-09-05T13:02:11.481+00:00",
"booking_url": "https://weedoo.massageplanner.app/g/60778b98c1…"
},
"emailed": true
}
The QR code of the link is also available as an image: GET https://<salon>.massageplanner.app/api/public/pass/{token}/qr.png, where {token} is the last part of booking_url. Handy for a printed welcome card.
Example: late checkout, then early departure
PATCH /api/v1/guest-passes/pms-checkin-49821
{ "valid_until": "2026-09-09T14:00:00Z" }
POST /api/v1/guest-passes/pms-checkin-49821/revoke
→ { "revoked": true }
Package passes: massages included in the stay
When the guest's package includes massages, add these fields. free_selection is how many massages the guest may book free of charge through the link: the booking page tells them how many are left and shows prices as "included". Each booked massage spends one; when the last one is booked the link stops working and used_at is set. Cancelling a booking gives the ticket back. hide_pricing hides prices altogether; is_couple opens the page in couple mode and makes the pass book two people at a time.
POST /api/v1/guest-passes
{
"extern_id": "pms-checkin-49822",
"guest_name": "Lotte & Jan Peeters",
"guest_email": "lotte@example.com",
"reservation_ref": "HTL-2026-778",
"valid_from": "2026-09-05T00:00:00Z",
"valid_until": "2026-09-08T23:59:59Z",
"included_in_package": true,
"hide_pricing": true,
"is_couple": true,
"free_selection": 2
}
Two rules follow from the pass, on the guest's link and on bookings you make with guest_pass_extern_id: a booking that needs more free massages than are left answers 409 guest_pass_free_exceeded with remaining; a couple pass must book two people and any other pass one, otherwise 400 guest_pass_party_mismatch with expected.
revoked_at and used_at are null and now is before valid_until. Reading it back with GET shows free_used, so your PMS can display "1 of 2 massages booked".Bookings bookings:readbookings:write
A booking is one appointment for one or two people (a couple in one room). You book on behalf of a guest — for example from the reception desk — and the guest gets the same confirmation email as when booking online.
/bookings1 member = solo, 2 members = couple/bookings?from=…&to=…bookings starting in the range (default: yesterday to two weeks ahead)/bookings/{ref}/cancelcancel; free tickets go back to the pass/bookings/{ref}/approveconfirm a booking that is pending{ref} is your extern_id, or the numeric booking number the salon sees. A booking is pending, confirmed or cancelled. It comes back pending when the salon must first move portable equipment into the room; it holds its slot either way, and the salon (or you, with approve) confirms it.
Fields
| Field | Type | Meaning |
|---|---|---|
starts_atrequired | timestamp | Start of the appointment; must be a bookable time (see below) |
membersrequired | array of 1–2 | One entry per person |
members[].service_extern_idrequired | string | Which massage |
members[].customerrequired | object | name, email (both required), phone |
members[].staff_extern_id | string | Preferred therapist; omitted, any qualified therapist who is free |
members[].staff_gender | "female" · "male" | The guest wants a woman or a man; any qualified therapist of that gender. Ignored when staff_extern_id is given |
members[].extern_id | string | Your id for this person's line, if you track them separately |
extern_id | string | Your id for the whole booking; lets you cancel by it later |
guest_pass_extern_id | string | Book on behalf of a pass you created: stay window, party size and free tickets apply; the reservation number is taken from the pass |
reservation_ref | string | Reservation number, when not booking through a pass |
room_extern_id | string | A specific room; omitted, any suitable room |
notes | string | Anything the therapist should know (health, wishes); shown in the salon's agenda |
Example: one person, any therapist
POST /api/v1/bookings
{
"extern_id": "pms-book-1201",
"starts_at": "2026-09-06T13:00:00Z",
"reservation_ref": "HTL-2026-777",
"members": [
{
"service_extern_id": "classic-60",
"customer": { "name": "Nesrin Yilmaz", "email": "nesrin@example.com" }
}
]
}
Response 201:
{
"booking": {
"booking_no": 42,
"status": "confirmed",
"starts_at": "2026-09-06T13:00:00+00:00",
"ends_at": "2026-09-06T14:00:00+00:00",
"manage_token": "1d02f5…",
"group_id": "afc2…"
}
}
manage_token is the guest's own cancel link: https://<salon>.massageplanner.app/b/{manage_token}. It is in their confirmation email; you rarely need it.
Example: a couple, through their package pass, with a preferred therapist
POST /api/v1/bookings
{
"extern_id": "pms-book-1202",
"starts_at": "2026-09-06T15:00:00Z",
"guest_pass_extern_id": "pms-checkin-49822",
"notes": "Jan: lower back sensitive",
"members": [
{
"service_extern_id": "hotstone-90",
"staff_extern_id": "therapist-ayla",
"customer": { "name": "Lotte Peeters", "email": "lotte@example.com" }
},
{
"service_extern_id": "classic-60",
"customer": { "name": "Jan Peeters", "email": "jan@example.com" }
}
]
}
Both people are placed in one room at the same start time; each may have a different massage and therapist. The two free tickets of the pass are spent, so the guests pay nothing.
Which times can be booked?
A time is bookable when it lies on the salon's slot grid, is at least the salon's lead time away, falls inside a shift of a qualified therapist (or the one you named), a room with the right equipment is free, and no time off blocks it. If not, the answer is 409 slot_unavailable and nothing is stored. There is no availability call in the partner API yet: when your system needs to show free times before booking, tell the salon and we will add one.
Example: list, cancel, approve
GET /api/v1/bookings?from=2026-09-06T00:00:00Z&to=2026-09-07T00:00:00Z
{
"bookings": [
{
"booking_no": 42,
"extern_id": "pms-book-1201",
"reservation_ref": "HTL-2026-777",
"status": "confirmed",
"starts_at": "2026-09-06T13:00:00+00:00",
"ends_at": "2026-09-06T14:00:00+00:00",
"rooms": { "name": "Room 1", "extern_id": "room-1" },
"guest_pass_extern_id": null,
"included_package": false,
"persons": 1,
"total_cents": 6500,
"bookings": [
{
"guest_name": "Nesrin Yilmaz",
"status": "confirmed",
"extern_id": null,
"services": { "name": "Classic Massage", "extern_id": "classic-60", "price_cents": 6500 }
}
]
}
]
}
POST /api/v1/bookings/pms-book-1201/cancel
→ { "cancelled": true }
POST /api/v1/bookings/42/approve
→ { "approved": true }
For package bookings included_package is true and every price reads 0: there is nothing to settle. guest_pass_extern_id tells you which stay it belongs to.
Waiting list
When a day has no free time, guests can leave their details on the booking page; the moment a matching time opens (a cancellation, an added shift) everyone waiting for that day is emailed and the first booking wins. Read-only for now, so a concierge screen can see who is hoping for a time:
/waitlistopen entries from today on, oldest firstbookings:readGET /api/v1/waitlist
{
"waitlist": [
{
"date": "2026-09-14",
"time_from": "14:00",
"time_to": null,
"services": [ { "extern_id": "classic-60", "name": "Classic Massage" } ],
"staff_gender": "female",
"guest_name": "Nesrin Yilmaz",
"guest_email": "nesrin@example.com",
"guest_phone": null,
"notes": null,
"status": "open",
"notified_at": null,
"created_at": "2026-09-12T09:41:07.113+00:00"
}
]
}
status is open or notified (a time was emailed, not booked yet). A booking made for that email on that day closes the entry.
Services, items, rooms catalog:readcatalog:write
The catalog is what the booking page offers and what the engine needs to place a booking: services (massage types), items (equipment such as a hot-stone heater or a massage chair) and rooms. A service can require items; a room has fixed items and can forbid items. Portable items travel between rooms; that is what makes a booking pending until the salon moved it.
/services POST/services PATCH/services/{extern_id} DELETE/services/{extern_id}/services/{extern_id}/required-itemsreplaces the list of items the service needs/items POST/items PATCH/items/{extern_id} DELETE/items/{extern_id}/rooms POST/rooms PATCH/rooms/{extern_id} DELETE/rooms/{extern_id}/rooms/{extern_id}/fixed-itemsitems that are always in this room PUT/rooms/{extern_id}/restrictionsitems that may never be brought inService fields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | Your product code |
namerequired | string | Shown on the booking page |
description | string | One line under the name on the booking page; keep it around 90 characters |
details | string | Longer explanation; opens from an "i" beside the name. Line breaks are kept |
duration_minrequired | integer 5–600 | Length of the appointment |
price_centsrequired | integer | Display price |
active | boolean | Default true; false hides it from the booking page |
sort_order | integer | Position on the booking page, lowest first |
Write the texts in English: the booking page shows them to guests in English, Dutch and French, translated automatically. The salon can correct a translation in its dashboard; your next change of the English text replaces the automatic translation of that text (and drops the salon's correction of it).
Item fields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | |
namerequired | string | |
portablerequired | boolean | true = can be carried into any room that does not forbid it |
portable_quantity | integer | How many portable units exist; bookings needing more at the same time are refused |
active | boolean |
Room fields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | |
namerequired | string | |
capacityrequired | 1 or 2 | 2 = a couple can be treated together here |
active | boolean |
Example: a hot-stone massage that needs a heater
POST /api/v1/items
{ "extern_id": "stone-heater", "name": "Hot Stone Heater", "portable": true, "portable_quantity": 1 }
POST /api/v1/services
{
"extern_id": "hotstone-90",
"name": "Hot Stone Massage",
"description": "Warmed basalt stones melt deep muscle tension",
"details": "Smooth, heated basalt stones are placed along the back and used in long, slow strokes.\n\nGood for stiff shoulders and cold days. Not suitable during pregnancy.",
"duration_min": 90,
"price_cents": 9500
}
PUT /api/v1/services/hotstone-90/required-items
[ { "item_extern_id": "stone-heater", "quantity": 1 } ]
Example: a couples room with a fixed shower, no aroma diffuser
POST /api/v1/rooms
{ "extern_id": "room-2", "name": "Room 2", "capacity": 2 }
PUT /api/v1/rooms/room-2/fixed-items
[ { "item_extern_id": "shower", "quantity": 1 } ]
PUT /api/v1/rooms/room-2/restrictions
[ "aroma-diffuser" ]
Example: change a price, hide a service, bring it back
PATCH /api/v1/services/hotstone-90
{ "price_cents": 9900 }
DELETE /api/v1/services/hotstone-90
→ { "deactivated": true }
PATCH /api/v1/services/hotstone-90
{ "active": true }
Staff & hours staff:readstaff:write
Team members, what they can do (skills = services), and when they work (shifts). Availability comes from shifts: no shift, no bookings. Shifts are dated — there is no weekly pattern — so a planning system sends the actual roster.
/staff POST/staff PATCH/staff/{extern_id} DELETE/staff/{extern_id}/staff/{extern_id}/skillsarray of service extern_ids, replaces the set/staff/{extern_id}/shifts?from=…&to=…default: last week to 60 days ahead/staff/{extern_id}/shiftsadd dated shifts (up to 500 per call)/staff/{extern_id}/shifts?from=…&to=…remove the shifts you added in a rangeStaff fields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | Your employee id |
display_namerequired | string | Shown to guests as the therapist's name |
email | For the personal calendar link and absence notices | |
gender | "female" · "male" | Guests may ask for a woman or a man; only staff marked here are offered under such a preference. Null = not set |
active | boolean | false = not bookable |
Example: a new therapist with two skills
POST /api/v1/staff
{ "extern_id": "therapist-ayla", "display_name": "Ayla", "email": "ayla@example.com" }
PUT /api/v1/staff/therapist-ayla/skills
[ "classic-60", "hotstone-90" ]
Example: a week of shifts, including a split day
Send as many shifts as you like in one call. Several per day are fine as long as they do not overlap; a shift that overlaps one already stored answers 409 overlapping_shifts.
POST /api/v1/staff/therapist-ayla/shifts
[
{ "starts_at": "2026-09-14T08:00:00+02:00", "ends_at": "2026-09-14T12:00:00+02:00" },
{ "starts_at": "2026-09-14T14:00:00+02:00", "ends_at": "2026-09-14T18:00:00+02:00" },
{ "starts_at": "2026-09-15T09:00:00+02:00", "ends_at": "2026-09-15T17:00:00+02:00" }
]
→ 201
{
"shifts": [
{ "id": "…", "starts_at": "2026-09-14T06:00:00+00:00", "ends_at": "2026-09-14T10:00:00+00:00", "source": "manual" },
…
]
}
Example: replace next week's roster
The simplest sync: clear the range, then send the new shifts. Bookings already made in that week stay; the salon sees a warning in its agenda if a booking ends up outside a shift.
DELETE /api/v1/staff/therapist-ayla/shifts?from=2026-09-14T00:00:00Z&to=2026-09-21T00:00:00Z
→ { "removed": 5 }
POST /api/v1/staff/therapist-ayla/shifts
[ … next week … ]
GET /staff returns every team member with their skills and their shifts from a week ago onwards; source is manual for shifts sent by you or the salon and shyfter for shifts synced from Shyfter. People whose hours come from Shyfter answer 409 shyfter_managed to shift writes.
Time off staff:readstaff:write
A block of time when a person is away, a room is out of use, or the whole salon is closed. Bookings cannot be made inside it; bookings that already exist inside it are reported back so someone can call the guests.
/time-offevery entry, oldest first/time-off/time-off/{extern_id}Fields
| Field | Type | Meaning |
|---|---|---|
extern_idrequired | string | |
staff_extern_id | string | The person; leave out together with room_extern_id to close the whole salon |
room_extern_id | string | The room, e.g. maintenance |
starts_at, ends_atrequired | timestamp | A whole day is midnight to the next midnight in the salon's offset |
kind | enum | sick, holiday, training, personal, closed, other. Default other, or closed for a salon closure. Shown in the salon's agenda |
reason | string | Free text for the salon |
Example: sick day with a booking inside it
POST /api/v1/time-off
{
"extern_id": "leave-2026-09-10-ayla",
"staff_extern_id": "therapist-ayla",
"starts_at": "2026-09-10T00:00:00+02:00",
"ends_at": "2026-09-11T00:00:00+02:00",
"kind": "sick"
}
→ 201
{
"time_off": { "extern_id": "leave-2026-09-10-ayla", "kind": "sick", "starts_at": "2026-09-09T22:00:00+00:00", "ends_at": "2026-09-10T22:00:00+00:00", … },
"affected": [
{
"bookingNo": 57,
"startsAt": "2026-09-10T08:00:00.000Z",
"endsAt": "2026-09-10T09:00:00.000Z",
"roomName": "Room 1",
"status": "confirmed",
"members": [
{ "serviceName": "Classic Massage", "staffName": "Ayla", "customerName": "Nesrin Yilmaz", "customerEmail": "nesrin@example.com", "customerPhone": null }
]
}
]
}
The salon is emailed the same affected list. Moving or cancelling those bookings is a decision for a person; the API does not do it for you.
Example: close the salon for a public holiday
POST /api/v1/time-off
{ "extern_id": "closed-2026-12-25", "starts_at": "2026-12-25T00:00:00+01:00", "ends_at": "2026-12-26T00:00:00+01:00", "kind": "closed", "reason": "Christmas" }
Typical flows
Hotel PMS: from check-in to checkout
POST /guest-passes with the reservation number, check-in and checkout as valid_from/valid_until, package fields if the rate includes massages. The guest gets the email; print the QR on the welcome card if you like.POST /bookings and guest_pass_extern_id. GET /bookings per day feeds the housekeeping or concierge screen.PATCH the pass with a later valid_until. Room change or new email: PATCH again. Guest cannot find the email: POST …/email.valid_until. Early departure: POST …/revoke. Package used up: GET the pass and read free_used for the folio.Central catalog: keep prices and the menu in one place
POST /services; on 409 extern_id_conflict send PATCH /services/{extern_id} with the current fields. Products gone from your system: DELETE (hides them, keeps history).Planning tool: push the roster
DELETE …/shifts?from&to for the week, then POST …/shifts with the new shifts. Absences go to POST /time-off with the right kind.affected list on time off and the salon's agenda warnings: the API never moves a guest's appointment on its own.Testing your integration
There is no separate sandbox; every call reaches the real salon. This keeps the integration honest, and with a few habits it is perfectly safe:
- Start read-only. Ask the salon for a key with only the
:readscopes and confirm thatGET /services,GET /staffandGET /bookingsreturn what you expect. Then get a second key with write scopes. - Prefix your test ids. Use
test-…asextern_idso everyone can tell test data apart, and deactivate or cancel it when you are done. - Use your own email. A guest pass emails the guest and a booking emails the customer. Put your own address in
guest_emailandcustomer.email, or send"send_email": falseon passes. - Book far enough ahead. A time must be in the future, inside a shift and on the salon's slot grid (usually every 15 minutes); the safest test slot is tomorrow morning. Cancel the test booking afterwards so the slot is free again.
- Provoke the errors once. Send a wrong email, an unknown
service_extern_id, a duplicateextern_id, a time in the past: your code should show each error code from the table sensibly to its users. - Check the dashboard. The salon sees your bookings under Bookings and Today, your passes under Guest passes, and the key's last use under API keys — the quickest way to confirm a call did what you meant.
- Retry safely. On a timeout, repeat the same request with the same
extern_id: a409 extern_id_conflictmeans the first attempt went through.
Stuck? Send the salon the request you made (without the key), the response and the time; they can pass it on and we can see the call in our logs.
What changed
| Date | Change |
|---|---|
| 2026-09-09 | Services: details (longer explanation behind the "i" on the booking page). Name, description and details are translated automatically into Dutch and French. |
| 2026-09-12 | Waiting list on the booking page; GET /waitlist (read-only) for partners. |
| 2026-09-11 | Rate limits on the partner API: 300 requests a minute per address, 600 per key; 429 rate_limited with Retry-After. |
| 2026-09-09 | Staff: gender; bookings: members[].staff_gender lets a guest ask for a woman or a man. |
| 2026-09-09 | This page: field tables, response examples, flows and the testing checklist. |