nisebookings docs

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.

Contents

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>):

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:

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)
}

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" } }

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 }

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" }

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');
}

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

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.

  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.
  7. 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.
  8. 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.

What the API does not do

Deliberately out of scope for the public API (do these through the admin or your own layer):