{
  "openapi": "3.1.0",
  "info": {
    "title": "nisebookings — public API",
    "version": "2026-10-13",
    "summary": "Hosted appointment booking, packages, waiting lists and event tickets.",
    "description": "Public, unauthenticated API for one business account, identified by its **public key** in the\npath (Instellingen → Insluiten in the admin). Used by the embeddable widgets and by custom\nfront-ends. Test against a 24-hour sandbox: `POST https://app.nisebookings.com/api/sandbox`.\n\n## Rules that matter\n- All instants are ISO 8601 UTC. `from`/`until` are business-local calendar dates (`YYYY-MM-DD`).\n- The server never trusts client amounts, staff, extras or persons: every request is re-resolved\n  and re-checked against the live schedule, then written transactionally with per-staff locks.\n- Two customers can never book the same seat: the loser of a race receives `409 slot_unavailable`\n  with the 0-based index of the failing item.\n- Send back the exact `start` (and `staffId`) that a slot search returned. Times are never\n  constructed client-side.\n- CORS: the request `Origin` is reflected when the account's allowed-websites list (`allowedOrigins`) is empty or\n  contains that origin; otherwise `403 origin_not_allowed`. `OPTIONS` is answered with 204.\n- Responses are `Cache-Control: no-store`, except `GET services` (`public, max-age=60`).\n- Retries: send an `Idempotency-Key` header on `POST bookings` and `POST tickets`. A retry with the\n  same key and body returns the original response (`Idempotent-Replayed: true`) instead of creating\n  a second order. Keys are kept 24 hours.\n\n## Versioning and rate limits\n- Send `X-Api-Version: 2026-10-13` to pin a version; omit it for the latest, which every response\n  echoes — except the unknown-version `400` itself. An unknown value is `400 bad_request` and lists\n  the supported `versions`.\n- Per minute and per client IP: 120 requests, or 20 for booking commits (`POST bookings` and\n  `POST tickets`). Over that is `429 rate_limited`. Counted responses carry `X-RateLimit-Limit`,\n  `X-RateLimit-Remaining` and `X-RateLimit-Reset` (epoch seconds); a 429 adds `Retry-After`\n  (seconds). The unknown-version `400` and free `OPTIONS` preflights carry no rate-limit state.\n- A breaking change ships as a new dated version; the old one stays supported for at least six\n  months, announced with `Deprecation` and `Sunset` headers.\n- This document is served on the app origin as `GET /openapi.json`; the management API spec as\n  `GET /api/v1/openapi.json`.\n\nLimits: 60 items per order, 10 chain parts, 52 recurrence occurrences, 31 days per slots call,\n2000-character notes, 40-character coupon codes, 1–50 tickets per type (max 20 types).\n\nErrors use stable `error` codes: `not_found`, `origin_not_allowed`, `validation`,\n`slot_unavailable`, `sold_out`, `sales_closed`, `payment_unavailable`, `bad_request`,\n`idempotency_in_progress`, `idempotency_mismatch`, `rate_limited`, `server`.\n",
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://app.nisebookings.com"
    }
  ],
  "tags": [
    {
      "name": "catalogue"
    },
    {
      "name": "availability"
    },
    {
      "name": "bookings"
    },
    {
      "name": "payments"
    },
    {
      "name": "packages"
    },
    {
      "name": "events"
    },
    {
      "name": "sandbox"
    }
  ],
  "paths": {
    "/api/public/{key}/services": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "get": {
        "tags": [
          "catalogue"
        ],
        "summary": "List the business settings and bookable services",
        "description": "Cacheable for 60 seconds. Call once per page load; everything else is keyed off the ids\nreturned here. Only `visibility = public` services with at least one public staff member are\nlisted.\n",
        "operationId": "listServices",
        "responses": {
          "200": {
            "description": "Business configuration and catalogue",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tenant",
                    "services"
                  ],
                  "properties": {
                    "tenant": {
                      "$ref": "#/components/schemas/Tenant"
                    },
                    "services": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Service"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/slots": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "get": {
        "tags": [
          "availability"
        ],
        "summary": "Slot search for one item",
        "description": "Time slots for an ordinary service; for a day service (`durationDays` > 0) one entry per free\nstart date instead — `start` is the business-local midnight (with offset) and `end` the local\nmidnight `durationDays` days later. Send both back verbatim when booking.\n",
        "operationId": "getSlots",
        "parameters": [
          {
            "name": "service",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "staff",
            "in": "query",
            "description": "Staff id, or `any` (default) to let the engine choose.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "description": "Business-local date. Defaults to today.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "days",
            "in": "query",
            "description": "Number of days (1–31). Defaults to 7.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 7
            }
          },
          {
            "name": "persons",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Slots per day; `hasMore` says whether the booking horizon is beyond this window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlotsResponse"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "availability"
        ],
        "summary": "Slot search for a chain or with holds",
        "description": "One item behaves like the GET search. More than one item searches a chain: consecutive\nservices for one customer; every returned slot then has `parts` with each resolved\nappointment. `hold` items (already chosen by the customer) are treated as occupied and are\nnever joined.\n",
        "operationId": "postSlots",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "items"
                ],
                "properties": {
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 10,
                    "items": {
                      "$ref": "#/components/schemas/ItemInput"
                    }
                  },
                  "hold": {
                    "type": "array",
                    "description": "Items the customer already selected (with a concrete `staff` id).",
                    "maxItems": 60,
                    "items": {
                      "$ref": "#/components/schemas/ItemInput"
                    }
                  },
                  "from": {
                    "type": "string",
                    "format": "date"
                  },
                  "days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 31,
                    "default": 7
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Slots per day",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlotsResponse"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/recurrence": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "availability"
        ],
        "summary": "Plan a recurring series",
        "description": "Returns the occurrence dates for a rule, with conflict handling: a moved occurrence has\n`anotherTime: true`, an occurrence without any free time has `start: null`. Occurrences on a\nfull group slot come back with `state: \"waitlist\"` and must be booked with `waitlist: true`.\nRequires the account to have recurrence enabled and the service to offer the frequency.\n",
        "operationId": "postRecurrence",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "item",
                  "rule"
                ],
                "properties": {
                  "item": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/ItemInput"
                      },
                      {
                        "type": "object",
                        "required": [
                          "start"
                        ]
                      }
                    ]
                  },
                  "rule": {
                    "$ref": "#/components/schemas/RecurrenceRule"
                  },
                  "hold": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/ItemInput"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Occurrences in date order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "occurrences"
                  ],
                  "properties": {
                    "occurrences": {
                      "type": "array",
                      "maxItems": 52,
                      "items": {
                        "$ref": "#/components/schemas/Occurrence"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "409": {
            "description": "The base item's slot is not available",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/quote": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "bookings"
        ],
        "summary": "Price an order",
        "description": "Computes the price for fully resolved items (each with a `start`). The server recomputes the\nsame numbers for `POST bookings`, so this is only for display. Call it again after a coupon\nchanges. The legacy single-item body (`service`, `staff`, `start`, …) is still accepted.\n",
        "operationId": "postQuote",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "maxItems": 60,
                    "items": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/ItemInput"
                        },
                        {
                          "type": "object",
                          "required": [
                            "start"
                          ]
                        }
                      ]
                    }
                  },
                  "coupon": {
                    "type": "string",
                    "maxLength": 40
                  },
                  "payFull": {
                    "type": "boolean"
                  },
                  "gateway": {
                    "$ref": "#/components/schemas/Gateway"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quote",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "409": {
            "description": "An item's slot is no longer available",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/uploads": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "bookings"
        ],
        "summary": "Upload a customer file",
        "description": "One file for an intake question of kind `file` (see GET /services): `multipart/form-data` with a\n`file` part. JPEG, PNG, WebP or PDF, at most 5 MB. Send the returned `id` back as the answer to\nthe file question when booking. Uploads count against the commit rate limit (20/min per address).\nThe file is private: the account reads it through a short-lived signed URL (admin, mails). An\nupload that no booking uses is deleted 24 hours after it was made.\n",
        "operationId": "postUpload",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "JPEG, PNG, WebP or PDF, at most 5 MB."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "size"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid",
                      "description": "Upload id: the answer to the file question."
                    },
                    "name": {
                      "type": "string",
                      "description": "The original file name."
                    },
                    "size": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/bookings": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "bookings"
        ],
        "summary": "Create an order",
        "description": "One request = one order = one payment. Re-checks every item against the live schedule and\nwrites transactionally. For `gateway: \"mollie\"` the response contains a `checkoutUrl`; the\ncustomer's confirmation mail is deferred until the payment completes. For local/free orders\nthe mail is sent immediately.\n",
        "operationId": "postBookings",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "items",
                  "name",
                  "email"
                ],
                "properties": {
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 60,
                    "items": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/ItemInput"
                        },
                        {
                          "type": "object",
                          "description": "A timed item needs `start`; a package purchase (`kind: package`) must not have one."
                        }
                      ]
                    }
                  },
                  "series": {
                    "type": "object",
                    "description": "Rules per series key used by the items.",
                    "additionalProperties": {
                      "$ref": "#/components/schemas/RecurrenceRule"
                    }
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 255
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "notes": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "gateway": {
                    "$ref": "#/components/schemas/Gateway"
                  },
                  "coupon": {
                    "type": "string",
                    "maxLength": 40
                  },
                  "payFull": {
                    "type": "boolean"
                  },
                  "returnUrl": {
                    "type": "string",
                    "format": "uri",
                    "maxLength": 2000,
                    "description": "Required for Mollie. Must be http(s) and its origin must be in the account's\nallowlist, or equal the request `Origin` when no allowlist is set. The server\nappends `nb_pay=<payment token>` on return.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Order created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingResult"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "409": {
            "description": "Slot taken (`slot_unavailable`; `item` is the failing item index), or `idempotency_in_progress`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "Mollie could not be reached. The order and its payment are written and then set to rejected, so no visit stays booked; answer the customer with a retry or an on-site payment option.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/public/{key}/bookings/{token}/reschedule": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "The booking token from `POST bookings` (the one in the `/b/<token>` cancel link).",
          "schema": {
            "type": "string",
            "pattern": "^[0-9a-f]{48}$"
          }
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "bookings"
        ],
        "summary": "Reschedule the customer's own booking",
        "description": "Moves one booking to another time, addressed by its 48-hex token (the `/b/<token>` link). Keeps\nthe service, staff and duration; `staff` picks another staff member who offers the service.\nThe new slot is re-checked exactly like a new booking — overlap, capacity, schedule and holiday\nconflicts answer `409 slot_unavailable`, and there is no `force`. Allowed while the booking is\n`pending` or `approved` and at least the account's cancel window (`cancelMinMinutes`) before its\nstart: the same limit as cancelling, otherwise `409 cannot_reschedule`.\n",
        "operationId": "postBookingReschedule",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "start"
                ],
                "properties": {
                  "start": {
                    "type": "string",
                    "description": "The exact `start` of a slot from `GET slots`."
                  },
                  "staff": {
                    "type": "string",
                    "format": "uuid",
                    "description": "A staff member who offers the appointment's service; omitted = keep the current one."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Moved to the new time",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "start",
                    "end"
                  ],
                  "properties": {
                    "start": {
                      "type": "string",
                      "description": "New start (ISO 8601 UTC)."
                    },
                    "end": {
                      "type": "string",
                      "description": "New end; the appointment keeps its duration."
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`cannot_reschedule` (not pending/approved, or inside the cancel window / already started) or `slot_unavailable` (taken, outside the schedule or on a holiday)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/payments/{token}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "name": "token",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "pattern": "^[0-9a-f]{48}$"
          }
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "get": {
        "tags": [
          "payments"
        ],
        "summary": "Payment status (poll after a Mollie redirect)",
        "description": "If the payment is still pending and of type mollie, this call asks Mollie directly, so it can\nbe polled instead of relying on the webhook. Poll every 2 s for up to ~30 s.\n",
        "operationId": "getPayment",
        "responses": {
          "200": {
            "description": "Payment status with the order's items",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentStatus"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/packages/{token}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "name": "token",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "pattern": "^[0-9a-f]{48}$"
          }
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "get": {
        "tags": [
          "packages"
        ],
        "summary": "Package overview for a personal link",
        "description": "No customer PII beyond the first name. Book a visit with `\"package\": \"<token>\"`.",
        "operationId": "getPackage",
        "responses": {
          "200": {
            "description": "Package details and planned visits",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PackageOverview"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/events": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "get": {
        "tags": [
          "events"
        ],
        "summary": "Published events with open ticket sales",
        "operationId": "listEvents",
        "responses": {
          "200": {
            "description": "Events",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Event"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/public/{key}/events/{id}/tickets": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Key"
        },
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "events"
        ],
        "summary": "Buy tickets",
        "description": "Tickets are always paid in full (no deposits, no coupons). Codes are returned only once the\norder is approved: free = immediately, local = after the desk completes the payment, mollie =\nafter the payment completes. 1–20 distinct ticket types, 1–50 tickets each.\n",
        "operationId": "postTickets",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tickets",
                  "name",
                  "email"
                ],
                "properties": {
                  "tickets": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 20,
                    "items": {
                      "type": "object",
                      "required": [
                        "type",
                        "qty"
                      ],
                      "properties": {
                        "type": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "qty": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 50
                        }
                      }
                    }
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 255
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "gateway": {
                    "$ref": "#/components/schemas/Gateway"
                  },
                  "payFull": {
                    "type": "boolean"
                  },
                  "returnUrl": {
                    "type": "string",
                    "format": "uri",
                    "maxLength": 2000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Ticket order created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "orderToken",
                    "attendees",
                    "payment"
                  ],
                  "properties": {
                    "orderToken": {
                      "type": "string"
                    },
                    "attendees": {
                      "type": "array",
                      "description": "Empty until the order is approved.",
                      "items": {
                        "type": "object",
                        "required": [
                          "code"
                        ],
                        "properties": {
                          "code": {
                            "type": "string",
                            "example": "ABCD-EFGH"
                          }
                        }
                      }
                    },
                    "payment": {
                      "$ref": "#/components/schemas/PaymentRef"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginNotAllowed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`sold_out` (with `type`: ticket type id), `sales_closed`, or `idempotency_in_progress`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Validation"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "Mollie could not be reached. The ticket order is written and then set to rejected/cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/sandbox": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApiVersion"
        }
      ],
      "post": {
        "tags": [
          "sandbox"
        ],
        "summary": "Create a 24-hour sandbox account (no auth)",
        "description": "Returns a complete test account with sample data (2 staff, 3 services incl. a group class, one event)\nwith its public key and a management-API key (`nk_…`) that has every scope. The key is shown\nonce. The sandbox never delivers mail (read it via `GET /api/v1/outbox`), sends Mollie payments\nto a fake checkout page, and is deleted after 24 hours or by `DELETE /api/v1/sandbox`.\nBody: none or `{}`. Limits: 5 creates per client IP per hour, and a global cap on live sandboxes.\nManagement API and MCP reference: https://guide.nisebookings.com/api/\n",
        "operationId": "createSandbox",
        "responses": {
          "201": {
            "description": "Sandbox created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tenantId",
                    "name",
                    "publicKey",
                    "apiKey",
                    "scopes",
                    "expiresAt",
                    "endpoints"
                  ],
                  "properties": {
                    "tenantId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string",
                      "examples": [
                        "Sandbox 3fa2"
                      ]
                    },
                    "publicKey": {
                      "type": "string",
                      "description": "Public key for /api/public/{key} and the widget `data-key`"
                    },
                    "apiKey": {
                      "type": "string",
                      "description": "Management API / MCP key, all scopes, shown once"
                    },
                    "scopes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "expiresAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "endpoints": {
                      "type": "object",
                      "properties": {
                        "publicApi": {
                          "type": "string",
                          "format": "uri"
                        },
                        "managementApi": {
                          "type": "string",
                          "format": "uri"
                        },
                        "mcp": {
                          "type": "string",
                          "format": "uri"
                        },
                        "widget": {
                          "type": "string",
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/SandboxRateLimited"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "Key": {
        "name": "key",
        "in": "path",
        "required": true,
        "description": "The account's public key, 1–64 chars of `[A-Za-z0-9_-]`.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,64}$"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional; 1–255 visible ASCII characters (use a UUID, one per checkout attempt). Same key + same\nbody within 24 hours → the stored status and body, with header `Idempotent-Replayed: true`; nothing\nis booked or mailed again. Same key + different body → 422 `idempotency_mismatch`. First request\nstill running → 409 `idempotency_in_progress` (`Retry-After: 1`). Malformed → 400 `bad_request`.\n5xx responses are not stored, so the retry runs for real.\n",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255,
          "pattern": "^[\\x21-\\x7E]+$"
        }
      },
      "ApiVersion": {
        "name": "X-Api-Version",
        "in": "header",
        "required": false,
        "description": "Defaults to the latest version when omitted.",
        "schema": {
          "type": "string",
          "enum": [
            "2026-10-13"
          ]
        }
      }
    },
    "headers": {
      "Retry-After": {
        "description": "Seconds until the rate-limit window resets. Sent on 429 only.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Limit": {
        "description": "Requests allowed per minute for this identity (client IP).",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests left in the current minute.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Reset": {
        "description": "Epoch seconds when the current window resets.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "NotFound": {
        "description": "Unknown key, service, event, package, payment or booking token.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "OriginNotAllowed": {
        "description": "The request `Origin` is not in the account's allowed websites (`allowedOrigins`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Validation": {
        "description": "Invalid input; `fields` names the offending fields.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooLarge": {
        "description": "The upload is larger than the limit (`too_large`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error (details are logged server-side only).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "SandboxRateLimited": {
        "description": "Too many sandboxes created from this IP (`rate_limited`) or globally (`capacity`).",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string",
                      "enum": [
                        "rate_limited",
                        "capacity"
                      ]
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded (`rate_limited`): 120 requests per minute per client IP, or 20 for booking\ncommits.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Tenant": {
        "type": "object",
        "required": [
          "name",
          "timeZone",
          "currency",
          "locale",
          "pricesIncludeTax",
          "depositMode",
          "gateways",
          "cart",
          "chain",
          "recurringPayment",
          "extrasMultiply",
          "showOccupancy"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "timeZone": {
            "type": "string",
            "example": "Europe/Amsterdam"
          },
          "currency": {
            "type": "string",
            "example": "EUR"
          },
          "locale": {
            "type": "string",
            "example": "nl"
          },
          "pricesIncludeTax": {
            "type": "boolean"
          },
          "depositMode": {
            "type": "string",
            "enum": [
              "deposit_only",
              "choose_deposit",
              "choose_full"
            ]
          },
          "gateways": {
            "type": "array",
            "description": "Enabled and configured; never empty (`local` as fallback).",
            "items": {
              "$ref": "#/components/schemas/Gateway"
            }
          },
          "cart": {
            "type": "boolean",
            "description": "Multiple appointments per checkout."
          },
          "chain": {
            "type": "boolean",
            "description": "Booking services back-to-back."
          },
          "recurringPayment": {
            "type": "string",
            "enum": [
              "all",
              "first"
            ],
            "description": "What an online payment covers for a recurring series."
          },
          "extrasMultiply": {
            "type": "boolean",
            "description": "Extra prices multiply by persons."
          },
          "showOccupancy": {
            "type": "string",
            "enum": [
              "off",
              "busy",
              "free"
            ],
            "description": "`busy` shows occupied seats (`3/8`), `free` shows remaining seats."
          }
        }
      },
      "Service": {
        "type": "object",
        "required": [
          "id",
          "title",
          "info",
          "type",
          "price",
          "deposit",
          "taxRate",
          "categoryName",
          "duration",
          "capacityMax",
          "staff",
          "extras",
          "fields",
          "oneBookingPerSlot",
          "recurrence"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "info": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "simple",
              "compound",
              "collaborative",
              "package"
            ]
          },
          "price": {
            "type": "number",
            "description": "The service's own price; a staff member may override it."
          },
          "deposit": {
            "type": "string",
            "description": "'100%', a percentage like '30%', or a fixed amount per person like '20'."
          },
          "taxRate": {
            "type": "number",
            "example": 21
          },
          "categoryName": {
            "type": [
              "string",
              "null"
            ]
          },
          "duration": {
            "type": "integer",
            "description": "Total minutes (longest part for collaborative, sum of parts + spare time for compound)."
          },
          "capacityMax": {
            "type": "integer"
          },
          "staff": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "extras": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Extra"
            }
          },
          "fields": {
            "type": "array",
            "description": "Intake questions, in display order; the booking item sends the answers back by field id. Empty = none.",
            "items": {
              "$ref": "#/components/schemas/Field"
            }
          },
          "oneBookingPerSlot": {
            "type": "boolean"
          },
          "recurrence": {
            "type": "array",
            "description": "Frequencies the service offers; empty = no repeats.",
            "items": {
              "type": "string",
              "enum": [
                "daily",
                "weekly",
                "biweekly",
                "monthly"
              ]
            }
          },
          "parts": {
            "type": "integer",
            "description": "Number of parts (compound/collaborative)."
          },
          "hideStaff": {
            "type": "boolean",
            "description": "Collaborative services only."
          },
          "size": {
            "type": "integer",
            "description": "Appointments per package."
          },
          "lifeDays": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Package validity in days; null = unlimited."
          },
          "serviceTitle": {
            "type": "string",
            "description": "Package: the service the visits are for."
          },
          "serviceId": {
            "type": "string",
            "format": "uuid",
            "description": "Package: id of the base service."
          }
        }
      },
      "Extra": {
        "type": "object",
        "required": [
          "id",
          "title",
          "price",
          "duration",
          "minQty",
          "maxQty"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "price": {
            "type": "number"
          },
          "duration": {
            "type": "integer",
            "description": "Extra minutes added after the appointment."
          },
          "minQty": {
            "type": "integer",
            "description": "1 or more = mandatory."
          },
          "maxQty": {
            "type": "integer"
          },
          "partServiceId": {
            "type": "string",
            "format": "uuid",
            "description": "Compound/collaborative: the part that owns this extra."
          }
        }
      },
      "Field": {
        "type": "object",
        "required": [
          "id",
          "label",
          "kind",
          "required",
          "perPerson",
          "options"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "label": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "text",
              "textarea",
              "select",
              "checkbox",
              "number",
              "date",
              "file"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "perPerson": {
            "type": "boolean",
            "description": "One answer per person."
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Select only; the answer must be one of these."
          },
          "showIf": {
            "type": [
              "object",
              "null"
            ],
            "description": "Ask this question only while the referenced earlier answer equals `is` (trimmed; a checkbox is \"true\"/\"false\"). A question whose condition fails is not required and an answer sent for it is dropped.",
            "required": [
              "field",
              "is"
            ],
            "properties": {
              "field": {
                "type": "string",
                "format": "uuid",
                "description": "An earlier",
                "unconditional": null,
                "non-per-person question of the same service.": null
              },
              "is": {
                "type": "string",
                "maxLength": 100
              }
            }
          }
        }
      },
      "ItemInput": {
        "type": "object",
        "required": [
          "service"
        ],
        "properties": {
          "service": {
            "type": "string",
            "format": "uuid"
          },
          "staff": {
            "type": [
              "string",
              "null"
            ],
            "description": "Staff id, or `any`/omitted to let the engine choose."
          },
          "start": {
            "type": "string",
            "description": "ISO 8601 UTC, exactly as returned by a slot search. A day service returns it with the business offset (local midnight); send it back as-is."
          },
          "end": {
            "type": "string",
            "description": "Day services only (`durationDays` > 0): the `end` of the same slot — local midnight `durationDays` calendar days after `start`. Send it back verbatim; the server re-checks the whole span. Ignored for other services."
          },
          "persons": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "default": 1
          },
          "extras": {
            "type": "object",
            "description": "Quantity per extra id; clamped server-side, mandatory extras added. A day service has no extras (any value → 422).",
            "additionalProperties": {
              "type": "integer",
              "minimum": 0
            }
          },
          "fields": {
            "type": "object",
            "description": "Answers to the service's intake questions, keyed by field id (see GET /services). A perPerson field sends an array with one entry per person; `textarea` is capped at 2000 and `text` at 500 characters, `date` is YYYY-MM-DD, and a `file` field sends the upload id from POST /uploads (unknown, another account's or already-used ids are 422). Unknown ids are ignored; a missing or wrong answer is 422 with the field id in `fields`.",
            "additionalProperties": true
          },
          "series": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,64}$",
            "description": "Key linking this item to a rule in the order's `series` map."
          },
          "waitlist": {
            "type": "boolean",
            "description": "Join the waiting list; cannot combine with `series` or `package`."
          },
          "package": {
            "type": "string",
            "pattern": "^[0-9a-f]{48}$",
            "description": "Token of a bought package; the visit is not paid. Requires `persons: 1`."
          },
          "kind": {
            "type": "string",
            "enum": [
              "booking",
              "package"
            ],
            "default": "booking",
            "description": "`package` buys a package itself (no `start`)."
          }
        }
      },
      "SlotsResponse": {
        "type": "object",
        "required": [
          "days",
          "hasMore"
        ],
        "properties": {
          "days": {
            "type": "array",
            "description": "Only days that have slots appear, in date order; days without free times are omitted.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "slots"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "slots": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Slot"
                  }
                }
              }
            }
          },
          "hasMore": {
            "type": "boolean",
            "description": "False when the window passed the account's booking horizon."
          }
        }
      },
      "Slot": {
        "type": "object",
        "required": [
          "start",
          "time",
          "staffId"
        ],
        "properties": {
          "start": {
            "type": "string",
            "description": "ISO 8601 the server produced; send this back verbatim. UTC for a time slot, the business-local midnight (with offset) for a day service."
          },
          "time": {
            "type": "string",
            "description": "Business-local `HH:MM`; `00:00` for a day service."
          },
          "staffId": {
            "type": "string",
            "format": "uuid"
          },
          "end": {
            "type": "string",
            "description": "Day services only: local midnight `durationDays` calendar days after `start`; the booked span is `[start, end)`."
          },
          "occupied": {
            "type": "integer",
            "description": "Only when the account shows occupancy; never on a day slot."
          },
          "capacity": {
            "type": "integer",
            "description": "Only when the account shows occupancy."
          },
          "state": {
            "type": "string",
            "enum": [
              "waitlist"
            ],
            "description": "Slot is full but a waiting list is open."
          },
          "waiting": {
            "type": "integer",
            "description": "Persons already waiting (waitlist slots only)."
          },
          "parts": {
            "type": "array",
            "description": "Chain slots only — each resolved part.",
            "items": {
              "type": "object",
              "required": [
                "service",
                "staff",
                "start"
              ],
              "properties": {
                "service": {
                  "type": "string",
                  "format": "uuid"
                },
                "staff": {
                  "type": "string",
                  "format": "uuid"
                },
                "start": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "RecurrenceRule": {
        "type": "object",
        "required": [
          "freq"
        ],
        "properties": {
          "freq": {
            "type": "string",
            "enum": [
              "daily",
              "weekly",
              "biweekly",
              "monthly"
            ]
          },
          "every": {
            "type": "integer",
            "minimum": 1,
            "maximum": 30,
            "description": "Interval."
          },
          "on": {
            "type": "array",
            "description": "ISO weekdays (1 = Monday) for weekly frequencies.",
            "minItems": 1,
            "maxItems": 7,
            "items": {
              "type": "integer",
              "minimum": 1,
              "maximum": 7
            }
          },
          "monthly": {
            "description": "One of `day` (1–31; short months skip) or `nth` + `weekday`.",
            "oneOf": [
              {
                "type": "object",
                "required": [
                  "day"
                ],
                "properties": {
                  "day": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 31
                  }
                }
              },
              {
                "type": "object",
                "required": [
                  "nth",
                  "weekday"
                ],
                "properties": {
                  "nth": {
                    "type": "integer",
                    "enum": [
                      1,
                      2,
                      3,
                      4,
                      -1
                    ]
                  },
                  "weekday": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 7
                  }
                }
              }
            ]
          },
          "until": {
            "type": "string",
            "format": "date",
            "description": "Capped at the booking horizon."
          },
          "count": {
            "type": "integer",
            "minimum": 1,
            "maximum": 52
          }
        }
      },
      "Occurrence": {
        "type": "object",
        "required": [
          "date",
          "start",
          "time",
          "staffId",
          "anotherTime"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "start": {
            "type": [
              "string",
              "null"
            ],
            "description": "null = no free time found for this occurrence."
          },
          "time": {
            "type": [
              "string",
              "null"
            ]
          },
          "staffId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "anotherTime": {
            "type": "boolean",
            "description": "The preferred time was taken; this is a moved occurrence."
          },
          "state": {
            "type": "string",
            "enum": [
              "waitlist"
            ]
          }
        }
      },
      "Quote": {
        "type": "object",
        "required": [
          "subtotal",
          "discount",
          "tax",
          "total",
          "payNow",
          "due",
          "paidType",
          "coupon",
          "items",
          "onSite"
        ],
        "properties": {
          "subtotal": {
            "type": "number"
          },
          "discount": {
            "type": "number"
          },
          "tax": {
            "type": "number",
            "description": "VAT part of the total (included) or on top (excluded)."
          },
          "total": {
            "type": "number"
          },
          "payNow": {
            "type": "number",
            "description": "Amount requested now."
          },
          "due": {
            "type": "number",
            "description": "Amount to pay later (deposit mode)."
          },
          "paidType": {
            "type": "string",
            "enum": [
              "in_full",
              "deposit"
            ]
          },
          "coupon": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "required": [
                  "code",
                  "status"
                ],
                "properties": {
                  "code": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "ok",
                      "invalid",
                      "expired"
                    ]
                  }
                }
              }
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "price",
                "extras",
                "payNow"
              ],
              "properties": {
                "price": {
                  "type": "number"
                },
                "extras": {
                  "type": "number"
                },
                "payNow": {
                  "type": "boolean"
                }
              }
            }
          },
          "onSite": {
            "type": "number",
            "description": "Recurring 'first': amount of occurrences paid on site."
          }
        }
      },
      "BookingResult": {
        "type": "object",
        "required": [
          "orderToken",
          "items",
          "payment"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "First item's booking id (legacy single-item clients)."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "rejected",
              "cancelled",
              "done",
              "waitlisted"
            ]
          },
          "start": {
            "type": [
              "string",
              "null"
            ]
          },
          "serviceTitle": {
            "type": "string"
          },
          "staffName": {
            "type": "string"
          },
          "fields": {
            "$ref": "#/components/schemas/BookingFields",
            "description": "The stored answers of the first item (legacy position), keyed by field id."
          },
          "orderToken": {
            "type": "string",
            "description": "48-hex order token."
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "status",
                "serviceTitle",
                "staffName"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "package"
                  ],
                  "description": "Present for package purchases."
                },
                "status": {
                  "type": "string",
                  "description": "`package` for package purchases, else a booking status."
                },
                "start": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "serviceTitle": {
                  "type": "string"
                },
                "staffName": {
                  "type": "string"
                },
                "fields": {
                  "$ref": "#/components/schemas/BookingFields",
                  "description": "The stored answers, keyed by field id."
                }
              }
            }
          },
          "payment": {
            "$ref": "#/components/schemas/PaymentRef"
          }
        }
      },
      "BookingFields": {
        "type": "object",
        "description": "Answers to the service's intake questions as stored on the booking; a `file` answer is `{id, name}`.",
        "additionalProperties": true
      },
      "PaymentRef": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "completed",
              "none"
            ]
          },
          "checkoutUrl": {
            "type": "string",
            "description": "Present for Mollie orders; redirect the browser here."
          }
        }
      },
      "PaymentStatus": {
        "type": "object",
        "required": [
          "status",
          "items",
          "attendees"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "completed",
              "rejected",
              "refunded"
            ]
          },
          "booking": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "serviceTitle": {
                    "type": "string"
                  },
                  "staffName": {
                    "type": "string"
                  },
                  "start": {
                    "type": "string"
                  }
                }
              }
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "status",
                "start",
                "serviceTitle",
                "staffName"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "status": {
                  "type": "string"
                },
                "start": {
                  "type": "string"
                },
                "serviceTitle": {
                  "type": "string"
                },
                "staffName": {
                  "type": "string"
                }
              }
            }
          },
          "attendees": {
            "type": "array",
            "description": "Event tickets, only once approved (paid).",
            "items": {
              "type": "object",
              "required": [
                "code"
              ],
              "properties": {
                "code": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "PackageOverview": {
        "type": "object",
        "required": [
          "service",
          "packageTitle",
          "staff",
          "size",
          "used",
          "remaining",
          "expiresOn",
          "firstName",
          "visits"
        ],
        "properties": {
          "service": {
            "type": "object",
            "required": [
              "id",
              "title",
              "duration"
            ],
            "description": "The base service a visit books; its intake questions are what a visit must answer.",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "title": {
                "type": "string"
              },
              "duration": {
                "type": "integer"
              },
              "fields": {
                "type": "array",
                "description": "The intake questions a visit must answer",
                "same shape as in GET services.": null,
                "items": {
                  "$ref": "#/components/schemas/Field"
                }
              }
            }
          },
          "packageTitle": {
            "type": "string"
          },
          "staff": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "required": [
                  "id",
                  "name"
                ],
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "name": {
                    "type": "string"
                  }
                }
              }
            ]
          },
          "size": {
            "type": "integer"
          },
          "used": {
            "type": "integer"
          },
          "remaining": {
            "type": "integer"
          },
          "expiresOn": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "null = not started or unlimited."
          },
          "firstName": {
            "type": "string"
          },
          "visits": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "start",
                "status"
              ],
              "properties": {
                "start": {
                  "type": "string"
                },
                "status": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "Event": {
        "type": "object",
        "required": [
          "id",
          "title",
          "info",
          "imageUrl",
          "start",
          "end",
          "tickets"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "info": {
            "type": "string"
          },
          "imageUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "start": {
            "type": "string"
          },
          "end": {
            "type": "string"
          },
          "tickets": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "title",
                "price",
                "left"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "title": {
                  "type": "string"
                },
                "price": {
                  "type": "number"
                },
                "left": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "Gateway": {
        "type": "string",
        "enum": [
          "local",
          "mollie"
        ],
        "description": "`local` = pay on site, `mollie` = online (iDEAL/card on Mollie's page)."
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "not_found",
              "origin_not_allowed",
              "validation",
              "slot_unavailable",
              "cannot_reschedule",
              "sold_out",
              "sales_closed",
              "payment_unavailable",
              "bad_request",
              "idempotency_in_progress",
              "idempotency_mismatch",
              "rate_limited",
              "server"
            ]
          },
          "fields": {
            "type": "object",
            "description": "Present for `validation`; field name → `invalid` | `required` | `too_long`.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "item": {
            "type": "integer",
            "description": "0-based index of the failing item."
          },
          "type": {
            "type": "string",
            "description": "Ticket type id for `sold_out`."
          },
          "versions": {
            "type": "array",
            "description": "Present for `bad_request` when `X-Api-Version` is unknown; the supported versions.",
            "items": {
              "type": "string",
              "example": "2026-10-13"
            }
          },
          "retryAfter": {
            "type": "integer",
            "description": "Present for `rate_limited` (429), same value as the `Retry-After` header."
          }
        }
      }
    }
  }
}
