# nisebookings A hosted booking service for businesses: appointments, group bookings, recurring series, packages, waiting lists and event tickets. It ships three surfaces: | Surface | What it is | For | |---|---|---| | **Embeddable widget** | two dependency-free web components you paste into any site | any website, WordPress | | **Public REST API** | the same engine without UI, one public key per business | custom front-ends, apps, agents | | **Admin back office** | calendar, appointments, catalogue, payments, mail templates | the business | The engine is strict where it matters: slots are re-checked and written transactionally, capacity can never be exceeded, prices are always recomputed server-side, and all time is anchored to the business's time zone. ## Start here | Your task | Read | |---|---| | Decide whether this fits (and when not to use it) | [When to use it](09-when-to-use.md) | | Test against a throwaway sandbox, no sign-up | [Sandbox](08-sandbox.md) | | Put bookings on a website in 5 minutes | [Quickstart](01-quickstart.md) | | Understand services, slots, capacity, money, time | [Concepts](02-concepts.md) | | Configure the widget (attributes, flows, styling) | [Widget reference](03-widget-reference.md) | | Build a custom front-end on the API | [Public API](04-public-api.md) · [openapi.yaml](openapi.yaml) · [verified examples](examples/README.md) | | Integrate into a Next.js app | [Next.js](11-nextjs.md) · [runnable example](examples/nextjs/README.md) | | Configure your account from code (no admin UI) | [Management API](../api/README.md) · [openapi.yaml](../api/openapi.yaml) | | Let Claude Code, Codex, Cursor or VS Code operate your account | [MCP server](../api/mcp.md) | | Get notified of bookings, cancellations, tickets and payments | [Webhooks](10-webhooks.md) | | Use the back office | [Admin guide](05-admin-guide.md) | | Change e-mail templates | [Notifications](06-notifications.md) | | Know what data is stored and who can see it | [Data and privacy](07-data-and-privacy.md) | | Feed a coding agent | [Agent instruction files](agents/README.md) | ## 5-minute integration ```html ``` The customer gets service → time → details → payment → confirmation, in a shadow-DOM element that inherits your page's font and is themed with CSS custom properties. Event tickets: ``. WordPress: paste the same two lines into a **Custom HTML** block. ## Headless integration ```js const API = 'https://app.nisebookings.com/api/public/'; const { tenant, services } = await (await fetch(`${API}/services`)).json(); const { days } = await (await fetch(`${API}/slots?service=${id}&from=2026-10-14&days=7`)).json(); const res = await fetch(`${API}/bookings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ service: id, staff: days[0].slots[0].staffId, start: days[0].slots[0].start, persons: 1 }], name: 'Ada Jansen', email: 'ada@example.com', gateway: 'local', }), }); ``` Send back the exact `start` you were given; handle `409 slot_unavailable` by re-fetching slots. Add an `Idempotency-Key` header so a retry after a timeout never books twice. ## The five things to know 1. **Account = public key.** One key identifies the business on every public call. Nothing else is needed to embed or book. 2. **One order = one payment.** A cart, a chain, a repeated series or the parts of a compound service are all one checkout, with one payment and one order token. 3. **The client never decides.** Prices, staff, extras, capacity and slot availability are recomputed server-side inside a transaction with per-staff locks. The only thing the client must get right is sending back the `start` (and `staffId`) it received. 4. **Time is UTC on the wire, business-local in the UI.** Slot `start` values are ISO UTC; `from`/`until` are business-local dates; schedules, holidays and reminders use the business time zone. 5. **Timed work runs on its own.** Reminders, mail delivery, payment expiry, waiting-list offers and automatic status changes are processed by the service about once a minute; there is nothing to schedule on your side. ## For AI agents and coding agents - [`agents/`](agents/README.md) — drop-in instruction files: `AGENTS.md` (Codex, Cursor, Copilot coding agent, VS Code, …), a Claude Code skill, and a Copilot instructions file. Copy one into the project that integrates this service. - [`08-sandbox.md`](08-sandbox.md) — `POST /api/sandbox` returns a 24-hour test account with a public key and an all-scopes API key; no mail is delivered (read it via `GET /api/v1/outbox`), payments are fake. Verify your integration there before touching your real account. - [`10-webhooks.md`](10-webhooks.md) — signed event messages (Standard Webhooks) to your backend for bookings, tickets and payments, with retries and a deliveries log; a dependency-free verifier. - [`examples/`](examples/README.md) — three smallest-possible integrations; all run against a sandbox key, including a full Next.js app (widget and own UI) with every page explained in [`11-nextjs.md`](11-nextjs.md). - [`llms.txt`](llms.txt) — this documentation as an index, per the llmstxt.org convention. - [`llms-full.txt`](llms-full.txt) — every guide page concatenated into one file. - [`openapi.yaml`](openapi.yaml) · [`openapi.json`](openapi.json) — machine-readable description of the whole public API (OpenAPI 3.1), in YAML and JSON. When implementing a booking feature: start at [Quickstart](01-quickstart.md), fall back to the [Public API](04-public-api.md) for anything the widget does not expose, and treat [Concepts](02-concepts.md) as the source of truth for slot, capacity and pricing semantics. The [Data and privacy](07-data-and-privacy.md) page explains what is stored, how accounts are isolated and what is reachable from outside. ## What this service is not (yet) - The [management API](../api/README.md) and the [MCP server](../api/mcp.md) cover what the admin does except creating real accounts (a 24-hour [sandbox](08-sandbox.md) can be created with one call) and the dashboard statistics. The MCP server takes API keys only (no OAuth yet). - No customer accounts or "my bookings" area; bookings are managed through magic links: cancel a single booking (`/b/`) or a whole order (`/o/`), reschedule a single booking on `/b/` or with `POST bookings/{token}/reschedule`. - No SMS/WhatsApp and no external calendar sync. - Admin UI is Dutch-only; the widget's text is Dutch **and English** (`data-lang`, or the account's language in Instellingen → Algemeen). - No plans, quotas or billing layer — accounts are isolation + roles. [`09-when-to-use.md`](09-when-to-use.md) turns this into a decision table with alternatives. # Quickstart — add bookings to a site The fastest path: get a public key, paste two lines of HTML, done. Every route below is a live integration path; pick one. | You want | Path | Time | |---|---|---| | Bookings on a normal website | [Widget](#path-a-widget-on-any-website) | minutes | | Bookings on a WordPress site | [WordPress](#path-b-wordpress) | minutes | | Your own UI / headless | [Public API](#path-c-your-own-front-end) | hours | | Ticket sales for an event | [Events widget](#booking-events-instead-of-appointments) | minutes | | Just test, no account yet | [Sandbox](08-sandbox.md): `curl -X POST https://app.nisebookings.com/api/sandbox` | seconds | ## Get a key 1. Create an account and complete the onboarding: **Bedrijfsnaam**, **Webadres** (a slug) and **Tijdzone**. This creates your account and makes you owner. 2. Add what can be booked: **Medewerkers** (staff/resources, each with a weekly schedule) and **Diensten** (services, and which staff perform them). 3. Copy the **Publieke sleutel** from **Instellingen → Insluiten**. That key is the only credential the browser-facing side needs. Optional but recommended before going live: **Instellingen → Bedrijf en e-mail** (sender name, reply-to), **Instellingen → Betalingen** (VAT, deposit, Mollie key), **Instellingen → Insluiten → Toegestane websites** (restrict who may embed your widget). ### Let an agent configure your account Everything above can also be done from code: **Instellingen → API** creates a key with scopes, and the [management API](../api/README.md) then builds the whole catalogue — categories, services, staff, per-staff prices, the working week, opening hours and holidays. Nothing to click. ```bash export API=https://app.nisebookings.com/api/v1 KEY=nk_… curl -s -H "Authorization: Bearer $KEY" $API/me curl -s -H "Authorization: Bearer $KEY" -X POST $API/services \ -d '{"title":"Knippen","duration":30,"price":25,"visibility":"public"}' ``` The quickstart in [`../api/README.md`](../api/README.md) walks an empty account to a real booking, and the [OpenAPI spec](../api/openapi.yaml) covers every endpoint. Prefer to ask in plain language? The same key connects your coding agent to the hosted [MCP server](../api/mcp.md): ```bash claude mcp add --transport http nise-booking https://app.nisebookings.com/api/mcp \ --header "Authorization: Bearer $KEY" ``` ## Path A: widget on any website ```html ``` That is the whole integration. The element renders the full flow — service, time, details, payment — inside a shadow DOM, styled with the page's font. Optional attributes: ```html data-service="" data-staff="" data-lang="nl|en" > ``` Details, styling tokens and limitations: [`03-widget-reference.md`](03-widget-reference.md). ## Path B: WordPress No plugin needed; this works on any WordPress site where you can add HTML. 1. Edit the page, add a **Custom HTML** block and paste the two lines from Path A (with your public key). Optional attributes such as `data-service` and `data-staff` work the same way. 2. Add the WordPress site to **Toegestane websites** in the booking app, or leave the list empty. Your WordPress user must be allowed to add scripts (administrators are, by default). ## Path C: your own front-end ```js const KEY = ''; const API = 'https://app.nisebookings.com/api/public/' + KEY; const { tenant, services } = await fetch(`${API}/services`).then((r) => r.json()); // tenant.timeZone, tenant.currency, tenant.gateways, tenant.depositMode, … const { days } = await fetch(`${API}/slots?service=${serviceId}&from=2026-10-14&days=7&persons=1`) .then((r) => r.json()); const slot = days.flatMap((d) => d.slots)[0]; const res = await fetch(`${API}/bookings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ service: serviceId, staff: slot.staffId, start: slot.start, persons: 1 }], name: 'Ada Jansen', email: 'ada@example.com', gateway: 'local', }), }); const order = await res.json(); // order.orderToken, order.items[0].status ``` Full endpoint reference, error codes and recipes (Mollie checkout, recurring series, waiting list, tickets): [`04-public-api.md`](04-public-api.md). Machine-readable: [`openapi.yaml`](openapi.yaml). ## Booking events instead of appointments ```html ``` Publish an event with ticket types in **Evenementen**, then embed. See [`03-widget-reference.md`](03-widget-reference.md#nise-events). ## Try it first No account needed: one call creates a [sandbox](08-sandbox.md) — a complete test business with a public key and an all-scopes management key, valid for 24 hours. It never sends mail (read what it would have sent via `GET /api/v1/outbox`) and its Mollie checkout is a fake page. ```bash curl -s -X POST https://app.nisebookings.com/api/sandbox # → {publicKey, apiKey, expiresAt, endpoints} ``` To just look: `https://app.nisebookings.com/demo.html` (widget + event shop). Runnable code: [`examples/`](examples/README.md) — `headless.mjs` runs against a sandbox key unchanged. ## Verify your integration 1. Complete a booking through your page. The calendar (**Agenda**) shows it immediately. 2. **Afspraken** shows the booking status; **Betalingen** shows the payment (if priced). 3. **Meldingen → Verzendlog** shows the confirmation mail and its delivery status. 4. The confirmation mail contains a cancel link; open it and cancel to test the customer path. ## Troubleshooting | Symptom | Cause / fix | |---|---| | Widget shows "De agenda kan nu niet worden geladen." | Wrong key, or the API host is unreachable from the browser. Check the network tab: a 404 means the key is wrong. | | `403 {"error":"origin_not_allowed"}` | The embedding origin is not in **Toegestane websites**. Add it, or clear the list. | | Widget renders but lists no services | Your account has no services with `visibility = public` and at least one public staff member who performs them. Check **Diensten** and **Medewerkers → Diensten**. | | No times offered | Staff schedules (or opening hours for new staff), company/staff holidays, minimum notice, booking horizon, and the slot grid. Try another day; check the staff's **Rooster**. | | Times offered but booking fails with 409 | Someone else took the slot; re-fetch and pick again. | | Payment link fails / `502 payment_unavailable` | Mollie key missing or invalid, or Mollie temporarily unreachable. Check **Instellingen → Betalingen**. | | Mails not arriving | Check **Meldingen → Verzendlog** for status and error text; a customer may also find it in their spam folder. | | Widget shows an older version than expected | Rare — the console shows `nisebookings@` on load; add `?v=` to the script URL (or hard-refresh) if a cache serves an old copy. | ## Going live checklist - [ ] Real services, staff schedules, holidays (company + per staff) entered. - [ ] **Instellingen → Algemeen**: time zone, time step, minimum notice, booking horizon. - [ ] **Instellingen → Afspraken**: new bookings confirmed immediately or pending approval? - [ ] **Instellingen → Betalingen**: VAT mode, deposit mode, gateways, Mollie live key. - [ ] **Instellingen → Insluiten**: allowed websites restricted to your domains. - [ ] Notification templates reviewed in Dutch (sender name, reply-to, texts, reminders). - [ ] Reminders, payment timeouts and automatic status changes are handled by the service — nothing to run yourself. - [ ] A test booking, cancellation and refund done end to end. # Concepts How the booking engine thinks. Read this before writing an integration; it prevents most wrong assumptions about slots, capacity, money and time. ## Contents - [The shape of the system](#the-shape-of-the-system) - [Your account (tenant)](#your-account-tenant) - [Catalogue: services, staff, extras](#catalogue-services-staff-extras) - [When is a time bookable?](#when-is-a-time-bookable) - [Appointments, bookings, orders](#appointments-bookings-orders) - [Statuses](#statuses) - [Special booking kinds](#special-booking-kinds) - [Money](#money) - [Notifications](#notifications) - [Time and locale](#time-and-locale) - [Terminology: Dutch UI and English](#terminology-dutch-ui-and-english) ## The shape of the system ``` ┌─────────────────────────── admin UI (/app, login) account ─► catalogue ──┤ (key) services │ staff └──► availability engine ──► slots ──► order ──► payment schedules │ holidays └──► notifications (mail) ▲ public API / widget ───────────┘ (no login, public key, server-side re-check) ``` - **One account = one business.** Your account is identified by its public key on the public side, and by the signed-in user's membership in the admin. - **The availability engine is deterministic**: given service, staff, schedules, holidays, existing appointments and preferences, it returns the list of bookable slots. The server re-runs the same check when a booking comes in, so what was offered is what gets verified. - **The public API never trusts the client.** Every booking is re-resolved and re-checked, then written inside one transaction that takes a per-staff lock. The widget is a convenience client, not a privileged one. ## Your account (tenant) An account is one business (the API calls it a *tenant*): name, slug, **public key** (the credential for the public API), time zone, locale, currency, VAT mode, deposit mode, gateways, booking rules (slot length, minimum notice, booking horizon, default status), calendar options and feature switches (cart, chain, recurrence, waiting list, combined-price method, occupancy display). All data belongs to one account: a business can only ever see its own records, and every public call is bound to exactly one account by its key. A person relates to an account with a role (`owner`, `admin`, `front_desk`, `staff`); there is no account switcher — the first membership wins. ## Catalogue: services, staff, extras **Category** — grouping for the customer-facing list (Knippen, Verhuur, …). Optional. **Service** — the thing being booked. Four types: | Type | Meaning | Example | |---|---|---| | `simple` | one appointment, one staff member | haircut, intake, rental hour | | `compound` | a fixed sequence of parts, one after another, with optional spare time between them | wash → cut → dry | | `collaborative` | parts that must happen **at the same time**, each by a different staff member | skipper + boat; two instructors | | `package` | a prepaid bundle of N appointments of one `simple` service | 5-ride card | A `simple` service carries: duration, optional own slot length, price, deposit rule, VAT rate, minimum and maximum persons (capacity), padding before/after, staff preference for "any", visibility (public/private), `one_booking_per_slot`, which recurrence frequencies customers may choose, and an optional waiting-list cap. Prices and capacity can be overridden **per staff member** (`staff_services`), which is why a slot is always evaluated per (service, staff) pair. **Staff** — anything with its own schedule that can be booked: a person, a boat, a room, a device. Staff carries a weekly schedule (per weekday start/end plus breaks), holidays/vacation days, contact details, visibility (public/private/archive), and the list of services performed. A staff record needs no login; a login is linked via `staff.user_id` when the person should see their own agenda. **Extras** — optional add-ons per simple service: price, extra duration (added **after** the appointment), minimum/maximum quantity. A minimum of 1 makes the extra mandatory. Extras belong to simple services; on compound/collaborative services every extra is attached to the part that owns it. **Schedule and holidays** — a service's availability comes from the *staff* schedules. Company opening hours are only a template for new staff rows; company holidays apply to everyone, staff holidays only to that staff member. A "toch open" holiday row (kind `exception`) makes the company open on a yearly closing day. ## When is a time bookable? A slot is a start time the engine may offer. For the chosen service and each candidate staff member: 1. The start time lies on the **slot grid**: step = `service.slot_length` or the account-wide `slot_length` (default 15 min). 2. The **whole block fits in the schedule**: duration + extras + padding before/after must be inside a working period and outside breaks, on that weekday. 3. The date is not a **holiday** for the staff member or the company. 4. The start is at least `min_prior_minutes` from now and within the booking horizon (`max_days`, default 365). 5. It does not **conflict**, with padding, with an existing blocking appointment of that staff member (plus events they are linked to, plus holds while a customer is mid-checkout). 6. Capacity allows it: the number of persons already booked (blocking statuses only) plus the requested persons must fit the staff member's capacity for that service. A block that is partly occupied is offered as a **partial** slot when the remaining capacity suffices (group slots; the UI shows the occupancy). A **full** slot is omitted, unless a waiting list is open — then it is returned with `state: "waitlist"`. Two services can be booked into the *same* appointment (group join) only when the service, start, duration and extras match and the capacity still allows it; `one_booking_per_slot` forbids any join. When several staff members qualify and the customer sends `staff: "any"`, the service's **staff preference** decides: `order` (list order), `least_expensive`, `most_expensive`. For compound/collaborative services the engine searches the whole sequence at once: parts run consecutively (compound, with spare time free for others) or in parallel on **distinct** staff members (collaborative). Only the first part's staff is customer-choosable. ## Appointments, bookings, orders ``` order (one checkout) ← orders row, one token, one payment ├── appointment 09:30–10:15 (service A, staff X) ← a block of time in a calendar │ ├── booking: Ada, 1 person, approved │ └── booking: Bob, 2 persons, approved ← group join, same appointment ├── appointment 10:15–11:00 (service B, staff Y) ← chain part └── series: appointments on 7 dates ← recurring occurrences ``` - **Appointment** = the occupied time block (staff + service + start/end + internal note). - **Booking** = one customer on an appointment, with persons, status, notes and a magic **token**. The public cancel/reschedule page is `/b/`; the token also appears in mails. - **Order** = everything a customer checks out in one go (cart items, chain parts, all occurrences of a series, compound/collaborative parts). One order = one payment. The order token is returned as `orderToken` and mailed as the cancel-order link (`{cancel_order_url}` → `/o/`). - **Hold** = an item a customer has chosen but not yet booked; the client sends it back with slot searches so the customer's own choices count as occupied. Holds are never real appointments. - **Series** = recurring appointments created together, sharing a `series` row with the rule. The admin can edit one, this-and-following, or all occurrences. - **Compound/collaborative parts** share a `group_token`; status changes apply to all parts. Blocking statuses are `pending` and `approved` — those (and appointments with **no** bookings at all, which block entirely) determine occupancy. ## Statuses | Status | Dutch label | Blocking | Meaning | |---|---|---|---| | `pending` | In afwachting | yes | booked, awaiting confirmation (when the business requires approval, or a payment is open) | | `approved` | Bevestigd | yes | confirmed | | `waitlisted` | Wachtlijst | no | wants a seat if one frees up; never paid, never blocking | | `cancelled` | Geannuleerd | no | cancelled by customer or admin | | `rejected` | Afgewezen | no | refused by admin, or payment failed/expired | | `done` | Afgerond | no | the appointment happened (manual or via automatic status change) | An account-level **automatic status change** can move appointments that have ended from one status to another (e.g. approved → done) automatically, checked every minute; bookings changed in the last two days get a notification. ## Special booking kinds - **Group booking** — a service with capacity > 1; several customers share one appointment. Occupancy can be shown to customers (off / `3/8` / `nog 5`). - **Waiting list** — when a slot is full and the service and account settings allow it, customers can take a place on the list. Optional mode *"Wachtlijst telt mee als bezet"*: waitlisted persons count as occupying and once anyone is waitlisted, later bookers are waitlisted too. When seats free up, **all** waiting customers of that appointment get a first-come-first-served mail. No payment is involved. - **Recurring** — a series of occurrences with a rule (daily/weekly/biweekly/monthly, interval, weekdays, monthly day or nth-weekday, until/count, max 52). Occurrences whose preferred time is taken are moved to a nearby available time (`anotherTime: true`) or reported as "no time found". Payment mode: the whole series in one payment, or only the first occurrence (the rest is paid on site — `onSite` in the quote). - **Package** — prepaid N appointments. Bought online (`kind: "package"`) or created by the admin; the customer receives a personal `/p/` link and schedules visits with the widget in package mode. Validity starts at the first planned visit; visits are never paid again. - **Events** — separate from appointments: dated happenings with ticket types, quantities, reserved seats and a sales deadline. Tickets get unique codes from a mask; codes are only shown/valid once the order is approved (paid). Every staff member linked to an event (organiser or support) shows the event in the calendar and has that time blocked for bookings. - **Extras** — priced and timed add-ons, snapshot per booking (title, price, duration, quantity). Whether extras multiply by persons is an account setting. - **Chain** — several services back-to-back for one customer, one after another, with each part possibly a different staff member. - **Cart** — several independent appointments checked out together as one order. ## Money **Price sources.** The unit price of an item is the staff-specific price (`staff_services.price`) when that staff member was assigned, otherwise the service price. Compound/collaborative services use their own price, or the sum of the parts' staff prices when the business chose *Optelsom van de onderdelen*. **Order formula**: per item `unit price × persons`, plus extras (× persons when the account multiplies them), summed to a subtotal; deposit applied per item (`'100%'`, or `'30%'`, or a fixed amount **per person**); coupon discount and fixed deduction; optional gateway surcharge (Mollie % + fixed); VAT. Prices include VAT by default (NL B2C): the shown total already contains it and the quote returns `tax` as the part that is VAT. Rounding is half-away-from-zero at 2 decimals. **What the customer pays now** (`payNow`) is the deposit, or the full amount, depending on the account's deposit mode (`deposit_only`, `choose_deposit`, `choose_full`) and the customer's choice. The rest is `due`, payable on site. A zero order becomes a **free** order; a Mollie order whose payable amount is zero falls back to a **local** one. **Payment types.** `local` (pay at the desk — the admin completes it later), `free` (nothing to pay), `mollie` (online; iDEAL/card on Mollie's page). One payment per order. Statuses: `pending`, `completed`, `rejected`, `refunded`; late arrivals are flagged (`late_paid`) when the slot was already given away. **Coupons** — percentage and/or fixed amount, usage limit (account-wide), once-per-customer, validity dates (the *booking* date counts), minimum/maximum appointments per order, and optional restrictions to services/staff. A coupon use is given back when the payment is rejected. ## Notifications Mail is driven by **templates** per type: `new_booking`, `status_changed`, `reminder`, `waitlisted`, `waitlist_offer`, `package_new`, `event_tickets`. Each template picks recipients (customer, the staff member, admins, extra addresses), a status filter, a service filter and — for reminders — a timing (relative: N hours before the start; or a fixed hour, N days before). Bodies use `{codes}` and `{#if …}` blocks. Rendering happens when the mail is queued; sending retries with backoff. Order mails are sent once per order (staff get one per own appointment); status changes and reminders stay per booking. See [`06-notifications.md`](06-notifications.md). ## Time and locale - Instants are stored and transmitted as UTC. Everything customer-visible (slot times, day headings, "today", holidays, schedules, reminder timing) is computed in the **business time zone**. - Recurrence and slot `from`/`until` use business-local calendar dates (`YYYY-MM-DD`). - Formatting uses the account `locale` (default `nl`) and `currency` (default `EUR`). - Bookings do not store a customer time zone; the whole product presents business-local time. ## Terminology: Dutch UI and English | Dutch (UI) | English (docs) | |---|---| | dienst | service | | medewerker | staff (member) | | categorie | category | | afspraak | appointment | | boeking | booking | | bestelling | order | | reeks | series | | wachtlijst | waiting list | | pakket | package | | evenement / deelnemer | event / attendee | | tickettype | ticket type | | rooster | schedule | | vrije dag | day off / holiday | | openingstijden | opening hours | | pauze | break | | toeslag | surcharge | | aanbetaling | deposit | | kortingscode | coupon | | opmerking | note (customer's) | | interne notitie | internal note | | extra('s) | extras | | tijdstap | slot length / time step | | bezetting | occupancy | # Widget reference Two dependency-free custom elements turn any HTML page into a booking page: - **``** — the booking flow (service → time → details → payment → done). - **``** — the event/ticket shop. Both render into an open shadow DOM, read their configuration from `data-*` attributes, and talk to the [public API](04-public-api.md) directly from the browser. There is no iframe, no build step and no npm package. ## Contents - [Embedding](#embedding) - [Language](#language) - [The API base URL](#the-api-base-url) - [`` attributes](#nise-booking-attributes) - [Booking flow step by step](#booking-flow-step-by-step) - [Package mode](#package-mode) - [Recurrence, cart and chain in the widget](#recurrence-cart-and-chain-in-the-widget) - [Waitlist and occupancy](#waitlist-and-occupancy) - [Payment step and return handling](#payment-step-and-return-handling) - [``](#nise-events) - [Styling](#styling) - [Accessibility](#accessibility) - [WordPress](#wordpress) - [Limits, caching and versioning](#limits-caching-and-versioning) - [What the widget cannot do](#what-the-widget-cannot-do) ## Embedding ```html ``` ```html ``` - The scripts are classic scripts (not modules), need no dependencies, and register the element only if it is not registered yet — loading the script twice is harmless. - Keep `defer`: the elements upgrade themselves and start loading as soon as they are connected. - One element instance per page slot. The element has no JS API: no constructor, no methods, no events (details below); the only global it sets is `window.nisebookings.version`. - The widget needs no cookies and sends no credentials. ## Language The customer-facing texts ship in **Dutch and English**. Each element resolves its language on its own, in this order: 1. **`data-lang` on the element** — trimmed and lowercased; anything starting with `nl` is Dutch, everything else English. Use it to run one page, or one embed, in another language than the account. 2. **The account's language** — **Instellingen → Algemeen → Taal van het boekingsformulier** (Nederlands/Engels; `locale` in `PATCH /api/v1/settings`). It arrives with `GET services`. 3. **Dutch**, when neither is set. `` sets `lang` on its host element (the shadow tree inherits it) and re-renders the current step when `data-lang` changes; `` reads the attribute on connect and again after the first fetch. On WordPress the plugin shortcodes take the same value: `[nise_booking lang="en"]` and `[nise_events lang="en"]` (`"nl"` for Dutch); without it they follow the account language. Not translated on purpose: server data (service, staff and event titles and descriptions) and numbers and dates. Formatted values follow the *account's* `locale`, so an English widget on a Dutch account shows Dutch month names and `€ 1.234,56` — set the account language as well if you want everything in English. ## The API base URL The widget decides once, at script-load time, where the API lives: 1. `data-api="https://app.nisebookings.com"` if present — used **verbatim** (no trailing-slash normalisation); 2. otherwise the origin of the script's own `src` (`https://app.nisebookings.com/widget.js` → `https://app.nisebookings.com`); 3. otherwise the embedding page's origin. If you load the scripts from anywhere other than `https://app.nisebookings.com`, **`data-api` is mandatory**. Note that `document.currentScript` is `null` for module scripts, inline scripts and dynamically injected `async` scripts; in those cases the fallback is the page origin, so set `data-api` explicitly. Requests without an `Origin` header work too (server-to-server), but for browser embeddings your account's **Toegestane websites** list applies (Instellingen → Insluiten). Empty list = any site; otherwise the API answers 403 `origin_not_allowed`. ## `` attributes | Attribute | Default | Meaning | |---|---|---| | `data-key` | — | **Required.** Your account's public key. | | `data-api` | script origin | API base URL (see above). | | `data-service` | — | Preselect a service id (from `GET services`). The widget skips the service step only when the service has fewer than 2 staff (or hides staff), no extras, capacity 1, the chain feature is off and the entry is not already in the cart. | | `data-staff` | `any` | Preselect a staff id, or `any`. Ignored when the chosen service is not offered by that staff member (falls back to `any`). | | `data-package` | — | Package mode for an already bought package: the token from the `/p/` link. | | `data-lang` | account language | Language of the customer-facing texts: `nl` or `en` (anything not starting with `nl` means English). See [Language](#language). | Attributes are read on connect only — except `data-lang`, which the element observes: changing it after the element is in the DOM switches the language (and the host's `lang` attribute) and re-renders. No other attribute has an `attributeChangedCallback`, and re-inserting the same element does not re-initialise it. If you need a different configuration, create a new element (or replace the old one). ## Booking flow step by step Steps are `0` services, `1` time, `2` details/payment, `3` done, `4` payment-status message, `5` cart. **Step 0 — Kies een dienst.** Service list with title, description, duration and price; packages show "N dagen geldig"/"onbeperkt geldig". A staff `