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.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 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.
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
# 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)
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
# 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.
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.
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.
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:
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.
Conventions
Retries:
Idempotency-Key. EveryPOSTaccepts anIdempotency-Keyheader (1–255 visible ASCII characters; a UUID). Retrying with the same key and the same body returns the stored response withIdempotent-Replayed: trueinstead 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.5xxresponses are not stored (the retry runs for real). Keys are per account and expire after 24 hours. Without the header nothing changes.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-replayedVersioning and rate limits. Every request may carry
X-Api-Version: 2026-10-13; without the header the latest version is assumed, an unknown value is400 bad_request. Every response carriesX-Api-Version, except that400(rejected before a version is resolved). Requests are counted per API key at 120/min; over the limit →429 rate_limitedwithRetry-After(seconds) andX-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/Sunsetheaders when that happens). The spec as JSON:GET /api/v1/openapi.json(generated fromopenapi.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,createdAtmay carry fractional seconds); dates areYYYY-MM-DD, wall-clock timesHH:MM. Requests accept any offset (2026-10-14T09:00:00+02:00).Errors:
{"error":{"code","message","fields?"}}. Codes:bad_request400 (body is not a JSON object; or an unknownX-Api-Version),unauthorized401,forbidden403 (missing scope),not_found404 (also for ids from another account),conflict409 (still in use),idempotency_in_progress409,idempotency_mismatch422,validation422 (fields: camelCase field → message),warnings422 (see above),capacity_exceeded/slot_unavailable409,package_*422,rate_limited429,server500. Messages are English;fieldsmaps 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) andoffsetand 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 areCache-Control: no-store; no CORS, so call from a server.Not available: creating real accounts (only sandboxes, via
POST /api/sandbox), dashboard stats (/stats). The calendar isGET /appointments?from=&to=.