nisebookings docs

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