Public API reference
Everything a website, app or widget needs to sell and manage bookings for one business. No
authentication: the business's account is identified by its public key in the path. All endpoints live under
/api/public/<key>/….
Read 02-concepts.md first if terms like service, item, order or hold are new.
- Base URL:
https://app.nisebookings.com. - This page documents the public API (no login). The account itself — services, staff, schedules, opening hours — is configured through the separate management API, which uses scoped keys instead of the public key.
- The key is the business's publieke sleutel: the admin shows it under Instellingen → Insluiten.
- Bodies are JSON (
Content-Type: application/json), exceptPOST uploadswhich takesmultipart/form-data. Responses are JSON withCache-Control: no-store— exceptGET services, which is cacheable for 60 s. - All times in requests and responses are ISO 8601 UTC (
2026-10-14T09:30:00.000Z). Dates in requests (from,until) are business-local calendar dates (YYYY-MM-DD). The business time zone comes fromGET services(tenant.timeZone).
Contents
- CORS and errors
- The item object
- Endpoints
- Retries and Idempotency-Key
- Versioning and rate limits
- Payment lifecycle
- Integration recipes
- Rules the server enforces
- What the API does not do
CORS and errors
The server reflects the request Origin when the account's Toegestane websites list
(allowedOrigins) is empty
(default = allow any site) or contains that exact origin. Otherwise it answers
403 {"error":"origin_not_allowed"}. OPTIONS is answered with 204 for every endpoint. Allowed request headers:
Content-Type, Idempotency-Key and X-Api-Version.
Every error body has an error field:
| HTTP | error |
Meaning |
|---|---|---|
| 403 | origin_not_allowed |
Origin not in the account's Toegestane websites list. |
| 404 | not_found |
Unknown public key, service, event, package, payment or booking token. |
| 409 | slot_unavailable |
Between choosing a time and submitting, the slot went away. Body has item: the 0-based index of the failing item. Re-fetch slots and let the user pick again. |
| 409 | cannot_reschedule |
This booking cannot move (cancelled/rejected/waitlisted, already started, or inside the account's cancel window). |
| 409 | sold_out / sales_closed |
Event tickets. sold_out has type: the ticket type id. |
| 400 | bad_request |
Idempotency-Key header is empty, longer than 255 characters or not visible ASCII. |
| 400 | bad_request |
X-Api-Version is not a supported version; the body also lists versions. |
| 409 | idempotency_in_progress |
The first request with this Idempotency-Key is still running. Wait (Retry-After: 1) and send it again. |
| 413 | too_large |
POST uploads file bigger than 5 MB. |
| 422 | idempotency_mismatch |
This Idempotency-Key was already used with a different body. Use a new key for a new order. |
| 422 | validation |
Body has fields: {<field>: 'invalid'|'required'|'too_long'} and sometimes item (index). For an intake question the key is the field id and the value a message in the request's Accept-Language (nl or en; English without the header — the widget sends its language). |
| 429 | rate_limited |
More than 120 requests/min from this IP (20/min for booking commits). retryAfter in the body and Retry-After say when to try again. |
| 500 | server |
Bug or DB failure; the message is logged server-side only. |
| 502 | payment_unavailable |
Mollie could not be reached when creating the checkout. The order exists but its payment and bookings are set to rejected, so no visit stays booked; the customer sees this immediately after submitting. |
The item object
A booking request is a list of items. Most integrations send exactly one. The same shape is used
by POST slots, POST recurrence, POST quote and POST bookings.
{
"service": "50000000-0000-0000-0000-000000000001", // required, uuid from GET services
"staff": "e0000000-…-0001", // uuid, or "any"/omitted = server picks
"start": "2026-10-14T09:30:00.000Z", // required for bookings/quote/recurrence; the exact
// `start` of a slot from GET slots
"end": "2026-10-17T00:00:00+02:00", // day services only: the `end` of that same day slot
// (see "Day services" below), sent back verbatim
"persons": 1, // default 1; must fit the service capacity
"extras": { "<extraId>": 2 }, // optional; quantity per extra id
"fields": { "<fieldId>": "noten" }, // optional; answers to the service's intake questions
// (a perPerson field sends an array: one entry per person;
// a file field sends the upload id from POST uploads)
"series": "a", // optional key: this item is one occurrence of a recurring series
"waitlist": true, // optional: join the waiting list instead of booking (slot state "waitlist")
"package": "<48-hex token>", // optional: schedule a visit of an already-bought package (no payment)
"kind": "package" // buy a package itself: {"kind":"package","service":"<packageService>","staff":null}
}
Rules the parser applies (all violations → 422 with fields.item/fields.<name>):
staff: "any"(or missing/empty) means let the engine choose by the service's staff preference. The response always contains the resolvedstaffId/staffName.personsis an integer 1–1000; it must be ≥ the minimum and ≤ the maximum capacity of some staff member that can perform the service. Multi-part (compound/collaborative) services require it to fit every part.extraskeys are extra ids of that service; unknown or non-positive entries are dropped, quantities are clamped to[max(1,minQty), maxQty], and mandatory extras (minQty ≥ 1) are added at their minimum even if the client omits them. The response price proves what was applied. A day service has no extras: anyextras→ 422fields.extras.waitlist: truemay not be combined withseriesorpackage, andpackagevisits are alwayspersons: 1. Day services (below) have no waiting list either: a full rental simply has no slot.- At most 60 items (
MAX_ITEMS) per order. A chain (see below) may have at most 10 items. - A recurring series may have at most 52 occurrences (
MAX_OCCURRENCES).
Endpoints
GET services
The catalogue. Cacheable for 60 seconds; call it once per page load.
GET /api/public/<key>/services
{
"tenant": {
"name": "Kapsalon Demo", "timeZone": "Europe/Amsterdam", "currency": "EUR", "locale": "nl",
"pricesIncludeTax": true, // see 02-concepts.md § Pricing
"depositMode": "deposit_only", // "deposit_only" | "choose_deposit" | "choose_full"
"gateways": ["local", "mollie"], // enabled AND configured; never empty ("local" as fallback)
"cart": true, "chain": true, // may the customer combine items / "Dienst toevoegen"
"recurringPayment": "all", // "all" | "first": what a recurring booking is paid for
"extrasMultiply": true, // extra prices × persons
"showOccupancy": "free" // "off" | "busy" ([3/8]) | "free" (nog 5)
},
"services": [
{ "id": "…", "title": "Knippen dames", "info": "Wassen, knippen en föhnen",
"type": "simple", // simple | compound | collaborative | package
"price": 42.5, "deposit": "100%", "taxRate": 21,
"categoryName": "Knippen", "duration": 45, "durationDays": 0, "capacityMax": 1,
"staff": [{ "id": "…", "name": "Sanne" }],
"extras": [{ "id": "…", "title": "Haarmasker", "price": 7.5, "duration": 10,
"minQty": 0, "maxQty": 1 }],
"fields": [{ "id": "…", "label": "Allergieën", "kind": "text",
"required": true, "perPerson": true, "options": [], "showIf": null }],
"oneBookingPerSlot": false,
"recurrence": ["weekly", "biweekly"] // [] when the account has recurrence off
}
]
}
Service-type-specific fields:
type |
Extra fields | duration means |
|---|---|---|
simple |
— | the slot length |
compound |
parts (count) |
total of all parts + spare time |
collaborative |
parts, hideStaff (bool) |
the longest part |
package |
size, lifeDays (nullable), serviceTitle, serviceId |
duration of the appointment it is for |
Behaviour notes:
- Only
visibility = 'public'services with at least one public staff member are listed. An unknown/unbookableserviceid in later calls answers 404, a private/unknownstaffid yields an empty slot list (never a leak). - Staff lists are the public staff that performs the service, in display order. For
compound/collaborative services only the first part's staff is choosable (
hideStaffasks the UI to hide even that). - Extras of compound/collaborative services carry
partServiceId; send them inextrasexactly as for simple services — the server routes each extra to the part that owns it. - A service with
recurrence: []cannot be repeated, even when the account has recurrence enabled. fieldsare the service's intake questions, in display order (empty array = none). Render them before the customer confirms:requiredmust be answered,kindistext|textarea|select|checkbox|number|date|file(optionsonly forselect), andperPersonasks one answer per person. Send the answers back asfieldson the booking item, keyed by field id; the server validates them again and answers 422 when one is missing or wrong. Afilefield's answer is theidfromPOST uploads— upload first, then send the id. The stored answer is{ "id": "…", "name": "paspoort.png" }.showIf({"field": "<id of an earlier question>", "is": "<answer>"}, ornull) makes a question conditional: show it only while the referenced answer equalsis(compare the trimmed value — a checkbox is"true"/"false", a number is its digits). The server applies the same rule: a question whose condition fails is not required, and an answer sent for it is dropped rather than stored. Send the answers anyway if it is easier — the visibility is the server's call, not yours.
GET slots
Availability for one item, day by day.
GET /api/public/<key>/slots?service=<id>&staff=<id|any>&from=2026-10-14&days=7&persons=1
| Query | Default | Notes |
|---|---|---|
service |
— | required, uuid |
staff |
any |
optional |
from |
today (business time zone) | YYYY-MM-DD |
days |
7 |
1–31; larger windows need several calls (hasMore tells you if the horizon is not reached) |
persons |
1 |
integer |
{
"days": [
{ "date": "2026-10-14",
"slots": [
{ "start": "2026-10-14T07:30:00.000Z", "time": "09:30", "staffId": "…",
"occupied": 3, "capacity": 8, // only when tenant.showOccupancy ≠ "off"
"state": "waitlist", "waiting": 2 // only on waiting-list slots; omit otherwise
}
] }
],
"hasMore": true // false when from+days passed the account's booking horizon (max_days)
}
state: absent = bookable;"waitlist"= the slot is full but a waiting list is open (askwaitlist_enabled/waiting_list_capacity; send the item back withwaitlist: trueto join). Partially filled group slots are simply bookable.- Only days that have slots appear in
days(in date order); a day without free times is omitted, and so is the array itself when nothing is free in the window. Render your calendar from the dates you asked for, not from the returned entries. - Poll-again advice: on 409
slot_unavailable, callGET slotsfor that day and re-render.
Day services
A day service (a boat, a holiday home, a room; durationDays 1–365 on the service, created with the
management API) is rented per whole calendar day. GET slots then answers with day granularity
instead of times: one entry per free start date, where start is that date's local midnight (ISO 8601
with the business offset, e.g. 2026-10-14T00:00:00+02:00) and end is start plus durationDays
calendar days (again local midnight):
{ "days": [ { "date": "2026-10-14",
"slots": [ { "start": "2026-10-14T00:00:00+02:00", "time": "00:00", "staffId": "…",
"end": "2026-10-17T00:00:00+02:00" } ] } ], "hasMore": true }
A start date is offered only when every day of the span [start, start + durationDays) is free for
one staff member: no appointment that blocks (existing bookings and events that block support staff), no
day off for that staff member or the whole account. Business hours and the weekly schedule do not
apply — a rental is a day, not a shift. now + minPriorMinutes, the maxDays horizon and the
per-staff capacity all apply as usual. There is no occupied/capacity on a day slot.
Booking works like any other item — send the slot's start and end back verbatim; the server
re-checks the whole span inside the transaction, so a second booking overlapping the same staff member's
span is refused with 409 slot_unavailable. The price is staff price × durationDays × persons.
Day services cannot be combined into a chain or cart (items with a day service and any other item →
422), and POST slots reports them the same way as GET slots.
POST slots (chain / holds)
For a chain (one customer, several services back-to-back, tenant.chain) or to search while the
customer already holds items in a cart (hold, tenant.cart).
POST /api/public/<key>/slots
{
"items": [ { "service": "…", "staff": "any", "persons": 1 } ], // 1 item = normal, >1 = chain
"hold": [ { "service": "…", "staff": "…", "start": "…", "persons": 1 } ],
"from": "2026-10-14", "days": 7
}
Response is the same shape as GET slots; chain slots additionally carry
parts: [{ "service", "staff", "start" }] — the resolved consecutive appointments. A hold is an item
the user already selected; the search treats it as occupied and never joins it. hold items must have
a concrete staff id (the staffId of their slot) and are ignored for waiting-list/package items.
A day service must be the only item (chain → 422); its response is the same local-midnight span shape
as GET slots shows.
POST recurrence
Plan a recurring series from one chosen slot (spec: Bookly-style repeat with conflict handling).
POST /api/public/<key>/recurrence
{
"item": { "service": "…", "staff": "any", "start": "2026-10-14T09:30:00.000Z", "persons": 1 },
"rule": { "freq": "weekly", "every": 2, "on": [2], "until": "2026-12-31", "count": 10 },
"hold": [ … ]
}
rule:
| Field | Values | Notes |
|---|---|---|
freq |
daily | weekly | biweekly | monthly |
must be offered by the service (recurrence[]) |
every |
1–30 | interval, e.g. weekly + every: 2 |
on |
array of ISO weekdays 1–7 | for weekly frequencies |
monthly |
{ "day": 14 } or { "nth": 2, "weekday": 2 } (nth ∈ 1–4, −1 = last) |
day 31 skips months without it |
until |
YYYY-MM-DD |
capped at the booking horizon |
count |
1–52 | alternative to until |
{ "occurrences": [
{ "date": "2026-10-28", "start": "2026-10-28T09:30:00.000Z", "time": "09:30",
"staffId": "…", "anotherTime": false, "state": "waitlist"? }
] }
anotherTime: true means the preferred time was taken and the engine moved the occurrence (same
service, possibly another staff member) on the same day or later, up to until. start: null means
no time was found at all — show it and let the user drop that occurrence. Occurrences on a full
group slot come back with state: "waitlist": send them in the order with waitlist: true (and the
same series key) if the customer accepts a waiting-list place.
To book a series, send every occurrence as its own item with the same series key plus
series: { "<key>": rule } in the order body (see POST bookings).
POST quote
Price for an order that is already fully resolved (items with start). Use it to show the price
before submitting, and again after a coupon is entered. The server never trusts client amounts;
this endpoint and POST bookings compute the same numbers.
POST /api/public/<key>/quote
{ "items": [ … with "start" … ], "coupon": "WELKOM10", "payFull": false, "gateway": "mollie" }
{
"subtotal": 42.5, "discount": 4.25, "tax": 6.65, "total": 38.25,
"payNow": 38.25, // what is asked online now (Mollie) or at the desk
"due": 0, // pay later (deposit mode)
"paidType": "in_full", // "in_full" | "deposit"
"coupon": { "code": "WELKOM10", "status": "ok" }, // or "invalid" / "expired", null when not sent
"items": [{ "price": 42.5, "extras": 0, "payNow": true }],
"onSite": 0 // amount of recurring occurrences not in this payment ("first" mode)
}
POST uploads
Uploads a customer file for a file intake question (see GET services). Call this first, then send
the returned id as that field's answer in POST bookings.
POST /api/public/<key>/uploads
Content-Type: multipart/form-data
One file part. Limits: at most 5 MB; image/jpeg, image/png, image/webp or
application/pdf only.
// 201
{ "id": "7c9e…", "name": "paspoort.png", "size": 84231 }
// 413 { "error": "too_large" }
// 422 { "error": "validation", "fields": { "file": "file must be a JPEG, PNG, WebP or PDF" } }
// 422 { "error": "validation", "fields": { "file": "file is required" } }
- The file is stored in a private bucket; it is never public. Download links are signed and short-lived, so only the admin (and the mail/ICS flows) can read it back.
- An upload that is never used in a booking is deleted after 24 hours by the nightly cron. An upload can be attached to at most one booking: re-using an id answers 422 naming the field.
- The
idis a uuid; the response is not idempotent — a retried upload makes a second file (the first is swept).
POST bookings
Creates the order. One request = one order = one payment. Under the hood this re-checks every item against the live schedule and writes everything in one database transaction, so two simultaneous customers can never get the same seat.
POST /api/public/<key>/bookings
{
"items": [ … with "start" (and optional "series", "waitlist", "package") … ],
// answers to the service's intake questions, by field id (see GET services):
// "fields": { "<fieldId>": "noten", "<perPersonFieldId>": ["noten", "geen"] }
"series": { "a": { "freq": "weekly", "until": "2026-12-31" } },
"name": "Ada Jansen", "email": "ada@example.com", "phone": "+31612345678", "notes": "…",
"gateway": "local", // "local" | "mollie"; default = first of tenant.gateways
"coupon": "WELKOM10",
"payFull": false, // deposit mode choose_*: pay in full instead of the deposit
"returnUrl": "https://shop.example.com/bedankt" // required for Mollie
}
The pre-order body shape ({service, staff, start, persons, name, email, …} without items) is still
accepted for a single booking.
Responses
// 201 — booked
{
"id": "…", "status": "approved", "start": "2026-10-14T09:30:00.000Z",
"serviceTitle": "Knippen dames", "staffName": "Sanne", // first item, for single-item clients
"orderToken": "…", // 48-hex; also used in cancel links
"fields": { "<fieldId>": "noten" }, // the stored answers of the first item
"items": [ { "id": "…", "status": "approved", "start": "…",
"serviceTitle": "…", "staffName": "…",
"fields": { "<fieldId>": { "id": "…", "name": "paspoort.png" } } } ], // file answers store id + name
"payment": { "status": "pending", "checkoutUrl": "https://www.mollie.com/…" } // mollie
// "payment": { "status": "completed" } free order
// "payment": { "status": "pending" } local: admin marks it paid at the desk
}
// 409
{ "error": "slot_unavailable", "item": 0 }
// 422
{ "error": "validation", "fields": { "email": "invalid", "coupon": "invalid" }, "item": 0? }
// 422 — intake question missing or wrong: `fields` maps the *field id* to an English message
{ "error": "validation", "fields": { "9f1c…": "Allergieën is required" }, "item": 0 }
// 422 — file answer: unknown upload id, another account's file, or an id already on another booking
{ "error": "validation", "fields": { "9f1c…": "Allergieën must be an uploaded file id" }, "item": 0 }
returnUrlmust be http(s), and its origin must be in Toegestane websites (when set) or equal the request'sOriginheader (when the allowlist is empty). Otherwisefields.returnUrl = "invalid".- With
gateway: "mollie"the response'scheckoutUrlis where you send the browser. The server appendsnb_pay=<paymentToken>to yourreturnUrlwhen Mollie redirects back — see Payment lifecycle. - Confirmation mail: sent immediately for local/free orders; for Mollie orders after the payment completes (the customer must not get "confirmed" before paying).
statusispendingorapprovedper the account's default status (Instellingen → Afspraken),waitlistedfor waiting-list items, or the configured success status for a free order when the business set one.
POST bookings/{token}/reschedule
Moves the customer's own booking to another time — the API behind the /b/<token> page. The booking
is addressed by its 48-hex token (the one in the cancel link the confirmation mail sends; the
management API's GET /bookings/{id} also shows it).
POST /api/public/<key>/bookings/<token>/reschedule
{
"start": "2026-10-20T09:30:00.000Z", // exact `start` of a slot from GET slots
"staff": "e0000000-…-0001" // optional: another staff member who offers the service
}
// 200 — moved; `start` as requested, `end` keeps the appointment's duration
{ "start": "2026-10-20T09:30:00.000Z", "end": "2026-10-20T10:00:00.000Z" }
// 404 { "error": "not_found" }
// 409 { "error": "cannot_reschedule" }
// 409 { "error": "slot_unavailable" }
// 422 { "error": "validation" }
- The booking keeps its service, staff and duration;
staffmoves it to another staff member that offers that service. - The new time is re-checked exactly like a new booking — capacity, overlap, schedule and holidays —
and there is no
force: a time that does not fit answers 409slot_unavailable. Re-fetch slots and pick again. - The same limit as cancelling applies: the booking must be
pendingorapprovedand the call must be at leastcancelMinMinutes(Instellingen → Bedrijf en e-mail) before the start. Otherwise 409cannot_reschedule. Waitlisted bookings cannot move; cancel and re-book instead. - The customer and staff get the usual
status_changedmails (with the new time and ICS); the old slot is free for others immediately.
GET payments/{token}
GET /api/public/<key>/payments/<48-hex token>
{
"status": "completed", // pending | completed | rejected | refunded
"booking": { "serviceTitle": "…", "staffName": "…", "start": "…", "durationDays": 0 }, // first booking, or null
"items": [{ "id": "…", "status": "approved", "start": "…", "durationDays": 0, "serviceTitle": "…", "staffName": "…" }], // durationDays > 0 = a rental: start → start + that many days
"attendees": [{ "code": "ABCD-EFGH" }] // event tickets, only once approved (paid)
}
The payment token is what the widget polls after a Mollie redirect. If the status is still pending
and the type is mollie, this call first asks Mollie directly (webhooks may lag), so polling this
endpoint is a valid replacement for the webhook. Poll every 2 s for up to ~30 s.
GET packages/{token}
Package overview for the personal link that is mailed on purchase (/p/<token> renders it).
{
"service": { "id": "…", "title": "Knippen heren", "duration": 30, "fields": [ /* the base service's intake questions, same shape as GET services */ ] },
"packageTitle": "5× Knippen heren",
"staff": { "id": "…", "name": "Sanne" }, // null = customer picks
"size": 5, "used": 2, "remaining": 3,
"expiresOn": "2027-04-01", // null = not started yet or never expires
"firstName": "Ada",
"visits": [{ "start": "2026-10-14T09:30:00.000Z", "status": "approved" }]
}
To schedule a visit, book normally with the item's "package": "<token>" field; the visit is not
priced and needs no payment. A visit books the base service, so its intake questions apply: the
fields above are what to render, and POST bookings answers them exactly like an ordinary booking
(including files through POST uploads).
GET events
Published events whose sales are still open.
[{ "id": "…", "title": "Zonsondergangtocht", "info": "…", "imageUrl": null,
"start": "2026-10-25T17:30:00.000Z", "end": "2026-10-25T19:30:00.000Z",
"tickets": [{ "id": "…", "title": "Volwassene", "price": 25, "left": 26 }] }]
POST events/{id}/tickets
POST /api/public/<key>/events/<eventId>/tickets
{ "tickets": [{ "type": "<ticketTypeId>", "qty": 2 }],
"name": "…", "email": "…", "phone": "…",
"gateway": "mollie", "returnUrl": "https://…" }
// 201
{ "orderToken": "…",
"attendees": [{ "code": "ABCD-EFGH" }], // [] until the order is approved (free = immediately,
// local = after "Betaling voltooien", mollie = after paying)
"payment": { "status": "pending", "checkoutUrl": "…" } }
// 409 { "error": "sold_out", "type": "<ticketTypeId>" } | { "error": "sales_closed" }
1–20 distinct ticket types per request, 1–50 per type. All tickets in one order are paid in full
(deposits do not apply). Check GET packages|payments/<token> for the final codes; the confirmation
mail repeats them. Ticket codes use the event's ticket_mask (****-**** by default) and are unique
per event.
Retries and Idempotency-Key
A booking request can time out after the server already wrote the order. Retrying blindly would book
twice. Send an Idempotency-Key header on POST bookings and POST events/<id>/tickets, and a
retry with the same key returns the original result instead of creating a second order:
const key = crypto.randomUUID(); // one key per checkout attempt, generated before the first try
async function book(body) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${API}/bookings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify(body), // the same body on every attempt
}).catch(() => null); // network error / timeout: retry with the same key
const busy = res?.status === 409 && (await res.clone().json()).error === 'idempotency_in_progress';
if (res && res.status < 500 && !busy) return res; // 201, 409 slot_unavailable, 422 …: final
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
}
throw new Error('booking failed, try again later');
}
- Same key + same body → the stored response: same status, same body (same
orderToken, samecheckoutUrl), plus the headerIdempotent-Replayed: true. Nothing is written again and no second mail is sent. Validation errors and409 slot_unavailableare replayed too — fix the input and use a new key. - Same key + different body →
422 {"error":"idempotency_mismatch"}. - Same key while the first request is still running →
409 {"error":"idempotency_in_progress"}withRetry-After: 1. - A
5xxanswer is not stored: the key is released and the retry runs for real. - Keys are 1–255 visible ASCII characters (a UUID is ideal), scoped to your account, and kept for 24 hours; after that the same key counts as new. Without the header nothing changes.
- "Same body" means byte-identical: build the JSON once and resend that string.
The management API accepts the same header on every POST.
Versioning and rate limits
Every request may carry an X-Api-Version header; absent means the latest version. The current
(and only) version is 2026-10-13; an unknown value is answered with
400 {"error":"bad_request","versions":["2026-10-13"]}. Every response carries X-Api-Version plus
the rate-limit state, except that 400 itself (no version to echo) and OPTIONS preflights, which
are free and uncounted:
X-Api-Version: 2026-10-13
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1791897600 # epoch seconds: the window resets then
Requests are counted per client IP address in a one-minute window: 120/min in general and
20/min for the booking commits (POST bookings, POST bookings/{token}/reschedule,
POST events/{id}/tickets), which lock
schedules, send mail and start payments. Over the limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
{"error":"rate_limited","retryAfter":42}
Wait Retry-After seconds before retrying; the counter resets when the minute is over. The limits
live in the server process: a restart resets them, and running more than one instance multiplies
the budget. They slow down runaway scripts, not a determined attacker.
Breaking changes never arrive silently: they get a new dated version, and the previous version stays
supported for at least six months (announced with Deprecation/Sunset headers when that
happens). The generated schema for this version:
app.nisebookings.com/openapi.json.
Payment lifecycle
┌ free order (total 0) ──────► completed (mail immediately)
bookings ──────────►├ local ────────────────────► pending (admin: "Betaling voltooien" → completed)
└ mollie ─► createPayment ─► pending (redirect to checkoutUrl)
│
webhook ────────┼──────────► paid → completed (+ mails)
or │ failed/ → rejected (+ bookings rejected,
poll payments/ │ canceled/ coupon use given back)
<token> │ expired
└ timeout (payment timeout setting,
default 30 min, checked every minute) → sync, then rejected
- You only connect your Mollie API key (Instellingen → Betalingen). The service registers its own Mollie webhook on every payment and reconciles the status; there is nothing to configure on the Mollie side.
- A payment that arrives after rejection (customer paid too late) restores the bookings if the
slot is still free; otherwise the admin sees
late_paidon the payment and refunds manually. - Refunds are an admin action (Mollie refund API);
GET payments/<token>then reportsrefunded.
Integration recipes
1. Own front-end instead of the widget (single booking)
const API = 'https://app.nisebookings.com';
const KEY = '<your public key>';
const j = (r) => r.json();
const { tenant, services } = await fetch(`${API}/api/public/${KEY}/services`).then(j);
const svc = services.find((s) => s.title === 'Knippen dames');
const { days } = await fetch(
`${API}/api/public/${KEY}/slots?service=${svc.id}&from=${todayISO()}&days=14&persons=1`).then(j);
const slot = days.flatMap((d) => d.slots)[0]; // whatever your UI picked
const res = await fetch(`${API}/api/public/${KEY}/bookings`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ service: svc.id, staff: slot.staffId, start: slot.start, persons: 1 }],
name: 'Ada Jansen', email: 'ada@example.com', phone: '+31612345678',
gateway: 'local',
}),
});
if (res.status === 409) { /* re-fetch slots; the seat is gone */ }
const order = await res.json(); // order.items[0].status, order.orderToken
Always send back the exact start you got from GET slots and the staffId you were shown (or
"any"): the server re-resolves and re-checks it. Do not construct times yourself.
2. Mollie checkout
const res = await fetch(`${API}/api/public/${KEY}/bookings`, { … body: {
items, name, email, gateway: 'mollie',
returnUrl: location.origin + '/booking/thanks', // origin must be allowed (see above)
}});
const order = await res.json();
location.href = order.payment.checkoutUrl; // Mollie-hosted payment page
Mollie sends the customer back to returnUrl?nb_pay=<token>. On your thank-you page, poll
GET payments/<token>:
const t = new URLSearchParams(location.search).get('nb_pay');
while (true) {
const p = await fetch(`${API}/api/public/${KEY}/payments/${t}`).then(j);
if (p.status !== 'pending') return render(p); // completed | rejected | refunded
await new Promise((r) => setTimeout(r, 2000));
}
Webhook delay never matters: this endpoint asks Mollie itself while the status is pending.
3. Recurring series
const rule = { freq: 'weekly', on: [2], until: '2026-12-31' }; // Tuesdays until that date
const { occurrences } = await fetch(`${API}/api/public/${KEY}/recurrence`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item: { …firstItem }, rule }),
}).then(j);
const items = occurrences.filter((o) => o.start)
.map((o) => ({ service: svc.id, staff: o.staffId, start: o.start, persons: 1, series: 'a',
...(o.state === 'waitlist' && { waitlist: true }) }));
await fetch(`${API}/api/public/${KEY}/bookings`, { … body: {
items, series: { a: rule }, name, email, gateway: 'mollie', returnUrl,
}});
The whole series is one order and (with recurringPayment: "all") one payment. With "first",
only the earliest occurrence is charged online; quote.onSite shows the rest.
4. Waiting list
A full group slot with an open waiting list appears as { state: 'waitlist', waiting: n }. Book it
with waitlist: true; the status becomes waitlisted, nothing is charged, and when a seat frees the
customer receives a waitlist_offer mail with a /w/<token> link (first come, first served).
5. Sell event tickets
Use GET events + POST events/<id>/tickets with the same gateway/returnUrl rules. After the
payment completes, redirect-poll GET payments/<token> for the attendee codes.
Rules the server enforces
These are the invariants an integration must not fight — they also explain the error codes.
- Never trust the client.
POST bookingsre-resolves staff, extras, persons and the price from the database. Sending a different price, staff or duration changes nothing. - The slot must still be free at commit time. Every item is re-checked and then
written inside one transaction that locks that staff member's schedule. Two bookings
for the same seat can never both succeed: the loser gets 409
slot_unavailable. - Capacity and blocking. A slot is occupied by appointments whose bookings are
pendingorapproved, plus the service's padding before/after, plus extras duration.capacity_maxis the number of persons; a join of an existing group appointment is only allowed when service, start, duration, extras andoneBookingPerSlotall permit it. - Holds. Cart items and chain parts the client sends back count as occupied while the user is deciding; they are not real appointments and can never be joined.
- Time. All instants are UTC; day boundaries, schedules, holidays and "today" are computed in
the business time zone.
from/untilare business-local dates. - Day services occupy calendar days. A day service's booking blocks its whole span of days for one
staff member regardless of hours; the start and end bounds are local midnights,
durationDaysapart, andendcomes from the server's own slot search — a hand-made span is refused with 409. - Rate and size limits. 60 items per order, 10 chain parts, 52 occurrences, 31 days per slot
call, 2000-character return URLs and notes, 5 MB per upload (JPEG/PNG/WebP/PDF only). Requests from one IP address are capped at
120/min (booking commits 20/min); send
X-Api-Versionto pin a version — see Versioning and rate limits. - Idempotency is opt-in. Without an
Idempotency-Keyheader everyPOST bookingscreates a new order, so a blind retry after a timeout books twice. Send the header and retry with the same key — see Retries and Idempotency-Key.
What the API does not do
Deliberately out of scope for the public API (do these through the admin or your own layer):
- No customer accounts or login. Single bookings have a magic cancel/reschedule link
(
/b/<booking token>, also the address forPOST bookings/{token}/reschedule), orders an order-level one (/o/<order token>), both mailed by the default templates (see06-notifications.md). - No payment method list beyond
local/mollie; no iDEAL/card selection — Mollie's own page does that. - No statistics endpoints. Configuration that belongs to the business rather than the customer — services, staff, schedules, opening hours — lives in the management API (a scoped key, called server-side); the rest is the back office.
- No bulk import/export. Outbound webhooks (booking, ticket and payment events) are registered with the management API, not here (Mollie's payment callbacks are handled by the service).
- No event coupons, no gift cards, no tips (not planned).
- Day services ship in the widget and the admin (widget 0.4.0): the widget shows a start-date picker with the span and the price per day, the admin's service form has Verhuur (dagen), the appointment form takes only a date for a rental and the calendar labels the span. Still not included: changing the number of days of an existing rental (a booking's span is fixed when it is made — move the start or cancel and rebook), and mixing a rental with other items in a chain or cart through the API.