# 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`](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](../api/README.md), 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`), except `POST uploads` which takes
  `multipart/form-data`. Responses are JSON with `Cache-Control: no-store` — except
  `GET 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 from `GET services` (`tenant.timeZone`).

## Contents

- [CORS and errors](#cors-and-errors)
- [The item object](#the-item-object)
- [Endpoints](#endpoints)
  - [GET services](#get-services)
  - [GET slots](#get-slots)
  - [POST slots (chain / holds)](#post-slots-chain--holds)
  - [POST recurrence](#post-recurrence)
  - [POST quote](#post-quote)
  - [POST uploads](#post-uploads)
  - [POST bookings](#post-bookings)
  - [POST bookings/{token}/reschedule](#post-bookingstokenreschedule)
  - [GET payments/{token}](#get-paymentstoken)
  - [GET packages/{token}](#get-packagestoken)
  - [GET events](#get-events)
  - [POST events/{id}/tickets](#post-eventsidtickets)
- [Retries and Idempotency-Key](#retries-and-idempotency-key)
- [Versioning and rate limits](#versioning-and-rate-limits)
- [Payment lifecycle](#payment-lifecycle)
- [Integration recipes](#integration-recipes)
- [Rules the server enforces](#rules-the-server-enforces)
- [What the API does not do](#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`.

```jsonc
{
  "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 resolved `staffId`/`staffName`.
- `persons` is 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.
- `extras` keys 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: any `extras` → 422 `fields.extras`.
- `waitlist: true` may not be combined with `series` or `package`, and `package` visits are always
  `persons: 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
```

```jsonc
{
  "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/unbookable `service` id in later calls answers 404, a private/unknown `staff` id 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 (`hideStaff` asks the UI
  to hide even that).
- Extras of compound/collaborative services carry `partServiceId`; send them in `extras` exactly 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.
- `fields` are the service's intake questions, in display order (empty array = none). Render them
  before the customer confirms: `required` must be answered, `kind` is
  `text` | `textarea` | `select` | `checkbox` | `number` | `date` | `file` (`options` only for `select`),
  and `perPerson` asks one answer per person. Send the answers back as `fields` on the booking item,
  keyed by field id; the server validates them again and answers 422 when one is missing or wrong.
  A `file` field's answer is the `id` from [`POST uploads`](#post-uploads) — upload first, then send
  the id. The stored answer is `{ "id": "…", "name": "paspoort.png" }`.
  **`showIf`** (`{"field": "<id of an earlier question>", "is": "<answer>"}`, or `null`) makes a
  question conditional: show it only while the referenced answer equals `is` (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 |

```jsonc
{
  "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 (ask
  `waitlist_enabled`/`waiting_list_capacity`; send the item back with `waitlist: true` to 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`, call `GET slots` for 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):

```jsonc
{ "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` |

```jsonc
{ "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" }
```

```jsonc
{
  "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.

```jsonc
// 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 `id` is 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**

```jsonc
// 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 }
```

- `returnUrl` must be http(s), and its origin must be in **Toegestane websites** (when set) or equal
  the request's `Origin` header (when the allowlist is empty). Otherwise `fields.returnUrl = "invalid"`.
- With `gateway: "mollie"` the response's `checkoutUrl` is where you send the browser. The server
  appends `nb_pay=<paymentToken>` to your `returnUrl` when Mollie redirects back — see
  [Payment lifecycle](#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).
- `status` is `pending` or `approved` per the account's default status (Instellingen → Afspraken),
  `waitlisted` for 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
}
```

```jsonc
// 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; `staff` moves 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 409 `slot_unavailable`. Re-fetch slots
  and pick again.
- The same limit as cancelling applies: the booking must be `pending` or `approved` and the call must
  be at least `cancelMinMinutes` (Instellingen → Bedrijf en e-mail) before the start. Otherwise 409
  `cannot_reschedule`. Waitlisted bookings cannot move; cancel and re-book instead.
- The customer and staff get the usual `status_changed` mails (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>
```

```jsonc
{
  "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).

```jsonc
{
  "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.

```jsonc
[{ "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://…" }
```

```jsonc
// 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:

```js
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`, same
  `checkoutUrl`), plus the header `Idempotent-Replayed: true`. Nothing is written again and no second
  mail is sent. Validation errors and `409 slot_unavailable` are 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"}`
  with `Retry-After: 1`.
- A `5xx` answer 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](../api/README.md) 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](https://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_paid` on the payment and refunds manually.
- Refunds are an admin action (Mollie refund API); `GET payments/<token>` then reports `refunded`.

## Integration recipes

### 1. Own front-end instead of the widget (single booking)

```js
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

```js
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>`:

```js
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

```js
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 `code`s.

## Rules the server enforces

These are the invariants an integration must not fight — they also explain the error codes.

1. **Never trust the client.** `POST bookings` re-resolves staff, extras, persons and the price from
   the database. Sending a different price, staff or duration changes nothing.
2. **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`.
3. **Capacity and blocking.** A slot is occupied by appointments whose bookings are `pending` or
   `approved`, plus the service's padding before/after, plus extras duration. `capacity_max` is the
   number of persons; a join of an existing group appointment is only allowed when service, start,
   duration, extras and `oneBookingPerSlot` all permit it.
4. **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.
5. **Time.** All instants are UTC; day boundaries, schedules, holidays and "today" are computed in
   the business time zone. `from`/`until` are business-local dates.
6. **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, `durationDays` apart,
   and `end` comes from the server's own slot search — a hand-made span is refused with 409.
6. **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-Version` to pin a version — see
   [Versioning and rate limits](#versioning-and-rate-limits).
7. **Idempotency is opt-in.** Without an `Idempotency-Key` header every `POST bookings` creates 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](#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 for `POST bookings/{token}/reschedule`), orders an
  order-level one (`/o/<order token>`), both mailed by the default templates (see `06-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](../api/README.md) (a
  scoped key, called server-side); the rest is the back office.
- No bulk import/export. Outbound [webhooks](10-webhooks.md) (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.
