# Management API: quickstart for coding agents

With one API key you can take an empty account to bookable without touching the admin. The full reference is
[`openapi.yaml`](openapi.yaml) / [`openapi.json`](openapi.json) (OpenAPI 3.1). Every step below (catalogue from
scratch, a real booking through the public API, cancellation) is covered by an automated end-to-end test.

## 1. Get a key

In the admin, go to **Settings → API → New key**. Pick scopes and copy the key: it is shown **once** (`nk_` + 43 chars).

No account yet, or just testing? `curl -s -X POST https://app.nisebookings.com/api/sandbox` (no auth) creates a
24-hour [sandbox](https://guide.nisebookings.com/08-sandbox.html) and returns an all-scopes `apiKey` plus its `publicKey`. Its mail is never
delivered and its Mollie checkout is a fake page; `DELETE /sandbox` (below) removes it early.

| Scope | Gives |
|---|---|
| `catalog:read` / `catalog:write` | categories, services (+ `/extras`, `/fields`, `/parts`), staff, staff↔service links, schedules, holidays, coupons, events (+ ticket types, event staff) |
| `settings:read` / `settings:write` | `/settings`, `/business-hours`, `/notifications`, `/outbox` (read), `/webhooks` |
| `customers:read` / `customers:write` | `/customers` |
| `bookings:read` / `bookings:write` | `/appointments`, `/bookings`, `/packages`, event attendees, waiting list (`give-seat`) |
| `payments:read` / `payments:write` | `/payments` (+ complete, refund), `PUT/DELETE /settings/mollie-key` |

`write` implies `read`. To configure an account from scratch, use `catalog:write` + `settings:write`; add
`customers:write` + `bookings:write` to plan appointments and `payments:write` for payments and the Mollie key.

```sh
export API=https://app.nisebookings.com/api/v1 KEY=nk_...
H=(-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json')
curl -s "${H[@]}" $API/me     # {"tenant":{"id","name","timeZone","currency"},"scopes":[...]}  (works with any valid key)
```

## 2. Configure

```sh
# Settings (partial). The public key for step 3 comes from here.
curl -s "${H[@]}" -X PATCH $API/settings -d '{"timeZone":"Europe/Amsterdam","minPriorMinutes":60,"maxDays":90}'
curl -s "${H[@]}" -X PUT $API/business-hours -d '{"days":[{"weekday":1,"start":"09:00","end":"18:00"}]}'

# Catalogue
CAT=$(curl -s "${H[@]}" -X POST $API/categories -d '{"name":"Haar"}' | jq -r .id)
SVC=$(curl -s "${H[@]}" -X POST $API/services -d "{\"title\":\"Knippen\",\"categoryId\":\"$CAT\",\"duration\":30,\"price\":25}" | jq -r .id)
# A day-based service (a boat, a room, rented per whole day): add "durationDays": 3 → price × 3 per booking.
# Booked through the public API and the admin; business hours never apply, holidays and existing appointments do.
# POST /appointments takes the local midnight of the first day as start; without end the span is durationDays days.
BOAT=$(curl -s "${H[@]}" -X POST $API/services -d "{\"title\":\"Boot\",\"duration\":1440,\"durationDays\":3,\"price\":150}" | jq -r .id)
STAFF=$(curl -s "${H[@]}" -X POST $API/staff -d '{"fullName":"Anna de Vries","email":"anna@example.com"}' | jq -r .id)
curl -s "${H[@]}" -X PUT $API/staff/$STAFF/services/$SVC -d '{"price":25}'

# Working week (PUT replaces the whole week; a missing weekday = day off; weekday 1 = Monday)
curl -s "${H[@]}" -X PUT $API/staff/$STAFF/schedule \
  -d '{"days":[{"weekday":1,"start":"09:00","end":"17:00","breaks":[{"start":"12:00","end":"12:30"}]}]}'

# Day off: kind once | yearly | exception; staffId null = company-wide
curl -s "${H[@]}" -X POST $API/holidays -d "{\"date\":\"2026-12-25\",\"kind\":\"yearly\",\"staffId\":null}"
```

A service can be booked once it is `public`, has at least one public staff member linked, and that staff member has a schedule.

## 3. Book through the public API (no key, the account's `publicKey`)

```sh
PK=$(curl -s "${H[@]}" $API/settings | jq -r .publicKey); P=https://app.nisebookings.com/api/public/$PK
curl -s "$P/services"
curl -s "$P/slots?service=$SVC&staff=$STAFF&from=2026-10-13&days=7"   # {days:[{date,slots:[{start,time,staffId}]}],hasMore}
curl -s -X POST "$P/bookings" -H 'Content-Type: application/json' \
  -d "{\"items\":[{\"service\":\"$SVC\",\"staff\":\"$STAFF\",\"start\":\"<slot.start>\"}],\"name\":\"Jan\",\"email\":\"jan@example.com\",\"phone\":\"\"}"
```

## 4. Full setup: everything else the admin does

```sh
# Coupons (code stored upper case) and a batch of unique codes from a mask
curl -s "${H[@]}" -X POST $API/coupons -d '{"code":"WELKOM10","discount":10,"usageLimit":100}'
curl -s "${H[@]}" -X POST $API/coupons/generate -d '{"mask":"ZOMER-****","count":20,"discount":15}'

# Extras on a simple service; parts of a compound/collaborative service (replaces all, recalculates duration)
curl -s "${H[@]}" -X POST $API/services/$SVC/extras -d '{"title":"Wassen","price":5,"duration":10}'
curl -s "${H[@]}" -X PUT $API/services/$COMBO/parts -d "{\"parts\":[{\"kind\":\"service\",\"serviceId\":\"$SVC\"},{\"kind\":\"spare_time\",\"minutes\":15}]}"

# Intake questions (replaces all; send an id back to keep it): the widget renders them, the answers ride along
# with the booking item as {"fields":{"<fieldId>":"..."}} and come back on GET /bookings/{id}.
# kind "file" (no options) takes an attachment; its answer is stored as {"id":"<uploadId>","name":"<file>"}
# and the customer uploads through the public API first (POST /api/public/<key>/uploads, ≤5 MB, JPEG/PNG/WebP/PDF).
# showIf {"field":"<earlier id>","is":"<answer>"} makes a question conditional (e.g. only ask the tint when the
# colour is rood); a question whose condition fails is not asked and its answer is not stored.
curl -s "${H[@]}" -X PUT $API/services/$SVC/fields \
  -d '{"fields":[{"label":"Allergieën","kind":"text","required":true,"perPerson":true},{"label":"Kies een kleur","kind":"select","options":["rood","blauw"]}]}'

# Mail templates (codes per type: GET /notifications/codes?type=reminder)
curl -s "${H[@]}" -X POST $API/notifications -d '{"type":"reminder","name":"Herinnering","subject":"Morgen: {service_name}","body":"Tot morgen!","toCustomer":true,"attachIcs":true,"reminderMode":"at_hour","reminderAtHour":18,"reminderDaysBefore":1}'

# Events with ticket types and staff
EVT=$(curl -s "${H[@]}" -X POST $API/events -d '{"title":"Workshop","startAt":"2026-11-20T19:00:00+01:00","endAt":"2026-11-20T21:00:00+01:00","status":"published"}' | jq -r .id)
curl -s "${H[@]}" -X POST $API/events/$EVT/ticket-types -d '{"title":"Standaard","price":25,"quantity":30}'
curl -s "${H[@]}" -X PUT $API/events/$EVT/staff -d "{\"staff\":[{\"staffId\":\"$STAFF\",\"role\":\"organizer\"}]}"

# Customers and appointments
CUS=$(curl -s "${H[@]}" -X POST $API/customers -d '{"fullName":"Jan Jansen","email":"jan@example.com"}' | jq -r .id)
APP=$(curl -s "${H[@]}" -X POST $API/appointments \
  -d "{\"serviceId\":\"$SVC\",\"staffId\":\"$STAFF\",\"customerId\":\"$CUS\",\"start\":\"2026-10-14T09:00:00+02:00\",\"persons\":1,\"notify\":true}")
APP_ID=$(jq -r .id <<<"$APP"); BOOKING=$(jq -r .bookingId <<<"$APP")
curl -s "${H[@]}" -X PATCH $API/appointments/$APP_ID -d '{"start":"2026-10-14T07:00:00+02:00"}'              # 422 warnings (outside schedule)
curl -s "${H[@]}" -X PATCH $API/appointments/$APP_ID -d '{"start":"2026-10-14T07:00:00+02:00","force":true}' # saved anyway
curl -s "${H[@]}" -X PATCH $API/bookings/$BOOKING -d '{"status":"cancelled","notify":true}'
curl -s "${H[@]}" -X DELETE "$API/appointments/$APP_ID?notify=false"
# Bookings on their own: one row per booking with its appointment. Filters: status, from/to (appointment start), customerId, staffId, serviceId
curl -s "${H[@]}" "$API/bookings?customerId=$CUS&status=cancelled"
# A recurring series in one call: 8 weekly appointments sharing a seriesId (the 201 also returns appointmentIds)
curl -s "${H[@]}" -X POST $API/appointments \
  -d "{\"serviceId\":\"$SVC\",\"staffId\":\"$STAFF\",\"customerId\":\"$CUS\",\"start\":\"2026-10-14T09:00:00+02:00\",\"recurrence\":{\"freq\":\"weekly\",\"count\":8}}"

# Packages, payments
curl -s "${H[@]}" -X POST $API/packages -d "{\"customerId\":\"$CUS\",\"serviceId\":\"$PKG_SVC\",\"payment\":\"local\"}"
curl -s "${H[@]}" "$API/payments?status=pending"
curl -s "${H[@]}" -X POST $API/payments/$PAY/complete     # local payment paid at the desk
```

**Warnings and `force`.** Appointment create/update run the admin's checks: overlap with another appointment of the
staff member, duration different from the service, outside the schedule or in a break, on a day off. Any of these →
`422 {"error":{"code":"warnings","fields":{"schedule":"..."}}}` (keys: `overlap`, `duration`, `schedule`, `holiday`).
Resend with `"force": true` to save anyway. Hard limits are not warnings: capacity (`409 capacity_exceeded`), a slot
taken in the meantime (`409 slot_unavailable`), package problems (`422 package_used_up|package_expired|package_invalid`).

**`notify`.** `POST /appointments`, `POST /appointments/{id}/bookings`, `PATCH /bookings/{id}`, `POST /bookings/{id}/give-seat`
and `POST /packages` take `notify` in the body; `DELETE /appointments/{id}?notify=true`. (`PATCH /appointments/{id}` does not mail.)
`true` mails the customer exactly like the admin's "inform the customer" checkbox; staff/admin mails follow the
account's notification templates as in the admin. Default `false`, except `POST /packages` (default `true`, package_new).

**Series.** `POST /appointments` with a `recurrence` object (`freq`, plus `every`/`on`/`monthly`, and `count` ≤ 52
or `until`) creates the whole series in one call: one appointment per occurrence, all sharing one `seriesId`
(returned with `appointmentIds`). Every occurrence is checked like a single creation, so warnings name the date they
are about and `force: true` accepts them; the cap is 52 occurrences. `PATCH /appointments/{id}`, `PATCH /bookings/{id}`
and `DELETE /appointments/{id}` take `scope` `this` (default) | `following` | `series` for recurring bookings.

**Outbox: verify mails.** `GET /outbox` (scope `settings:read`) lists the mails the account sent or queued, newest first:
`id, status, toEmail, toName, subject, text, html, createdAt, sentAt, lastError`. Filters `status` and `to`, `limit` ≤ 100.
Statuses: `pending`, `sending`, `sent`, `failed`, and `suppressed` — a sandbox never delivers mail, so all its rows end there.

```sh
curl -s "${H[@]}" "$API/outbox?to=jan@example.com&limit=5"
```

**Sandbox.** `DELETE /sandbox` (any scope) deletes the calling key's sandbox account and everything in it → `204`; the key
is dead afterwards. On a real account it answers `404`. In a sandbox, Mollie payments use a fake checkout page and
`POST /payments/{id}/refund` succeeds without contacting Mollie. See [Sandbox](https://guide.nisebookings.com/08-sandbox.html).

**Webhooks.** Register an `https://` URL and get a signed `POST` (Standard Webhooks headers `webhook-id`,
`webhook-timestamp`, `webhook-signature`) for `booking.created|updated|cancelled`, `ticket.created|approved|cancelled`
and `payment.completed|failed|refunded`, whatever made the change. Scope `settings:write`; the `secret` (`whsec_…`) is
returned once. Retries with backoff for ~39 h, a deliveries log with manual retry, a test `ping`, secret rotation.
Payloads, verification code and retry rules: [Webhooks](https://guide.nisebookings.com/10-webhooks.html).

```sh
HOOK=$(curl -s "${H[@]}" -X POST $API/webhooks -d '{"url":"https://shop.example.com/hooks/nise","events":["booking.created","booking.cancelled"]}' | jq -r .id)
curl -s "${H[@]}" -X POST $API/webhooks/$HOOK/test                 # sends a `ping`
curl -s "${H[@]}" "$API/webhooks/$HOOK/deliveries?limit=5"          # what was sent, with status and attempts
```

**Mollie key.** `PUT /settings/mollie-key {"key":"test_..."}` (scope `payments:write`) stores it encrypted and returns
only `{"hint"}`. It is write-only: no endpoint ever returns the key. `DELETE /settings/mollie-key` removes it.
Set it before `PATCH /settings {"payMollieEnabled":true}`.

## 5. Or let your coding agent do it: MCP

The same API is available as a hosted MCP server at `https://app.nisebookings.com/api/mcp`, with the same
`nk_` key and scopes. Connect Claude Code, Codex, Cursor or VS Code and ask in plain language:

```sh
claude mcp add --transport http nise-booking https://app.nisebookings.com/api/mcp --header "Authorization: Bearer $NISE_API_KEY"
```

Tool list, the config for each client and the error behaviour: [`mcp.md`](mcp.md).

## Conventions

- **Retries: `Idempotency-Key`.** Every `POST` accepts an `Idempotency-Key` header (1–255 visible ASCII characters;
  a UUID). Retrying with the same key and the same body returns the stored response with `Idempotent-Replayed: true`
  instead of running again, so a timeout never creates a second customer, appointment or payment action. The same key
  with a different body → `422 idempotency_mismatch`; while the first request is still running → `409
  idempotency_in_progress` (`Retry-After: 1`); a malformed key → `400 bad_request`. `5xx` responses are not stored
  (the retry runs for real). Keys are per account and expire after 24 hours. Without the header nothing changes.

  ```sh
  IK=$(uuidgen)
  curl -s "${H[@]}" -H "Idempotency-Key: $IK" -X POST $API/customers -d '{"fullName":"Jan Jansen","email":"jan@example.com"}'
  curl -si "${H[@]}" -H "Idempotency-Key: $IK" -X POST $API/customers -d '{"fullName":"Jan Jansen","email":"jan@example.com"}' | grep -i idempotent-replayed
  ```

- **Versioning and rate limits.** Every request may carry `X-Api-Version: 2026-10-13`; without the
  header the latest version is assumed, an unknown value is `400 bad_request`. Every response carries
  `X-Api-Version`, except that `400` (rejected before a version is resolved). Requests are counted per
  API key at **120/min**; over the limit → `429 rate_limited` with `Retry-After` (seconds) and
  `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
  (epoch seconds). The counters live in the server process — a restart resets them and extra instances
  multiply the budget. A breaking change gets a new dated version; the previous one stays supported for
  at least six months (`Deprecation`/`Sunset` headers when that happens). The spec as JSON:
  `GET /api/v1/openapi.json` (generated from `openapi.yaml`, `npm run docs:openapi`).
- **JSON, camelCase** field names. Unknown fields are ignored. PATCH merges into the current values.
- **Timestamps** in responses are RFC 3339 in UTC with `Z` (`2026-10-14T07:00:00Z`, `createdAt` may carry
  fractional seconds); dates are `YYYY-MM-DD`, wall-clock times `HH:MM`. Requests accept any offset (`2026-10-14T09:00:00+02:00`).
- **Errors**: `{"error":{"code","message","fields?"}}`. Codes: `bad_request` 400 (body is not a JSON object; or an unknown `X-Api-Version`), `unauthorized` 401,
  `forbidden` 403 (missing scope), `not_found` 404 (also for ids from another account), `conflict` 409 (still in use),
  `idempotency_in_progress` 409, `idempotency_mismatch` 422,
  `validation` 422 (`fields`: camelCase field → message), `warnings` 422 (see above), `capacity_exceeded` /
  `slot_unavailable` 409, `package_*` 422, `rate_limited` 429, `server` 500. Messages are English; `fields` maps each camelCase field to its message.
  (The public API uses its own shape: `{"error":"validation","fields":{...}}`, `409 slot_unavailable`.)
- **Pagination**: list endpoints take `limit` (1–100, default 50) and `offset` and return `{data, total}`.
- Every write that reaches the database is audited per key (Settings → API shows recent entries); a request rejected by the rate limit is not audited, and neither is `DELETE /api/v1/sandbox` (its audit row would be deleted with the tenant). Responses are `Cache-Control: no-store`; no CORS, so call from a server.
- Not available: creating real accounts (only [sandboxes](https://guide.nisebookings.com/08-sandbox.html), via `POST /api/sandbox`), dashboard stats (`/stats`). The calendar is `GET /appointments?from=&to=`.
