OpenAPI 3.1.1 · v1.0.0 · 231 operations
Gaplessly API
Two booking engines sharing plumbing, never booking logic.
Every path, status code, auth gate and engine guard below is checked against the route handlers on every build by scripts/check-openapi.ts. If a route changes without this page changing, the build fails. It is not a hand-maintained document that hopes to stay true.
Read this first
Two engines, not one API with a flag
Gaplessly runs two separate booking products behind one multi-tenant shell. They share plumbing (clients, notifications, auth, uploads, organization settings) and never share booking logic. Separate tables, separate Postgres exclusion constraints, separate slot mathematics.
Appointments
A client books one service with one staff member for a set duration. Salons, clinics, studios, tutors, consultants.
- Tables
book_servicesbook_providersbook_appointments- No double-booking
book_appointments_no_overlapon the provider- Slot maths
computeAvailableSlots()
Table reservations
A guest books a table for a party size inside a service period, for a turn. Restaurants, cafés, bars.
- Tables
book_tablesbook_service_periodsbook_reservations- No double-booking
book_reservations_no_overlapon the table- Slot maths
computeReservationSlots()
The 403 you will hit first
An organization picks its engine once, at creation, via businessType. It cannot be changed afterwards: flipping a live org strands whatever it already has, so a conversion is a data migration rather than a settings toggle.
Operations carrying an engine-guarded badge check that choice at the API layer and return 403 on a mismatch. An appointments org calling POST /api/tables is refused before any write. Operations without the badge do not make that check.
Shared operations deliberately carry no guard: a client record, a teammate and an org setting mean the same thing to both products, and guarding them would lock half the product out of its own settings.
One honest asymmetry. The public /api/book/{slug}/availability and its appointments checkout are grouped under the appointments engine but carry no badge, because they check only that the org is active. Their hospitality counterparts resolve the org through a helper that refuses a non-hospitality business outright. So the public surface enforces the boundary in one direction only.
Authentication
Which scheme a route accepts is the route, not a choice
Two authentication schemes coexist, and which one a route accepts is not a preference; it is the route. The dashboard's own surface (everything not under /api/v1) is session-cookie only and has no rate limiting. The programmatic surface is /api/v1/* and is API-key only: eight read endpoints, one for every :read scope in the vocabulary, all paged the same way and all camelCase. A session cookie will not authenticate an /api/v1 route and an API key will not authenticate anything else; notably not key management itself, which is why a leaked key cannot mint another. Full contract: docs/api-keys.md.
sessionCookieapiKey · cookie · sb-qxrvgfkjyvbngipqvslu-auth-tokenThe dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified app_metadata.company_id claim and enforced by row-level security; it is never read from request input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code, the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's own check is the enforcement.
cronBearerhttp · bearerThe CRON_SECRET value, sent as Authorization: Bearer <secret>. Used by exactly one route. Fails closed when the secret is unset.
applePassAuthhttp · ApplePassApple Wallet's own Web Service protocol header, Authorization: ApplePass <authenticationToken> (not a registered IANA scheme, but the literal token Apple's own iOS PassKit sends). Checked against book_loyalty_members.authentication_token (migration 0160) for the row the URL's serialNumber names, via timingSafeEqual (src/lib/loyalty/apple-web-service-auth.ts). A phone talking to these routes has no Supabase session and never will; this header is the entire gate.
bearerApiKeyhttp · bearerAuthorization: Bearer sk_live_<16 hex key id>_<43 char base64url secret>
The scheme for programmatic callers: partner integrations, a customer's own scripts, the planned skill and MCP server. Accepted by the /api/v1/* operations and by nothing else; the rest of this document is session-cookie only. Every :read scope below has a GET /api/v1/<resource> behind it, and a static check fails the build if a scope is added without one. Full detail lives in docs/api-keys.md; the load-bearing parts are:
- Transport. Authorization: Bearer sk_live_… only. No query parameter, no custom header, no cookie fallback: a credential in a URL ends up in access logs, referrers and browser history. - A key is a program with scopes, not a human with a role. It carries no admin/staff role at all; scopes are the entire authorization model, and an admin issuing a key is deliberately delegating their own authority. - Scopes are `resource:action`, where resource is exactly the /api/<resource> folder name and action is read or write. write does NOT imply read: they are compared by exact string. - The engine boundary is enforced twice. Engine scopes are filtered at issuance against the org's own business_type (an appointments org cannot be granted reservations:*), and again at use time by the gate's vertical argument. wrong_vertical is its own 403 code. - There is no scope for key management. A key can never mint, list or revoke another key, so a leaked key cannot be used to establish persistence. organization is read-only for the same reason migration 0022 exists. - Errors carry a machine-readable `code` alongside `error`: missing_authorization, invalid_key, key_revoked, key_expired, organization_inactive (all 401); insufficient_scope, wrong_vertical (403); rate_limited (429). Match on code, never on the sentence. Note this is a RICHER error body than the {error} every session-authenticated route in this document returns. - Rate limiting is per key, a fixed 60-second window defaulting to 120 requests/minute, with Retry-After and X-RateLimit-* headers on a 429. Session-authenticated routes have no rate limiting in application code, deliberately: the rest of the app is covered at the edge by a Vercel WAF rate-limit rule, which is not billed for what it blocks and never reaches a function. See docs/api-keys.md. - One envelope, one paging scheme. Every list answers { data: [...], nextCursor }; the singleton answers { data: {...} }. nextCursor is keyset (seek) paging, opaque, and null exactly when there are no more rows. Every field is camelCase, unlike the database columns underneath.
Conventions
What is true of every route
- Tenancy is never request input
On no route, ever, is the organization read from a body field, header or query parameter. A session request takes it from the verified
app_metadata.company_idclaim and row-level security enforces it a second time. An API-key request takes it from the key row. Because a key has no Supabase JWT, RLS has no claim to read there, so the handler's explicitcompany_idfilter is the entire tenant boundary. A static check asserts that filter exists on every key-authenticated route.- Errors are one shape
Every non-2xx body is
{ "error": "a sentence" }. Key-authenticated routes add{ "code": "machine_readable" }: match on the code, never on the sentence. Session routes have no error codes at all: the HTTP status is the code.- PATCH does not always mean partial
Some PATCH routes are true partial updates that write only the keys present in the body: the two booking editors and the client editor. Others rebuild every field from the body on every call, so omitting a field resets it:
PATCH /api/companiesand the four full-replacement setup routes (services, providers, tables, service periods). Each operation says which it is. Read before you send.- Derived fields cannot be set
ends_atis always computed from the start plus a duration. An appointment'spriceandduration_minutesare snapshotted from the service chosen; a reservation'sturn_minutesis a free field, because a reservation has no service to snapshot a length from. That difference is the clearest single illustration of why these are two engines.- Double-booking is a database guarantee
Overlap is prevented by Postgres exclusion constraints, not by an application check. Every write that could collide surfaces that as a 409, and the guest-facing flows re-derive the requested slot server-side rather than trusting a posted time: the constraint is the arbiter, the availability read is only an optimisation.
- Case is not uniform, and that is documented
Request bodies are camelCase. Responses are camelCase except where a handler selects a database row straight back: the two guest-checkout confirmations and the
/api/v1list endpoints return snake_case. Each of those says so at the operation.- Rate limiting exists only on /api/v1
API keys get a per-key fixed 60-second window, 120 requests per minute by default, with
Retry-AfterandX-RateLimit-*headers. Every session-authenticated route has no rate limiting whatsoever. There is also no CORS policy anywhere, so the session surface is same-origin in practice.- A malformed UUID is a 404
Postgres raises
22P02for an unparseable uuid, and routes map that to "not found" rather than letting it surface as a 500. A path id belonging to another organization is also a 404, since row-level security simply matches zero rows. An id in the body that does not resolve is usually a 400 instead: it is a validation failure, not a missing resource.
Reference
All 231 operations
Appointments engine
Services, staff and appointments. Session-authenticated, engine-guarded: a hospitality org gets 403.
post/api/servicesCreate a service
/api/servicesAdds a bookable service (a haircut, a consultation). company_id comes from the verified JWT claim and is never read from the body. Busts the cached public booking config for this org.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| durationMinutes* | integer | (min 5, max 600) |
| price | number | string | null | Coerced with Number() and rounded to 2 decimals. 0-100000. Empty string or null means "no price shown". |
| color* | string | Must be one of SERVICE_COLORS (src/lib/booking/palette.ts). Anything else is a 400. |
| groupId | string | null (uuid) | The service group this sits in (migration 0054). Null or omitted means ungrouped. Tenancy is enforced by the composite FK, so another org's group id, or a deleted one; comes back as a 400 "Group not found". |
| active | boolean | (default true) Defaults to true. Only the literal |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| service* | object |
| service.id | string (uuid) |
A validation message from parseServiceInput, "Group not found" for a groupId that is not this org's (FK 23503), or the raw Postgres message if the insert itself failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create a service",
"description": "Adds a bookable service (a haircut, a consultation). `company_id` comes from the verified JWT claim and is never read from the body. Busts the cached public booking config for this org.",
"tags": [
"Appointments engine"
],
"operationId": "createService",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"durationMinutes",
"color"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"durationMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"price": {
"type": [
"number",
"string",
"null"
],
"description": "Coerced with Number() and rounded to 2 decimals. 0-100000. Empty string or null means \"no price shown\"."
},
"color": {
"type": "string",
"description": "Must be one of SERVICE_COLORS (src/lib/booking/palette.ts). Anything else is a 400."
},
"groupId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The service group this sits in (migration 0054). Null or omitted means ungrouped. Tenancy is enforced by the composite FK, so another org's group id, or a deleted one; comes back as a 400 \"Group not found\"."
},
"active": {
"type": "boolean",
"default": true,
"description": "Defaults to true. Only the literal `false` turns it off (`active !== false`)."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"service"
],
"properties": {
"ok": {
"const": true
},
"service": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseServiceInput, \"Group not found\" for a groupId that is not this org's (FK 23503), or the raw Postgres message if the insert itself failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/servicesReorder and/or regroup services
/api/servicesThe body is one category's (or "Uncategorized"'s, via a null groupId) full shelf in its new order: position in the array becomes the new sort_order, and every listed id is written to groupId. A drag that only reorders within one category sends that category's own id back unchanged; a drag that moves a service into a different (or no) category sends the destination's id and its full resulting id list, including whichever service just arrived from elsewhere, so this one request is the only write a cross-category move needs. Same shape as PATCH /api/service-groups; an id that is not this org's simply matches zero rows under RLS.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| groupId | string (uuid) | The category this shelf belongs to, or null/omitted for "Uncategorized". |
| ids* | string (uuid)[] | (min items 1, max items 300) Every service id on this shelf, in the order they should appear. |
Responses
Reordered.
| Field | Type |
|---|---|
| ok* | true |
ids missing, empty, over 300 entries, or containing a non-uuid; groupId present but not a uuid; or "Group not found" for a groupId that is not this org's (FK 23503).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
Raw OpenAPI operation
{
"summary": "Reorder and/or regroup services",
"description": "The body is one category's (or \"Uncategorized\"'s, via a null groupId) full shelf in its new order: position in the array becomes the new `sort_order`, and every listed id is written to `groupId`. A drag that only reorders within one category sends that category's own id back unchanged; a drag that moves a service into a different (or no) category sends the destination's id and its full resulting id list, including whichever service just arrived from elsewhere, so this one request is the only write a cross-category move needs. Same shape as PATCH /api/service-groups; an id that is not this org's simply matches zero rows under RLS.",
"tags": [
"Appointments engine"
],
"operationId": "reorderServices",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ids"
],
"additionalProperties": false,
"properties": {
"groupId": {
"type": "string",
"format": "uuid",
"nullable": true,
"description": "The category this shelf belongs to, or null/omitted for \"Uncategorized\"."
},
"ids": {
"type": "array",
"minItems": 1,
"maxItems": 300,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Every service id on this shelf, in the order they should appear."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Reordered.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "`ids` missing, empty, over 300 entries, or containing a non-uuid; `groupId` present but not a uuid; or \"Group not found\" for a groupId that is not this org's (FK 23503).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/services/{id}Update a service
/api/services/{id}Full replacement of the service's editable fields; despite the verb, every field in the schema is written on every call, so a partial body resets what it omits. RLS scopes the update, so an id from another org simply matches zero rows and returns 404.
Parameters
id*pathstringService id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| durationMinutes* | integer | (min 5, max 600) |
| price | number | string | null | Coerced with Number() and rounded to 2 decimals. 0-100000. Empty string or null means "no price shown". |
| color* | string | Must be one of SERVICE_COLORS (src/lib/booking/palette.ts). Anything else is a 400. |
| groupId | string | null (uuid) | The service group this sits in (migration 0054). Null or omitted means ungrouped. Tenancy is enforced by the composite FK, so another org's group id, or a deleted one; comes back as a 400 "Group not found". |
| active | boolean | (default true) Defaults to true. Only the literal |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseServiceInput, or "Group not found" for a groupId that is not this org's (FK 23503).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No service with that id in this org. A malformed uuid (Postgres 22P02) is deliberately mapped here rather than surfacing as a 500.
Raw OpenAPI operation
{
"summary": "Update a service",
"description": "Full replacement of the service's editable fields; despite the verb, every field in the schema is written on every call, so a partial body resets what it omits. RLS scopes the update, so an id from another org simply matches zero rows and returns 404.",
"tags": [
"Appointments engine"
],
"operationId": "updateService",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Service id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"durationMinutes",
"color"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"durationMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"price": {
"type": [
"number",
"string",
"null"
],
"description": "Coerced with Number() and rounded to 2 decimals. 0-100000. Empty string or null means \"no price shown\"."
},
"color": {
"type": "string",
"description": "Must be one of SERVICE_COLORS (src/lib/booking/palette.ts). Anything else is a 400."
},
"groupId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The service group this sits in (migration 0054). Null or omitted means ungrouped. Tenancy is enforced by the composite FK, so another org's group id, or a deleted one; comes back as a 400 \"Group not found\"."
},
"active": {
"type": "boolean",
"default": true,
"description": "Defaults to true. Only the literal `false` turns it off (`active !== false`)."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseServiceInput, or \"Group not found\" for a groupId that is not this org's (FK 23503).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No service with that id in this org. A malformed uuid (Postgres 22P02) is deliberately mapped here rather than surfacing as a 500.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/services/{id}Delete a service (admin, or manage_services)
/api/services/{id}Hard delete. The foreign key from book_appointments has no cascade on purpose, so a service with booking history cannot be deleted; deactivate it instead (active: false). Gated to admin, or a staff login granted manage_services (migration 0082): price, duration and every other service setting is a delegable business decision now.
Parameters
id*pathstringService id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error other than the two handled below.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No service with that id in this org, or a malformed uuid.
The service has bookings against it (foreign key violation 23503). "This service has bookings. Deactivate it instead of deleting".
Raw OpenAPI operation
{
"summary": "Delete a service (admin, or manage_services)",
"description": "Hard delete. The foreign key from `book_appointments` has no cascade on purpose, so a service with booking history cannot be deleted; deactivate it instead (`active: false`). Gated to admin, or a staff login granted `manage_services` (migration 0082): price, duration and every other service setting is a delegable business decision now.",
"tags": [
"Appointments engine"
],
"operationId": "deleteService",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Service id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error other than the two handled below.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No service with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The service has bookings against it (foreign key violation 23503). \"This service has bookings. Deactivate it instead of deleting\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/services/{id}/imageUpload a service image
/api/services/{id}/imageStores the file at {companyId}/{serviceId} in the service-images bucket and writes a cache-busted public URL onto the service row. Not admin-gated: a photo is part of editing the service.
Ownership is checked BEFORE the upload (one indexed lookup ahead of a multi-megabyte write), so a request naming a service the caller does not own is a 404 with nothing written; this used to be upload-first-then-sweep, and this paragraph used to describe that.
Parameters
id*pathstringService id.
Request body multipart/form-data
The service image. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| imageUrl* | string (uri) | Public URL with a |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No service with that id in this org. Nothing was uploaded; the ownership check runs first.
Raw OpenAPI operation
{
"summary": "Upload a service image",
"description": "Stores the file at `{companyId}/{serviceId}` in the `service-images` bucket and writes a cache-busted public URL onto the service row. Not admin-gated: a photo is part of editing the service.\n\nOwnership is checked BEFORE the upload (one indexed lookup ahead of a multi-megabyte write), so a request naming a service the caller does not own is a 404 with nothing written; this used to be upload-first-then-sweep, and this paragraph used to describe that.",
"tags": [
"Appointments engine"
],
"operationId": "uploadServiceImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Service id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The service image. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"imageUrl"
],
"properties": {
"ok": {
"const": true
},
"imageUrl": {
"type": "string",
"format": "uri",
"description": "Public URL with a `?v=<timestamp>` cache-buster; the storage path itself never changes."
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No service with that id in this org. Nothing was uploaded; the ownership check runs first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/services/{id}/imageRemove a service image
/api/services/{id}/imageDeletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.
Parameters
id*pathstringService id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No service with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove a service image",
"description": "Deletes the stored object and nulls `image_url`. Storage removal is best-effort and its failure does not fail the request.",
"tags": [
"Appointments engine"
],
"operationId": "deleteServiceImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Service id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No service with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/service-groupsCreate a service group
/api/service-groupsA named shelf services hang off on the public page (migration 0054); "Hair Colouring", "Mens Cuts"; with an optional photo and colour. New groups land at the end of the order (sort_order = max+1). Busts the cached public booking config.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| color | string | null | One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400. |
| active | boolean | (default true) Defaults to true. An inactive group disappears from the public page; its services stay bookable in the flat list sense but stop being shelved under it. |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| group* | object |
| group.id | string (uuid) |
A validation message from parseServiceGroupInput, or the raw Postgres message if the insert itself failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create a service group",
"description": "A named shelf services hang off on the public page (migration 0054); \"Hair Colouring\", \"Mens Cuts\"; with an optional photo and colour. New groups land at the end of the order (`sort_order = max+1`). Busts the cached public booking config.",
"tags": [
"Appointments engine"
],
"operationId": "createServiceGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"color": {
"type": [
"string",
"null"
],
"description": "One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400."
},
"active": {
"type": "boolean",
"default": true,
"description": "Defaults to true. An inactive group disappears from the public page; its services stay bookable in the flat list sense but stop being shelved under it."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"group"
],
"properties": {
"ok": {
"const": true
},
"group": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseServiceGroupInput, or the raw Postgres message if the insert itself failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/service-groupsReorder service groups
/api/service-groupsThe body is the full shelf in its new order, and position in the array IS the new sort_order. Idempotent; an id that is not this org's simply matches zero rows under RLS. One request and one cache bust per drag session, rather than one per step.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| ids* | string (uuid)[] | (min items 1, max items 100) Every group id, in the order they should appear. |
Responses
Reordered.
| Field | Type |
|---|---|
| ok* | true |
ids missing, empty, over 100 entries, or containing a non-uuid.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Reorder service groups",
"description": "The body is the full shelf in its new order, and position in the array IS the new `sort_order`. Idempotent; an id that is not this org's simply matches zero rows under RLS. One request and one cache bust per drag session, rather than one per step.",
"tags": [
"Appointments engine"
],
"operationId": "reorderServiceGroups",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ids"
],
"additionalProperties": false,
"properties": {
"ids": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Every group id, in the order they should appear."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Reordered.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "`ids` missing, empty, over 100 entries, or containing a non-uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/service-groups/{id}Update a service group
/api/service-groups/{id}Full replacement of the group's editable fields, same PATCH-in-name-only semantics as PATCH /api/services/{id}. RLS scopes the update, so an id from another org matches zero rows and returns 404.
Parameters
id*pathstringGroup id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| color | string | null | One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400. |
| active | boolean | (default true) Defaults to true. An inactive group disappears from the public page; its services stay bookable in the flat list sense but stop being shelved under it. |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseServiceGroupInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No group with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.
Raw OpenAPI operation
{
"summary": "Update a service group",
"description": "Full replacement of the group's editable fields, same PATCH-in-name-only semantics as PATCH /api/services/{id}. RLS scopes the update, so an id from another org matches zero rows and returns 404.",
"tags": [
"Appointments engine"
],
"operationId": "updateServiceGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Group id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"color": {
"type": [
"string",
"null"
],
"description": "One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400."
},
"active": {
"type": "boolean",
"default": true,
"description": "Defaults to true. An inactive group disappears from the public page; its services stay bookable in the flat list sense but stop being shelved under it."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseServiceGroupInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No group with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/service-groups/{id}Delete a service group (admin, or manage_services)
/api/service-groups/{id}Never blocks and never takes services with it: the composite FK's column-scoped on delete set null (group_id) (0054) un-groups them in the same statement. That column list is load-bearing; a bare composite set null would null company_id too and fail the delete outright (the 0039 lesson). Gated to admin, or a staff login granted manage_services (migration 0082), same as every other service-catalog write.
Parameters
id*pathstringGroup id.
Responses
Deleted. Its services are now ungrouped.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No group with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Delete a service group (admin, or manage_services)",
"description": "Never blocks and never takes services with it: the composite FK's column-scoped `on delete set null (group_id)` (0054) un-groups them in the same statement. That column list is load-bearing; a bare composite `set null` would null `company_id` too and fail the delete outright (the 0039 lesson). Gated to admin, or a staff login granted `manage_services` (migration 0082), same as every other service-catalog write.",
"tags": [
"Appointments engine"
],
"operationId": "deleteServiceGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Group id."
}
],
"responses": {
"200": {
"description": "Deleted. Its services are now ungrouped.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No group with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providersCreate a staff member
/api/providersCreates the provider row, then its service links and working hours. If the second step fails the provider is deleted again (its cascade takes any partial links with it), so a failure leaves nothing behind. Returns the id so a follow-up avatar upload has something to hang off.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| string | null (email) | (max length 254) | |
| bio | string | null | (max length 500) |
| title | string | null | (max length 120) The public team page's role line (migration 0114), e.g. "Owner & Colorist", not this app's own auth role (admin/staff). |
| active | boolean | (default true) |
| serviceIds | string (uuid)[] | (max items 100) Services this staff member offers. Every id must belong to the caller's org or the whole request is a 400 ("Invalid service selection"). Deduplicated server-side. |
| hours | object[] | (max items 21) Up to 21 windows (3 a day for a week). Repeat a |
| hours[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| hours[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| hours[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
| userId | string | null (uuid) | The login this bookable person signs in as (migration 0034). Admin only: any other role sending this field gets a 403, because from 0034 onward this is what decides whose bookings are whose, and a staff member who could set it could hand themselves another person's work. Absent leaves an existing link untouched; explicit |
| availabilityDelegation | "standard" | "trusted" | This staff member's schedule trust tier (migration 0052). Admin only, PATCH only: POST /api/providers accepts the field for shape but ignores it, since a brand-new row already starts 'standard' at the database default. Absent leaves the existing tier untouched. On PATCH, when a non-admin caller is editing their own row and it is currently 'standard', the |
| conflictAcknowledged | boolean | (default false) PATCH only, and only meaningful when the schedule (hours, cycleWeeks, rotationAnchor or rotationWeeks together) genuinely differs from what is currently stored. No admin-only gate here, unlike POST /api/providers/{id}/availability-overrides: a Standard-tier caller can never trigger the underlying conflict at all (their whole schedule is silently ignored, see |
| cycleWeeks | integer | (min 1, max 8, default 1) Rotating multi-week rosters (migrations 0150/0151). 1 (the default) is the ordinary week, no rotation. Same wholesale-replace and Standard-tier-ignored rules as |
| rotationAnchor | string | null (date) | Required, and validated, only when the resolved |
| rotationWeeks | object[][] | (max items 7) Hours for weeks 1..cycleWeeks-1: week 0 is |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| provider* | object |
| provider.id | string (uuid) |
A validation message from parseProviderInput, "Invalid service selection" when a serviceId does not belong to this org, or a userId that is not on this company's roster.
No valid session cookie. {"error":"Not signed in"}.
The plan's seat cap is full and another seat cannot be billed (reserveSeat, src/lib/billing/seats.ts). Refused before any row is written, so nothing is left behind to clean up. Only a card that actually consumes a seat can reach this: one linked to somebody already on the roster, by userId or by an email that already matches a member, is not a second head and skips the reservation entirely.
Wrong engine for this org, a staff login without manage_staff (migration 0082, "Admin access required"), or a non-admin sent userId ("Only an admin can link a staff login").
That login is already linked to another staff member in this company.
The links/hours insert failed; the provider row was rolled back.
Raw OpenAPI operation
{
"summary": "Create a staff member",
"description": "Creates the provider row, then its service links and working hours. If the second step fails the provider is deleted again (its cascade takes any partial links with it), so a failure leaves nothing behind. Returns the id so a follow-up avatar upload has something to hang off.",
"tags": [
"Appointments engine"
],
"operationId": "createProvider",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": [
"string",
"null"
],
"format": "email",
"maxLength": 254
},
"bio": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"title": {
"type": [
"string",
"null"
],
"maxLength": 120,
"description": "The public team page's role line (migration 0114), e.g. \"Owner & Colorist\", not this app's own auth role (admin/staff)."
},
"active": {
"type": "boolean",
"default": true
},
"serviceIds": {
"type": "array",
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Services this staff member offers. Every id must belong to the caller's org or the whole request is a 400 (\"Invalid service selection\"). Deduplicated server-side."
},
"hours": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
},
"userId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The login this bookable person signs in as (migration 0034). **Admin only**: any other role sending this field gets a 403, because from 0034 onward this is what decides whose bookings are whose, and a staff member who could set it could hand themselves another person's work. Absent leaves an existing link untouched; explicit `null` unlinks. Must be somebody already on this company's roster: a composite FK to `book_org_members (company_id, user_id)` refuses anything else (400), and one login can hold at most one provider row per company (409); though the same person may hold one at each venue of an organization, which is what the future cross-venue work is built on."
},
"availabilityDelegation": {
"type": "string",
"enum": [
"standard",
"trusted"
],
"description": "This staff member's schedule trust tier (migration 0052). **Admin only, PATCH only**: POST /api/providers accepts the field for shape but ignores it, since a brand-new row already starts 'standard' at the database default. Absent leaves the existing tier untouched. On PATCH, when a non-admin caller is editing their own row and it is currently 'standard', the `hours` field is silently ignored (the stored week is kept) rather than rejecting the request; their only path to a schedule change is POST /api/providers/{id}/availability-overrides."
},
"conflictAcknowledged": {
"type": "boolean",
"default": false,
"description": "PATCH only, and only meaningful when the schedule (hours, cycleWeeks, rotationAnchor or rotationWeeks together) genuinely differs from what is currently stored. No admin-only gate here, unlike POST /api/providers/{id}/availability-overrides: a Standard-tier caller can never trigger the underlying conflict at all (their whole schedule is silently ignored, see `hours` above), so every caller who can reach a real conflict here (admin, a manage_staff holder, or a Trusted-tier caller on their own row) is already fully authorized to acknowledge it."
},
"cycleWeeks": {
"type": "integer",
"minimum": 1,
"maximum": 8,
"default": 1,
"description": "Rotating multi-week rosters (migrations 0150/0151). 1 (the default) is the ordinary week, no rotation. Same wholesale-replace and Standard-tier-ignored rules as `hours`, which it rides alongside as one schedule template rather than a separate permission: omitting it is the same as sending 1. PATCH only; POST /api/providers accepts it for shape but ignores it, since a brand-new provider always starts unrotated."
},
"rotationAnchor": {
"type": [
"string",
"null"
],
"format": "date",
"description": "Required, and validated, only when the resolved `cycleWeeks` is more than 1: any date that falls in week 1 of the cycle, used to resolve which week a future date lands on. Ignored (and stored as null) when `cycleWeeks` is 1."
},
"rotationWeeks": {
"type": "array",
"maxItems": 7,
"items": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
},
"description": "Hours for weeks 1..cycleWeeks-1: week 0 is `hours` above. `rotationWeeks[0]` is week 1 (the second week of the cycle), and so on. Length must exactly equal `cycleWeeks - 1` or the whole request is a 400; wholesale-replace, same as `hours`."
}
},
"description": "On PATCH, serviceIds and the working-hours rows are replaced wholesale, not merged. Omitting `hours` entirely is the same as sending `[]`: the staff member works no days and is offered no slots; UNLESS the caller is a Standard-tier staff member editing their own row, in which case the whole schedule (hours, cycleWeeks, rotationAnchor, rotationWeeks) is ignored outright (see availabilityDelegation)."
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"provider"
],
"properties": {
"ok": {
"const": true
},
"provider": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseProviderInput, \"Invalid service selection\" when a serviceId does not belong to this org, or a `userId` that is not on this company's roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "The plan's seat cap is full and another seat cannot be billed (`reserveSeat`, src/lib/billing/seats.ts). Refused before any row is written, so nothing is left behind to clean up. Only a card that actually consumes a seat can reach this: one linked to somebody already on the roster, by `userId` or by an email that already matches a member, is not a second head and skips the reservation entirely.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Wrong engine for this org, a staff login without `manage_staff` (migration 0082, \"Admin access required\"), or a non-admin sent `userId` (\"Only an admin can link a staff login\").",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "That login is already linked to another staff member in this company.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The links/hours insert failed; the provider row was rolled back.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/providers/{id}Update a staff member
/api/providers/{id}Replaces the provider's fields, their service links and their working hours; all three, in a single transaction. The whole replacement is one book_replace_provider() call (migration 0025, SECURITY DEFINER as of 0055) so RLS's tenant_all is never the real ownership boundary here: the function re-checks role and ownership itself, from the database.
It used to be four PostgREST calls with nothing transactional over them, so a failure after the deletes left the staff member with no services and no hours, which means no slots, so the public widget silently stopped offering them until somebody re-saved. That is why there is no longer a 500 here: a partial save is not a state this route can reach.
Links and hours are replaced wholesale, not diffed, because the editor posts the complete set every time.
Ownership (migration 0052): an admin may edit any provider in the org; any other role may only edit the provider row linked to their own login; a check this route was missing entirely before 0052, and the whole reason it exists: any staff-role login could open and submit any colleague's edit modal, hours included. A Standard-tier caller editing their own row additionally has the WHOLE schedule (hours, cycleWeeks, rotationAnchor, rotationWeeks) silently ignored (the stored template is kept) rather than the request being rejected.
Rotation (migrations 0150/0151): cycleWeeks/rotationAnchor/rotationWeeks layer an optional multi-week roster on top of hours (which stays week 0 either way): "week A this week, week B next week, week A again the week after". Wholesale-replace and the Standard-tier boundary both extend to all four fields as one schedule template, not a second permission to check.
Conflicts, added after this route shipped with none at all: replacing the schedule used to write straight through even when a future appointment already sat outside the new pattern, silently orphaning it. Only checked when the schedule genuinely differs from what is currently stored (hours, cycleWeeks or rotationAnchor; changing only the anchor can orphan a booking exactly as surely as changing the hours), and applied per (week, day of week) pair against every non-cancelled future appointment resolved through its own date (an appointment on a date with an approved override is excluded; that date answers to the override, not the recurring week). No staff-vs-admin split on the acknowledgment, unlike the override route: a Standard-tier caller can never reach a real conflict here at all (their whole schedule is silently ignored), so anyone who CAN trigger one is already fully authorized to resend with conflictAcknowledged: true.
Parameters
id*pathstringProvider id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| string | null (email) | (max length 254) | |
| bio | string | null | (max length 500) |
| title | string | null | (max length 120) The public team page's role line (migration 0114), e.g. "Owner & Colorist", not this app's own auth role (admin/staff). |
| active | boolean | (default true) |
| serviceIds | string (uuid)[] | (max items 100) Services this staff member offers. Every id must belong to the caller's org or the whole request is a 400 ("Invalid service selection"). Deduplicated server-side. |
| hours | object[] | (max items 21) Up to 21 windows (3 a day for a week). Repeat a |
| hours[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| hours[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| hours[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
| userId | string | null (uuid) | The login this bookable person signs in as (migration 0034). Admin only: any other role sending this field gets a 403, because from 0034 onward this is what decides whose bookings are whose, and a staff member who could set it could hand themselves another person's work. Absent leaves an existing link untouched; explicit |
| availabilityDelegation | "standard" | "trusted" | This staff member's schedule trust tier (migration 0052). Admin only, PATCH only: POST /api/providers accepts the field for shape but ignores it, since a brand-new row already starts 'standard' at the database default. Absent leaves the existing tier untouched. On PATCH, when a non-admin caller is editing their own row and it is currently 'standard', the |
| conflictAcknowledged | boolean | (default false) PATCH only, and only meaningful when the schedule (hours, cycleWeeks, rotationAnchor or rotationWeeks together) genuinely differs from what is currently stored. No admin-only gate here, unlike POST /api/providers/{id}/availability-overrides: a Standard-tier caller can never trigger the underlying conflict at all (their whole schedule is silently ignored, see |
| cycleWeeks | integer | (min 1, max 8, default 1) Rotating multi-week rosters (migrations 0150/0151). 1 (the default) is the ordinary week, no rotation. Same wholesale-replace and Standard-tier-ignored rules as |
| rotationAnchor | string | null (date) | Required, and validated, only when the resolved |
| rotationWeeks | object[][] | (max items 7) Hours for weeks 1..cycleWeeks-1: week 0 is |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message, "Invalid service selection", a userId that is not on this company's roster, an out-of-range cycleWeeks, a missing/invalid rotationAnchor when cycleWeeks is more than 1, or a rotationWeeks length that does not exactly equal cycleWeeks - 1.
No valid session cookie. {"error":"Not signed in"}.
Wrong engine for this org, a non-admin sent userId or availabilityDelegation, or the caller is staff editing a colleague's row with neither ownership nor manage_staff ("You can only edit your own staff profile", migrations 0052/0082).
No staff member with that id in this org, or a malformed uuid.
Either that login is already linked to another staff member in this company, or the new schedule (hours, cycleWeeks or rotationAnchor) conflicts with existing future bookings. In the schedule case, body includes conflicts (same shape as GET /api/availability-overrides); resend with conflictAcknowledged: true to proceed anyway.
Raw OpenAPI operation
{
"summary": "Update a staff member",
"description": "Replaces the provider's fields, their service links and their working hours; all three, in a single transaction. The whole replacement is one `book_replace_provider()` call (migration 0025, SECURITY DEFINER as of 0055) so RLS's tenant_all is never the real ownership boundary here: the function re-checks role and ownership itself, from the database.\n\nIt used to be four PostgREST calls with nothing transactional over them, so a failure after the deletes left the staff member with no services and no hours, which means no slots, so the public widget silently stopped offering them until somebody re-saved. That is why there is no longer a 500 here: a partial save is not a state this route can reach.\n\nLinks and hours are replaced wholesale, not diffed, because the editor posts the complete set every time.\n\n**Ownership (migration 0052)**: an admin may edit any provider in the org; any other role may only edit the provider row linked to their own login; a check this route was missing entirely before 0052, and the whole reason it exists: any staff-role login could open and submit any colleague's edit modal, `hours` included. A Standard-tier caller editing their own row additionally has the WHOLE schedule (hours, cycleWeeks, rotationAnchor, rotationWeeks) silently ignored (the stored template is kept) rather than the request being rejected.\n\n**Rotation (migrations 0150/0151)**: `cycleWeeks`/`rotationAnchor`/`rotationWeeks` layer an optional multi-week roster on top of `hours` (which stays week 0 either way): \"week A this week, week B next week, week A again the week after\". Wholesale-replace and the Standard-tier boundary both extend to all four fields as one schedule template, not a second permission to check.\n\n**Conflicts**, added after this route shipped with none at all: replacing the schedule used to write straight through even when a future appointment already sat outside the new pattern, silently orphaning it. Only checked when the schedule genuinely differs from what is currently stored (hours, cycleWeeks or rotationAnchor; changing only the anchor can orphan a booking exactly as surely as changing the hours), and applied per (week, day of week) pair against every non-cancelled future appointment resolved through its own date (an appointment on a date with an approved override is excluded; that date answers to the override, not the recurring week). No staff-vs-admin split on the acknowledgment, unlike the override route: a Standard-tier caller can never reach a real conflict here at all (their whole schedule is silently ignored), so anyone who CAN trigger one is already fully authorized to resend with `conflictAcknowledged: true`.",
"tags": [
"Appointments engine"
],
"operationId": "updateProvider",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": [
"string",
"null"
],
"format": "email",
"maxLength": 254
},
"bio": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"title": {
"type": [
"string",
"null"
],
"maxLength": 120,
"description": "The public team page's role line (migration 0114), e.g. \"Owner & Colorist\", not this app's own auth role (admin/staff)."
},
"active": {
"type": "boolean",
"default": true
},
"serviceIds": {
"type": "array",
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Services this staff member offers. Every id must belong to the caller's org or the whole request is a 400 (\"Invalid service selection\"). Deduplicated server-side."
},
"hours": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
},
"userId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The login this bookable person signs in as (migration 0034). **Admin only**: any other role sending this field gets a 403, because from 0034 onward this is what decides whose bookings are whose, and a staff member who could set it could hand themselves another person's work. Absent leaves an existing link untouched; explicit `null` unlinks. Must be somebody already on this company's roster: a composite FK to `book_org_members (company_id, user_id)` refuses anything else (400), and one login can hold at most one provider row per company (409); though the same person may hold one at each venue of an organization, which is what the future cross-venue work is built on."
},
"availabilityDelegation": {
"type": "string",
"enum": [
"standard",
"trusted"
],
"description": "This staff member's schedule trust tier (migration 0052). **Admin only, PATCH only**: POST /api/providers accepts the field for shape but ignores it, since a brand-new row already starts 'standard' at the database default. Absent leaves the existing tier untouched. On PATCH, when a non-admin caller is editing their own row and it is currently 'standard', the `hours` field is silently ignored (the stored week is kept) rather than rejecting the request; their only path to a schedule change is POST /api/providers/{id}/availability-overrides."
},
"conflictAcknowledged": {
"type": "boolean",
"default": false,
"description": "PATCH only, and only meaningful when the schedule (hours, cycleWeeks, rotationAnchor or rotationWeeks together) genuinely differs from what is currently stored. No admin-only gate here, unlike POST /api/providers/{id}/availability-overrides: a Standard-tier caller can never trigger the underlying conflict at all (their whole schedule is silently ignored, see `hours` above), so every caller who can reach a real conflict here (admin, a manage_staff holder, or a Trusted-tier caller on their own row) is already fully authorized to acknowledge it."
},
"cycleWeeks": {
"type": "integer",
"minimum": 1,
"maximum": 8,
"default": 1,
"description": "Rotating multi-week rosters (migrations 0150/0151). 1 (the default) is the ordinary week, no rotation. Same wholesale-replace and Standard-tier-ignored rules as `hours`, which it rides alongside as one schedule template rather than a separate permission: omitting it is the same as sending 1. PATCH only; POST /api/providers accepts it for shape but ignores it, since a brand-new provider always starts unrotated."
},
"rotationAnchor": {
"type": [
"string",
"null"
],
"format": "date",
"description": "Required, and validated, only when the resolved `cycleWeeks` is more than 1: any date that falls in week 1 of the cycle, used to resolve which week a future date lands on. Ignored (and stored as null) when `cycleWeeks` is 1."
},
"rotationWeeks": {
"type": "array",
"maxItems": 7,
"items": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
},
"description": "Hours for weeks 1..cycleWeeks-1: week 0 is `hours` above. `rotationWeeks[0]` is week 1 (the second week of the cycle), and so on. Length must exactly equal `cycleWeeks - 1` or the whole request is a 400; wholesale-replace, same as `hours`."
}
},
"description": "On PATCH, serviceIds and the working-hours rows are replaced wholesale, not merged. Omitting `hours` entirely is the same as sending `[]`: the staff member works no days and is offered no slots; UNLESS the caller is a Standard-tier staff member editing their own row, in which case the whole schedule (hours, cycleWeeks, rotationAnchor, rotationWeeks) is ignored outright (see availabilityDelegation)."
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message, \"Invalid service selection\", a `userId` that is not on this company's roster, an out-of-range `cycleWeeks`, a missing/invalid `rotationAnchor` when `cycleWeeks` is more than 1, or a `rotationWeeks` length that does not exactly equal `cycleWeeks - 1`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Wrong engine for this org, a non-admin sent `userId` or `availabilityDelegation`, or the caller is staff editing a colleague's row with neither ownership nor `manage_staff` (\"You can only edit your own staff profile\", migrations 0052/0082).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Either that login is already linked to another staff member in this company, or the new schedule (hours, cycleWeeks or rotationAnchor) conflicts with existing future bookings. In the schedule case, body includes `conflicts` (same shape as GET /api/availability-overrides); resend with `conflictAcknowledged: true` to proceed anyway.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/providers/{id}Delete a staff member (admin, or manage_staff)
/api/providers/{id}Hard delete. As with services, the appointment foreign key has no cascade, so a provider with bookings cannot be removed. Gated to admin, or a staff login granted manage_staff (migration 0082).
Parameters
id*pathstringProvider id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_staff permission (migration 0082, Admin access required).
No staff member with that id in this org.
The staff member has bookings against them. "Deactivate them instead of deleting".
Raw OpenAPI operation
{
"summary": "Delete a staff member (admin, or manage_staff)",
"description": "Hard delete. As with services, the appointment foreign key has no cascade, so a provider with bookings cannot be removed. Gated to admin, or a staff login granted `manage_staff` (migration 0082).",
"tags": [
"Appointments engine"
],
"operationId": "deleteProvider",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_staff` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The staff member has bookings against them. \"Deactivate them instead of deleting\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providers/{id}/archiveArchive a staff member (admin, or manage_staff)
/api/providers/{id}/archiveThe third state alongside active and deleted, and the one to reach for when somebody leaves: unlike DELETE it works on a staff member who has bookings, and unlike the free active toggle it stops the seat being billed.
Deliberately reversible. Only the photo is thrown away; bio, phone, service links and working hours all survive untouched, and a linked login is suspended rather than unlinked, so POST /api/providers/{id}/reactivate has nothing to re-enter or re-link. All of it happens inside one book_archive_provider() call (migration 0086), so a partial failure cannot leave a login suspended with the card still reading as active, or the reverse.
Billing moves rather than stops: the person leaves the normal seat cap (seatsUsed() excludes them) and joins a separate archived count, free and unlimited on Pro and Enterprise, $1/mo each on Basic. Solo and Free are refused with a 400 rather than a 402, because there is nothing to buy: the plan-gated feature is archiving itself, and those tiers keep the free active toggle on PATCH /api/providers/{id} instead. The tier is re-read from the database here rather than trusted from the caller.
Parameters
id*pathstringProvider id.
Responses
Archived.
| Field | Type |
|---|---|
| ok* | true |
The plan does not include archiving ("This plan doesn't include archiving. Use the Active toggle instead, which stays free", Solo and Free), the staff member is already archived, they are the caller's own profile, they are the last admin, or an unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_staff permission (migration 0082, Admin access required).
No staff member with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Archive a staff member (admin, or manage_staff)",
"description": "The third state alongside active and deleted, and the one to reach for when somebody leaves: unlike DELETE it works on a staff member who has bookings, and unlike the free `active` toggle it stops the seat being billed.\n\nDeliberately reversible. Only the photo is thrown away; bio, phone, service links and working hours all survive untouched, and a linked login is suspended rather than unlinked, so POST /api/providers/{id}/reactivate has nothing to re-enter or re-link. All of it happens inside one `book_archive_provider()` call (migration 0086), so a partial failure cannot leave a login suspended with the card still reading as active, or the reverse.\n\nBilling moves rather than stops: the person leaves the normal seat cap (`seatsUsed()` excludes them) and joins a separate archived count, free and unlimited on Pro and Enterprise, $1/mo each on Basic. Solo and Free are refused with a 400 rather than a 402, because there is nothing to buy: the plan-gated feature is archiving itself, and those tiers keep the free `active` toggle on PATCH /api/providers/{id} instead. The tier is re-read from the database here rather than trusted from the caller.",
"tags": [
"Appointments engine"
],
"operationId": "archiveProvider",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"responses": {
"200": {
"description": "Archived.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "The plan does not include archiving (\"This plan doesn't include archiving. Use the Active toggle instead, which stays free\", Solo and Free), the staff member is already archived, they are the caller's own profile, they are the last admin, or an unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_staff` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providers/{id}/reactivateReactivate an archived staff member (admin, or manage_staff)
/api/providers/{id}/reactivateThe exact inverse of POST /api/providers/{id}/archive, and it takes no request body at all: archiving kept everything except the photo, so book_reactivate_provider() only has to clear archived_at and, where a login was linked, that membership row's suspended_at.
The seat is reserved FIRST, before the person is restored, so an org that has since filled its plan refuses cleanly with the same 402 a brand-new hire would get rather than half-reactivating somebody the plan can no longer afford. Their archived-seat charge is dropped only once the reactivation has actually succeeded.
No sign-out is needed on the member's side: role and suspension are re-read from the roster on every dashboard request and were never cached in the JWT, so access returns on their very next request.
Parameters
id*pathstringProvider id.
Responses
Reactivated.
| Field | Type |
|---|---|
| ok* | true |
That staff member is not archived, or an unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
The plan's seat cap is full and another seat cannot be billed (reserveSeat, src/lib/billing/seats.ts), so there is no room to bring this person back. Nothing was changed. Free a seat or upgrade, then retry; the archived record is untouched and keeps waiting.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_staff permission (migration 0082, Admin access required).
No staff member with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Reactivate an archived staff member (admin, or manage_staff)",
"description": "The exact inverse of POST /api/providers/{id}/archive, and it takes no request body at all: archiving kept everything except the photo, so `book_reactivate_provider()` only has to clear `archived_at` and, where a login was linked, that membership row's `suspended_at`.\n\nThe seat is reserved FIRST, before the person is restored, so an org that has since filled its plan refuses cleanly with the same 402 a brand-new hire would get rather than half-reactivating somebody the plan can no longer afford. Their archived-seat charge is dropped only once the reactivation has actually succeeded.\n\nNo sign-out is needed on the member's side: role and suspension are re-read from the roster on every dashboard request and were never cached in the JWT, so access returns on their very next request.",
"tags": [
"Appointments engine"
],
"operationId": "reactivateProvider",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"responses": {
"200": {
"description": "Reactivated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "That staff member is not archived, or an unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "The plan's seat cap is full and another seat cannot be billed (`reserveSeat`, src/lib/billing/seats.ts), so there is no room to bring this person back. Nothing was changed. Free a seat or upgrade, then retry; the archived record is untouched and keeps waiting.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_staff` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providers/{id}/avatarUpload a staff photo
/api/providers/{id}/avatarByte-for-byte the service-image route with the nouns swapped: bucket staff-photos, path {companyId}/{providerId}, same verify-then-upload ordering (the id is validated and the row confirmed to be the caller's before anything is written).
Parameters
id*pathstringProvider id.
Request body multipart/form-data
The staff photo. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type |
|---|---|
| ok* | true |
| avatarUrl* | string (uri) |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No staff member with that id in this org.
Raw OpenAPI operation
{
"summary": "Upload a staff photo",
"description": "Byte-for-byte the service-image route with the nouns swapped: bucket `staff-photos`, path `{companyId}/{providerId}`, same verify-then-upload ordering (the id is validated and the row confirmed to be the caller's before anything is written).",
"tags": [
"Appointments engine"
],
"operationId": "uploadProviderAvatar",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The staff photo. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"avatarUrl"
],
"properties": {
"ok": {
"const": true
},
"avatarUrl": {
"type": "string",
"format": "uri"
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/providers/{id}/avatarRemove a staff photo
/api/providers/{id}/avatarDeletes the stored object and nulls avatar_url.
Parameters
id*pathstringProvider id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No staff member with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove a staff photo",
"description": "Deletes the stored object and nulls `avatar_url`.",
"tags": [
"Appointments engine"
],
"operationId": "deleteProviderAvatar",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No staff member with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providers/{id}/availability-overridesRequest a date-specific schedule override
/api/providers/{id}/availability-overridesSubmit a one-date replacement for this provider's recurring hours (migration 0052); more hours, fewer, or none at all (a day off). The approve-immediately-or-queue decision is made INSIDE book_submit_availability_override, from the caller's role, the provider's availability_delegation tier, and the org's availability_approval_horizon_days, all read from the database inside the function's own transaction, never trusted from this request body, so calling the underlying RPC directly cannot produce a different outcome than this route does.
Ownership: an admin may submit for any provider in the org; any other role only for the provider row linked to their own login. An unlinked staff login gets 403; a deliberate fail-closed departure from this codebase's usual fail-open convention for an unlinked staff member, because that convention protects a front-desk person's own usability, not license to edit someone else's schedule with no approval.
Conflicts against existing, non-cancelled appointments on the date are checked live, unconditionally. A staff caller with a conflict is always hard-blocked (409, nothing persisted); they already have the tool to fix it themselves (reschedule/cancel their own booking). An admin caller may proceed by sending conflictAcknowledged: true.
On success, notifies the org's admins always, and the affected staff login too when the change applied without needing their own approval and they were not the one who submitted it (an admin acting on their behalf).
Parameters
id*pathstringProvider id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| overrideDate* | string (date) | Must not be in the past. |
| windows | object[] | (max items 6) Up to 6 windows for the one date. Empty means the whole day off. |
| windows[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| windows[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, must differ from startTime. |
| reason | string | (max length 300) |
| conflictAcknowledged | boolean | (default false) Admin only; ignored for any other caller, both here and inside the RPC. |
Responses
Submitted; either pending or already applied, see status.
| Field | Type |
|---|---|
| ok* | true |
| id* | string (uuid) |
| status* | "pending" | "approved" |
An invalid date, malformed windows, a reason over 300 characters, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Wrong engine for this org, or the caller is staff with no linked provider or a linked provider other than this one ("You can only request a change to your own schedule").
No provider with that id in this org.
The proposed windows conflict with existing bookings on that date. Body includes conflicts (see GET /api/availability-overrides). A staff caller cannot proceed at all; an admin caller may resend with conflictAcknowledged: true.
Raw OpenAPI operation
{
"summary": "Request a date-specific schedule override",
"description": "Submit a one-date replacement for this provider's recurring hours (migration 0052); more hours, fewer, or none at all (a day off). The approve-immediately-or-queue decision is made INSIDE `book_submit_availability_override`, from the caller's role, the provider's `availability_delegation` tier, and the org's `availability_approval_horizon_days`, all read from the database inside the function's own transaction, never trusted from this request body, so calling the underlying RPC directly cannot produce a different outcome than this route does.\n\n**Ownership**: an admin may submit for any provider in the org; any other role only for the provider row linked to their own login. An unlinked staff login gets 403; a deliberate fail-closed departure from this codebase's usual fail-open convention for an unlinked staff member, because that convention protects a front-desk person's own usability, not license to edit someone else's schedule with no approval.\n\n**Conflicts** against existing, non-cancelled appointments on the date are checked live, unconditionally. A staff caller with a conflict is always hard-blocked (409, nothing persisted); they already have the tool to fix it themselves (reschedule/cancel their own booking). An admin caller may proceed by sending `conflictAcknowledged: true`.\n\nOn success, notifies the org's admins always, and the affected staff login too when the change applied without needing their own approval and they were not the one who submitted it (an admin acting on their behalf).",
"tags": [
"Appointments engine"
],
"operationId": "submitAvailabilityOverride",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"overrideDate"
],
"additionalProperties": false,
"properties": {
"overrideDate": {
"type": "string",
"format": "date",
"description": "Must not be in the past."
},
"windows": {
"type": "array",
"maxItems": 6,
"items": {
"type": "object",
"required": [
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, must differ from startTime."
}
}
},
"description": "Up to 6 windows for the one date. Empty means the whole day off."
},
"reason": {
"type": "string",
"maxLength": 300
},
"conflictAcknowledged": {
"type": "boolean",
"default": false,
"description": "Admin only; ignored for any other caller, both here and inside the RPC."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Submitted; either pending or already applied, see `status`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"id",
"status"
],
"properties": {
"ok": {
"const": true
},
"id": {
"type": "string",
"format": "uuid"
},
"status": {
"type": "string",
"enum": [
"pending",
"approved"
]
}
}
}
}
}
},
"400": {
"description": "An invalid date, malformed windows, a reason over 300 characters, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Wrong engine for this org, or the caller is staff with no linked provider or a linked provider other than this one (\"You can only request a change to your own schedule\").",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No provider with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The proposed windows conflict with existing bookings on that date. Body includes `conflicts` (see GET /api/availability-overrides). A staff caller cannot proceed at all; an admin caller may resend with `conflictAcknowledged: true`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/providers/{id}/blocked-periodsMark a staff member away (admin, or manage_staff)
/api/providers/{id}/blocked-periodsWrites a book_blocked_periods row (migration 0001) with this provider's id, covering firstDay 00:00 to lastDay 23:59:59 as one instant range: the same "wall clock wearing a +00" convention every other date-scoped write in this app uses. No engine change was needed to make this take effect. book_blocked_periods with a provider_id was already read on every public path that decides whether a provider is bookable (GET /api/book/{slug}/availability, POST /api/book/{slug}/appointments, the guest reschedule inside /api/book/{slug}/manage/{token}) before this route existed to write one. A provider_id IS NULL row is a DIFFERENT, older feature (a whole-venue closure, set from Organization > Special hours); this route never writes one.
Conflicts against existing, non-cancelled appointments anywhere in the range are checked live, the same findConflictingAppointments pattern POST /api/providers/{id}/availability-overrides and POST /api/organization/special-hours already prove: refused with 409 unless conflictAcknowledged: true. Every caller who can reach this route already holds manage_staff, so unlike the override route there is no staff-vs-admin branch; acknowledgment alone is the gate, same as special-hours' own reasoning.
Self-service (a staff member marking their own leave) is deliberately out of scope: this is the same admin/manage_staff boundary as the rest of provider CRUD, not the tiered self-request flow book_availability_overrides uses.
Parameters
id*pathstringProvider id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| firstDay* | string (date) | YYYY-MM-DD. The range must not have already fully passed. |
| lastDay* | string (date) | YYYY-MM-DD. Must not be before firstDay. |
| reason | string | (max length 300) |
| conflictAcknowledged | boolean | (default false) |
Responses
Saved.
| Field | Type |
|---|---|
| ok* | true |
| blockedPeriod* | object |
| blockedPeriod.id | string (uuid) |
| blockedPeriod.starts_at | string (date-time) |
| blockedPeriod.ends_at | string (date-time) |
| blockedPeriod.reason | string | null |
An invalid or backwards date range, a reason over 300 characters, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_staff permission (migration 0082, Admin access required).
No provider with that id in this org.
The date range conflicts with existing bookings. Body includes conflicts (same shape as GET /api/availability-overrides). Resend with conflictAcknowledged: true to proceed anyway.
Raw OpenAPI operation
{
"summary": "Mark a staff member away (admin, or manage_staff)",
"description": "Writes a book_blocked_periods row (migration 0001) with this provider's id, covering `firstDay` 00:00 to `lastDay` 23:59:59 as one instant range: the same \"wall clock wearing a +00\" convention every other date-scoped write in this app uses. No engine change was needed to make this take effect. book_blocked_periods with a `provider_id` was already read on every public path that decides whether a provider is bookable (GET /api/book/{slug}/availability, POST /api/book/{slug}/appointments, the guest reschedule inside /api/book/{slug}/manage/{token}) before this route existed to write one. A `provider_id IS NULL` row is a DIFFERENT, older feature (a whole-venue closure, set from Organization > Special hours); this route never writes one.\n\n**Conflicts** against existing, non-cancelled appointments anywhere in the range are checked live, the same `findConflictingAppointments` pattern `POST /api/providers/{id}/availability-overrides` and `POST /api/organization/special-hours` already prove: refused with 409 unless `conflictAcknowledged: true`. Every caller who can reach this route already holds `manage_staff`, so unlike the override route there is no staff-vs-admin branch; acknowledgment alone is the gate, same as special-hours' own reasoning.\n\nSelf-service (a staff member marking their own leave) is deliberately out of scope: this is the same admin/manage_staff boundary as the rest of provider CRUD, not the tiered self-request flow `book_availability_overrides` uses.",
"tags": [
"Appointments engine"
],
"operationId": "createProviderBlockedPeriod",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"firstDay",
"lastDay"
],
"additionalProperties": false,
"properties": {
"firstDay": {
"type": "string",
"format": "date",
"description": "YYYY-MM-DD. The range must not have already fully passed."
},
"lastDay": {
"type": "string",
"format": "date",
"description": "YYYY-MM-DD. Must not be before firstDay."
},
"reason": {
"type": "string",
"maxLength": 300
},
"conflictAcknowledged": {
"type": "boolean",
"default": false
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"blockedPeriod"
],
"properties": {
"ok": {
"const": true
},
"blockedPeriod": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"starts_at": {
"type": "string",
"format": "date-time"
},
"ends_at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": [
"string",
"null"
]
}
}
}
}
}
}
}
},
"400": {
"description": "An invalid or backwards date range, a reason over 300 characters, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_staff` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No provider with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The date range conflicts with existing bookings. Body includes `conflicts` (same shape as GET /api/availability-overrides). Resend with `conflictAcknowledged: true` to proceed anyway.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/providers/{id}/blocked-periodsRemove a time-away entry (admin, or manage_staff)
/api/providers/{id}/blocked-periodsThe provider's ordinary hours (and any approved date-specific override) apply again over that range immediately. Scoped to both id and this provider: a bare id match would let a caller delete a whole-venue closure or another provider's entry by reusing a uuid from the same table.
Parameters
id*pathstringProvider id.
id*querystring (uuid)The blocked-period row id.
Responses
Removed, or already absent.
| Field | Type |
|---|---|
| ok* | true |
The query id is missing, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_staff permission (migration 0082, Admin access required).
The query id is not a well-formed uuid.
Raw OpenAPI operation
{
"summary": "Remove a time-away entry (admin, or manage_staff)",
"description": "The provider's ordinary hours (and any approved date-specific override) apply again over that range immediately. Scoped to both `id` and this provider: a bare id match would let a caller delete a whole-venue closure or another provider's entry by reusing a uuid from the same table.",
"tags": [
"Appointments engine"
],
"operationId": "deleteProviderBlockedPeriod",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider id."
},
{
"name": "id",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "The blocked-period row id."
}
],
"responses": {
"200": {
"description": "Removed, or already absent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "The query `id` is missing, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_staff` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The query `id` is not a well-formed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/availability-overridesList availability-override requests
/api/availability-overridesStaff sees only their own linked provider's rows (server-forced, ignoring any client filter; there is none to send); admin sees every row across every status, so this one endpoint powers both a "needs attention" view (filter client-side on status: pending, or approved with conflictAcknowledged and conflictCount > 0) and a full history view.
conflictCount is recomputed live on every call for an admin caller (always 0 for staff); migration 0052 deliberately does not persist which appointments conflicted, so a later cancellation or reassignment through the ordinary calendar screen makes a row stop needing attention with no write required.
Responses
The list, newest date first.
| Field | Type | Notes |
|---|---|---|
| overrides* | object[] | |
| overrides[].id | string (uuid) | |
| overrides[].providerId | string (uuid) | |
| overrides[].providerName | string | null | |
| overrides[].overrideDate | string (date) | |
| overrides[].windows | object[] | (max items 6) Up to 6 windows for the one date. Empty means the whole day off. |
| overrides[].windows[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| overrides[].windows[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, must differ from startTime. |
| overrides[].reason | string | null | (max length 300) |
| overrides[].status | "pending" | "approved" | "rejected" | "cancelled" | "superseded" | |
| overrides[].requiresApproval | boolean | |
| overrides[].autoApplyReason | "trusted_tier" | "beyond_horizon" | "admin_direct" | null | |
| overrides[].createdByRole | "admin" | "staff" | |
| overrides[].createdAt | string (date-time) | |
| overrides[].reviewedAt | string | null (date-time) | |
| overrides[].decisionNote | string | null | (max length 300) |
| overrides[].conflictAcknowledged | boolean | |
| overrides[].conflictCount | integer | Admin only, live-recomputed on every GET; always 0 in a staff response. Never persisted; see the route file for why. |
A Postgres error reading the rows.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "List availability-override requests",
"description": "Staff sees only their own linked provider's rows (server-forced, ignoring any client filter; there is none to send); admin sees every row across every status, so this one endpoint powers both a \"needs attention\" view (filter client-side on `status: pending`, or `approved` with `conflictAcknowledged` and `conflictCount > 0`) and a full history view.\n\n`conflictCount` is recomputed live on every call for an admin caller (always 0 for staff); migration 0052 deliberately does not persist which appointments conflicted, so a later cancellation or reassignment through the ordinary calendar screen makes a row stop needing attention with no write required.",
"tags": [
"Appointments engine"
],
"operationId": "listAvailabilityOverrides",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"responses": {
"200": {
"description": "The list, newest date first.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"overrides"
],
"properties": {
"overrides": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"providerId": {
"type": "string",
"format": "uuid"
},
"providerName": {
"type": [
"string",
"null"
]
},
"overrideDate": {
"type": "string",
"format": "date"
},
"windows": {
"type": "array",
"maxItems": 6,
"items": {
"type": "object",
"required": [
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, must differ from startTime."
}
}
},
"description": "Up to 6 windows for the one date. Empty means the whole day off."
},
"reason": {
"type": [
"string",
"null"
],
"maxLength": 300
},
"status": {
"type": "string",
"enum": [
"pending",
"approved",
"rejected",
"cancelled",
"superseded"
]
},
"requiresApproval": {
"type": "boolean"
},
"autoApplyReason": {
"type": [
"string",
"null"
],
"enum": [
"trusted_tier",
"beyond_horizon",
"admin_direct",
null
]
},
"createdByRole": {
"type": "string",
"enum": [
"admin",
"staff"
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"reviewedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"decisionNote": {
"type": [
"string",
"null"
],
"maxLength": 300
},
"conflictAcknowledged": {
"type": "boolean"
},
"conflictCount": {
"type": "integer",
"description": "Admin only, live-recomputed on every GET; always 0 in a staff response. Never persisted; see the route file for why."
}
}
}
}
}
}
}
}
},
"400": {
"description": "A Postgres error reading the rows.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/availability-overrides/{id}Cancel or reject an availability-override request
/api/availability-overrides/{id}Two plain status transitions on a pending row: cancelled (the staff member who owns the linked provider, on their own row, or any admin) and rejected (admin only). approved is deliberately refused here even though the request shape alone would not stop it; it is a special action (POST .../approve), same rule PATCH /api/waitlist/{id} enforces for notified. superseded is never client-settable at all.
Both transitions call a SECURITY INVOKER database function (book_cancel_availability_override / book_decide_availability_override, migration 0052) rather than writing the row directly; this table has no update policy for authenticated at all, so a raw PostgREST update would be refused the same way from anywhere else. The functions re-check role/ownership themselves from the database; this route's own gates are the ordinary UX path, not the enforcement.
Parameters
id*pathstringAvailability-override request id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| status* | "cancelled" | "rejected" | |
| decisionNote | string | (max length 300) Only meaningful with |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
An unknown status, one this route refuses (approved, superseded), a note over 300 characters, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Wrong engine for this org, a non-admin sent rejected, or the function itself refused (staff trying to cancel someone else's row).
No request with that id in this org.
The request is not currently pending; it was already decided, cancelled, or superseded by a newer submission.
Raw OpenAPI operation
{
"summary": "Cancel or reject an availability-override request",
"description": "Two plain status transitions on a `pending` row: `cancelled` (the staff member who owns the linked provider, on their own row, or any admin) and `rejected` (admin only). `approved` is deliberately refused here even though the request shape alone would not stop it; it is a special action (POST .../approve), same rule PATCH /api/waitlist/{id} enforces for `notified`. `superseded` is never client-settable at all.\n\nBoth transitions call a SECURITY INVOKER database function (`book_cancel_availability_override` / `book_decide_availability_override`, migration 0052) rather than writing the row directly; this table has no update policy for `authenticated` at all, so a raw PostgREST update would be refused the same way from anywhere else. The functions re-check role/ownership themselves from the database; this route's own gates are the ordinary UX path, not the enforcement.",
"tags": [
"Appointments engine"
],
"operationId": "updateAvailabilityOverride",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Availability-override request id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"status"
],
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"enum": [
"cancelled",
"rejected"
]
},
"decisionNote": {
"type": "string",
"maxLength": 300,
"description": "Only meaningful with `rejected`."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unknown status, one this route refuses (`approved`, `superseded`), a note over 300 characters, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Wrong engine for this org, a non-admin sent `rejected`, or the function itself refused (staff trying to cancel someone else's row).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No request with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The request is not currently `pending`; it was already decided, cancelled, or superseded by a newer submission.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/availability-overrides/{id}/approveApprove a pending availability-override request
/api/availability-overrides/{id}/approveAdmin only, mirroring POST /api/waitlist/{id}/offer's shape (a special action gets its own route rather than being a plain status PATCH). Re-runs the conflict check LIVE rather than trusting anything the client sends; a booking made after the request was submitted, or after the admin last looked at the list, must not be silently approved over.
Reassignment is not a parameter here. The dashboard calls the existing, unmodified PATCH /api/appointments/{id} for each conflicting booking it wants to move, sequentially, BEFORE calling this route, never one endpoint doing both, so a failed reassignment cannot leave the override half-decided.
Notifies the submitting staff member's linked login on success.
Parameters
id*pathstringAvailability-override request id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| conflictAcknowledged | boolean | (default false) |
| decisionNote | string | (max length 300) |
Responses
Approved.
| Field | Type |
|---|---|
| ok* | true |
A note over 300 characters, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
No request with that id in this org.
The request is not currently pending, or it conflicts with existing bookings and conflictAcknowledged was not sent (body includes conflicts).
Raw OpenAPI operation
{
"summary": "Approve a pending availability-override request",
"description": "Admin only, mirroring POST /api/waitlist/{id}/offer's shape (a special action gets its own route rather than being a plain status PATCH). Re-runs the conflict check LIVE rather than trusting anything the client sends; a booking made after the request was submitted, or after the admin last looked at the list, must not be silently approved over.\n\n**Reassignment is not a parameter here.** The dashboard calls the existing, unmodified PATCH /api/appointments/{id} for each conflicting booking it wants to move, sequentially, BEFORE calling this route, never one endpoint doing both, so a failed reassignment cannot leave the override half-decided.\n\nNotifies the submitting staff member's linked login on success.",
"tags": [
"Appointments engine"
],
"operationId": "approveAvailabilityOverride",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Availability-override request id."
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"conflictAcknowledged": {
"type": "boolean",
"default": false
},
"decisionNote": {
"type": "string",
"maxLength": 300
}
}
}
}
}
},
"responses": {
"200": {
"description": "Approved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A note over 300 characters, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No request with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The request is not currently `pending`, or it conflicts with existing bookings and `conflictAcknowledged` was not sent (body includes `conflicts`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/appointmentsCreate an appointment, or a repeat series (staff side)
/api/appointmentsA booking taken by staff; a phone call, a walk-in. Deliberately NOT slot-validated the way guest checkout is: staff have the calendar in front of them, and book_appointments_no_overlap (a Postgres exclusion constraint) is the real backstop either way.
price, duration_minutes and ends_at are all snapshotted from the chosen service; sending them is not possible and would not be honoured. Status is always confirmed.
Repeats (migration 0149). An optional repeats object turns this into a series: every so-many weeks, on one or more weekdays, ending after a number of visits or on a date. When present it REPLACES startsAt entirely (repeats.startsOn + repeats.timeOfDay already say when the first visit is) and the response shape changes; see the 200 below. Deliberately the only creation surface a repeat series gets: not the public booking widget, and not the hospitality engine. book_appointments_no_overlap is still what actually prevents double-booking a generated date; a date that conflicts is skipped, not fatal to the rest of the series, matching the shipped feature this follows ("dates that don't fit... can be skipped"). One series_confirmed notification job is queued for the whole series, not one booking_confirmed per visit: each visit still gets its own reminder for free, since it is an ordinary row the existing reminder cron already reaches, and can still be individually moved or cancelled through this same PATCH/route below exactly like any other booking.
Request body application/json
The client is given one of two ways, matching the picker on the dashboard: either customerId for someone already on the roster, or customerName and customerPhone (plus optionally an email) to find-or-create one. When customerId is present the typed fields are ignored entirely.
customerPhone is required on the typed branch and only there. The check lives in resolveOrCreateCustomer, the single writer that creates a client from typed details, so it applies identically here, on POST /api/reservations and on POST /api/clients. Picking an existing client short-circuits to their id and never reaches it, which is what keeps a regular who predates the rule bookable.
| Field | Type | Notes |
|---|---|---|
| serviceId* | string (uuid) | |
| providerId* | string (uuid) | |
| startsAt* | string (date-time) | Anything Date.parse accepts. Stored as ISO 8601 UTC. Ignored when |
| notes | string | null | (max length 1000) The guest's request for this visit. |
| repeats | object | null | Present to create a series instead of one booking. Exactly one of |
| repeats.intervalWeeks | integer | (min 1, max 4) 1 = weekly, 2 = fortnightly, up to every 4 weeks. |
| repeats.weekdays | integer[] | (min items 1) 0 = Sunday. A Monday-and-Friday plan is one series with two entries. |
| repeats.timeOfDay | string | "HH:MM", every visit's start time. |
| repeats.startsOn | string (date) | The first visit's date. Must not be in the past. |
| repeats.endsOn | string | null (date) | |
| repeats.occurrenceCount | integer | null | (min 1, max 52) |
| customerId | string (uuid) | An existing client in this org. A foreign or unknown id is a 400 ("Client not found"). |
| customerName | string | (min length 1, max length 120) Required when |
| customerEmail | string | null (email) | (max length 254) Lowercased. Matches an existing client on (company_id, email) and updates that record rather than creating a second one. Omit and a fresh, undeduplicated record is always created: there is no other dedup key. |
| customerPhone | string | (min length 1, max length 40) Required when |
Responses
Created. Two shapes, dispatched on whether repeats was sent. Without it: the bare {id} this route has always returned (this and POST /api/reservations are the only two routes that do). With it: a series summary of seriesId, createdCount (visits actually booked), requestedCount (dates the rule generated), and skippedDates (an array of YYYY-MM-DD strings for any date book_appointments_no_overlap refused).
| Field | Type |
|---|---|
| id | string (uuid) |
| seriesId | string (uuid) |
| createdCount | integer |
| requestedCount | integer |
| skippedDates | string (date)[] |
A validation message; "Service not found" or "Client not found" for an id outside this org (400, not 404; these arrive in the body, not the path); "That staff member no longer exists" from the composite foreign key; or, for a repeats request, a message from parseSeriesRule/generateSeriesDates (a malformed rule, or one that would generate more than 52 visits).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Without repeats: book_appointments_no_overlap fired; that staff member already has a booking overlapping this window. Note the asymmetry with reservations: only cancelled releases an appointment slot, so no_show and completed still block it. With repeats: every generated date conflicted, so nothing was created and the series definition itself was not kept.
Raw OpenAPI operation
{
"summary": "Create an appointment, or a repeat series (staff side)",
"description": "A booking taken by staff; a phone call, a walk-in. Deliberately NOT slot-validated the way guest checkout is: staff have the calendar in front of them, and `book_appointments_no_overlap` (a Postgres exclusion constraint) is the real backstop either way.\n\n`price`, `duration_minutes` and `ends_at` are all snapshotted from the chosen service; sending them is not possible and would not be honoured. Status is always `confirmed`.\n\n**Repeats (migration 0149).** An optional `repeats` object turns this into a series: every so-many weeks, on one or more weekdays, ending after a number of visits or on a date. When present it REPLACES `startsAt` entirely (`repeats.startsOn` + `repeats.timeOfDay` already say when the first visit is) and the response shape changes; see the 200 below. Deliberately the only creation surface a repeat series gets: not the public booking widget, and not the hospitality engine. `book_appointments_no_overlap` is still what actually prevents double-booking a generated date; a date that conflicts is skipped, not fatal to the rest of the series, matching the shipped feature this follows (\"dates that don't fit... can be skipped\"). One `series_confirmed` notification job is queued for the whole series, not one `booking_confirmed` per visit: each visit still gets its own reminder for free, since it is an ordinary row the existing reminder cron already reaches, and can still be individually moved or cancelled through this same PATCH/route below exactly like any other booking.",
"tags": [
"Appointments engine"
],
"operationId": "createAppointment",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"description": "The client is given one of two ways, matching the picker on the dashboard: **either** `customerId` for someone already on the roster, **or** `customerName` and `customerPhone` (plus optionally an email) to find-or-create one. When `customerId` is present the typed fields are ignored entirely.\n\n`customerPhone` is required on the typed branch and only there. The check lives in `resolveOrCreateCustomer`, the single writer that creates a client from typed details, so it applies identically here, on `POST /api/reservations` and on `POST /api/clients`. Picking an existing client short-circuits to their id and never reaches it, which is what keeps a regular who predates the rule bookable.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"serviceId",
"providerId",
"startsAt"
],
"properties": {
"serviceId": {
"type": "string",
"format": "uuid"
},
"providerId": {
"type": "string",
"format": "uuid"
},
"startsAt": {
"type": "string",
"format": "date-time",
"description": "Anything Date.parse accepts. Stored as ISO 8601 UTC. Ignored when `repeats` is present."
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000,
"description": "The guest's request for this visit."
},
"repeats": {
"type": [
"object",
"null"
],
"description": "Present to create a series instead of one booking. Exactly one of `endsOn`/`occurrenceCount` must be set, never both or neither.",
"properties": {
"intervalWeeks": {
"type": "integer",
"minimum": 1,
"maximum": 4,
"description": "1 = weekly, 2 = fortnightly, up to every 4 weeks."
},
"weekdays": {
"type": "array",
"items": {
"type": "integer",
"minimum": 0,
"maximum": 6
},
"minItems": 1,
"description": "0 = Sunday. A Monday-and-Friday plan is one series with two entries."
},
"timeOfDay": {
"type": "string",
"description": "\"HH:MM\", every visit's start time."
},
"startsOn": {
"type": "string",
"format": "date",
"description": "The first visit's date. Must not be in the past."
},
"endsOn": {
"type": [
"string",
"null"
],
"format": "date"
},
"occurrenceCount": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 52
}
}
},
"customerId": {
"type": "string",
"format": "uuid",
"description": "An existing client in this org. A foreign or unknown id is a 400 (\"Client not found\")."
},
"customerName": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"description": "Required when `customerId` is absent."
},
"customerEmail": {
"type": [
"string",
"null"
],
"format": "email",
"maxLength": 254,
"description": "Lowercased. Matches an existing client on (company_id, email) and updates that record rather than creating a second one. Omit and a fresh, undeduplicated record is always created: there is no other dedup key."
},
"customerPhone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Required when `customerId` is absent; see the note above. A blank or missing number is a 400 (\"A contact number is required\") from `resolveOrCreateCustomer`, after the other field validation."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created. Two shapes, dispatched on whether `repeats` was sent. Without it: the bare `{id}` this route has always returned (this and POST /api/reservations are the only two routes that do). With it: a series summary of `seriesId`, `createdCount` (visits actually booked), `requestedCount` (dates the rule generated), and `skippedDates` (an array of `YYYY-MM-DD` strings for any date `book_appointments_no_overlap` refused).",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"seriesId": {
"type": "string",
"format": "uuid"
},
"createdCount": {
"type": "integer"
},
"requestedCount": {
"type": "integer"
},
"skippedDates": {
"type": "array",
"items": {
"type": "string",
"format": "date"
}
}
}
}
}
}
},
"400": {
"description": "A validation message; \"Service not found\" or \"Client not found\" for an id outside this org (400, not 404; these arrive in the body, not the path); \"That staff member no longer exists\" from the composite foreign key; or, for a `repeats` request, a message from `parseSeriesRule`/`generateSeriesDates` (a malformed rule, or one that would generate more than 52 visits).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Without `repeats`: `book_appointments_no_overlap` fired; that staff member already has a booking overlapping this window. Note the asymmetry with reservations: only `cancelled` releases an appointment slot, so `no_show` and `completed` still block it. With `repeats`: every generated date conflicted, so nothing was created and the series definition itself was not kept.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/appointments/{id}/seriesWhether this booking is part of a repeat series
/api/appointments/{id}/seriesOne id in, one (possibly null) id out. Its own tiny route rather than folding seriesId into GET /api/appointments/{id}'s already-large select: series_id (migration 0149) is a brand-new column, and the same "isolated, tolerated read" a brand-new column always gets here (see loadCardOnFileForBooking in lib/billing/checkout.ts for the precedent): folding it into a shared select would 42703 the WHOLE row for every booking on screen the moment this column isn't confirmed live yet, not just this one field. What BookingDetailDialog fetches once on open to decide whether to offer "End series" at all.
Parameters
id*pathstringAppointment id.
Responses
Series membership.
| Field | Type |
|---|---|
| seriesId* | string | null (uuid) |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Whether this booking is part of a repeat series",
"description": "One id in, one (possibly null) id out. Its own tiny route rather than folding `seriesId` into GET /api/appointments/{id}'s already-large select: `series_id` (migration 0149) is a brand-new column, and the same \"isolated, tolerated read\" a brand-new column always gets here (see loadCardOnFileForBooking in lib/billing/checkout.ts for the precedent): folding it into a shared select would 42703 the WHOLE row for every booking on screen the moment this column isn't confirmed live yet, not just this one field. What BookingDetailDialog fetches once on open to decide whether to offer \"End series\" at all.",
"tags": [
"Appointments engine"
],
"operationId": "getAppointmentSeriesMembership",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Appointment id."
}
],
"responses": {
"200": {
"description": "Series membership.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"seriesId"
],
"properties": {
"seriesId": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/booking-series/{id}Read one repeat series and its visits
/api/booking-series/{id}The series row plus every book_appointments row it generated, in date order, each with its OWN current status, not just the ones still upcoming. What the "End series" confirm step (BookingDetailDialog) reads to say how many visits are actually about to be cancelled, versus already past or already cancelled individually; that distinction needs the full list, not a second round trip filtered ahead of time.
Parameters
id*pathstringbook_booking_series id.
Responses
The series and its visits.
| Field | Type |
|---|---|
| series* | object |
| series.id | string (uuid) |
| series.status | "active" | "ended" | "cancelled" |
| visits* | object[] |
| visits[].id | string (uuid) |
| visits[].starts_at | string (date-time) |
| visits[].status | "pending" | "confirmed" | "cancelled" | "completed" | "no_show" |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No series with this id for this company.
Raw OpenAPI operation
{
"summary": "Read one repeat series and its visits",
"description": "The series row plus every book_appointments row it generated, in date order, each with its OWN current status, not just the ones still upcoming. What the \"End series\" confirm step (BookingDetailDialog) reads to say how many visits are actually about to be cancelled, versus already past or already cancelled individually; that distinction needs the full list, not a second round trip filtered ahead of time.",
"tags": [
"Appointments engine"
],
"operationId": "getBookingSeries",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "book_booking_series id."
}
],
"responses": {
"200": {
"description": "The series and its visits.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"series",
"visits"
],
"properties": {
"series": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"status": {
"type": "string",
"enum": [
"active",
"ended",
"cancelled"
]
}
}
},
"visits": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"starts_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string",
"enum": [
"pending",
"confirmed",
"cancelled",
"completed",
"no_show"
]
}
}
}
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No series with this id for this company.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/booking-series/{id}End a series early
/api/booking-series/{id}"End a series early from any of its visits: the rest cancel in one go and the customer hears once" (the shipped spec this follows). Only {"status":"cancelled"} is accepted: there is no other transition a series makes.
Cancels every visit in the series that is both in the future AND not already cancelled/completed; a visit that already happened, or was cancelled individually by the guest or staff before this ran, is left exactly as it was rather than double-touched. book_booking_series.status flips to cancelled in the same action, which is what refuses a second call on an already-ended series with 409. One series_cancelled notification job is queued for the whole batch, not one per visit.
Parameters
id*pathstringbook_booking_series id.
Request body application/json
| Field | Type |
|---|---|
| status* | "cancelled" |
Responses
Ended.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| cancelledCount* | integer | How many visits this call actually cancelled. |
status was anything other than "cancelled".
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No series with this id for this company.
This series was already cancelled.
Raw OpenAPI operation
{
"summary": "End a series early",
"description": "\"End a series early from any of its visits: the rest cancel in one go and the customer hears once\" (the shipped spec this follows). Only `{\"status\":\"cancelled\"}` is accepted: there is no other transition a series makes.\n\nCancels every visit in the series that is both in the future AND not already `cancelled`/`completed`; a visit that already happened, or was cancelled individually by the guest or staff before this ran, is left exactly as it was rather than double-touched. `book_booking_series.status` flips to `cancelled` in the same action, which is what refuses a second call on an already-ended series with 409. One `series_cancelled` notification job is queued for the whole batch, not one per visit.",
"tags": [
"Appointments engine"
],
"operationId": "endBookingSeries",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "book_booking_series id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"status"
],
"properties": {
"status": {
"const": "cancelled"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Ended.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"cancelledCount"
],
"properties": {
"ok": {
"const": true
},
"cancelledCount": {
"type": "integer",
"description": "How many visits this call actually cancelled."
}
}
}
}
}
},
"400": {
"description": "`status` was anything other than `\"cancelled\"`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No series with this id for this company.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "This series was already cancelled.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/appointments/{id}Read one booking
/api/appointments/{id}The single-booking twin of the row builder behind the Bookings list: same embeds, same staff-scope confinement, same redaction. Exists so Calendar can pop the exact same BookingDetailDialog in place on a click, instead of navigating to /dashboard/main/bookings?open=id and stranding the viewer on a different page once they close it.
Staff-scope and redaction both follow docs/staff-privacy.md: a scoped staff member reaching a colleague's booking gets a 403, not a 404, since reads are filtered rather than hidden; and a staff caller's view of the customer is redacted per the org's staffClientVisibility setting, using this booking's own providerConfirmed as the anonymized-mode grant.
Parameters
id*pathstringAppointment id.
Responses
The booking, with an unread-message count and its service, staff and customer fields resolved.
| Field | Type | Notes |
|---|---|---|
| row* | object | |
| row.id | string (uuid) | |
| row.startsAt | string (date-time) | |
| row.endsAt | string (date-time) | |
| row.status | "pending" | "confirmed" | "cancelled" | "completed" | "no_show" | |
| row.price | number | null | |
| row.notes | string | null | |
| row.serviceId | string (uuid) | |
| row.providerId | string | null (uuid) | Nullable as of migration 0051: a permanently deleted staff member leaves this null and keeps |
| row.durationMinutes | integer | |
| row.depositStatus | "none" | "pending" | "paid" | "refunded" | "forfeited" | |
| row.unreadCount | integer | Unread guest messages on this booking's thread, the same definition the Inbox and the bookings list use. |
| row.serviceName | string | "(deleted service)" when the service no longer exists. |
| row.itemNames | string[] | A multi-service visit's (migration 0054) line items, in position order. Empty for a single-service booking. |
| row.serviceColor | string | null | |
| row.providerName | string | "(no longer on staff)" when the provider row has been deleted. |
| row.providerConfirmed | boolean | False on an "Available specialist" booking the system auto-assigned but nobody has confirmed yet (migration 0080). |
| row.customerName | string | Redacted for a scoped staff member per the org's staffClientVisibility, same as the bookings list. "(unknown)" when no customer is linked. |
| row.customerEmail | string | null | |
| row.customerPhone | string | null |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No booking with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Read one booking",
"description": "The single-booking twin of the row builder behind the Bookings list: same embeds, same staff-scope confinement, same redaction. Exists so Calendar can pop the exact same BookingDetailDialog in place on a click, instead of navigating to /dashboard/main/bookings?open=id and stranding the viewer on a different page once they close it.\n\nStaff-scope and redaction both follow docs/staff-privacy.md: a scoped staff member reaching a colleague's booking gets a 403, not a 404, since reads are filtered rather than hidden; and a staff caller's view of the customer is redacted per the org's staffClientVisibility setting, using this booking's own providerConfirmed as the anonymized-mode grant.",
"tags": [
"Appointments engine"
],
"operationId": "getAppointment",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Appointment id."
}
],
"responses": {
"200": {
"description": "The booking, with an unread-message count and its service, staff and customer fields resolved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"row"
],
"properties": {
"row": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"endsAt": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string",
"enum": [
"pending",
"confirmed",
"cancelled",
"completed",
"no_show"
]
},
"price": {
"type": [
"number",
"null"
]
},
"notes": {
"type": [
"string",
"null"
]
},
"serviceId": {
"type": "string",
"format": "uuid"
},
"providerId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Nullable as of migration 0051: a permanently deleted staff member leaves this null and keeps `providerName` instead."
},
"durationMinutes": {
"type": "integer"
},
"depositStatus": {
"type": "string",
"enum": [
"none",
"pending",
"paid",
"refunded",
"forfeited"
]
},
"unreadCount": {
"type": "integer",
"description": "Unread guest messages on this booking's thread, the same definition the Inbox and the bookings list use."
},
"serviceName": {
"type": "string",
"description": "\"(deleted service)\" when the service no longer exists."
},
"itemNames": {
"type": "array",
"items": {
"type": "string"
},
"description": "A multi-service visit's (migration 0054) line items, in position order. Empty for a single-service booking."
},
"serviceColor": {
"type": [
"string",
"null"
]
},
"providerName": {
"type": "string",
"description": "\"(no longer on staff)\" when the provider row has been deleted."
},
"providerConfirmed": {
"type": "boolean",
"description": "False on an \"Available specialist\" booking the system auto-assigned but nobody has confirmed yet (migration 0080)."
},
"customerName": {
"type": "string",
"description": "Redacted for a scoped staff member per the org's staffClientVisibility, same as the bookings list. \"(unknown)\" when no customer is linked."
},
"customerEmail": {
"type": [
"string",
"null"
]
},
"customerPhone": {
"type": [
"string",
"null"
]
}
}
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No booking with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/appointments/{id}Update an appointment
/api/appointments/{id}A true partial update: only keys actually present in the body are written, so the drawer's {status}-only call cannot blank a field it never mentioned.
Derived fields, none of which the caller may set: sending serviceId re-snapshots price and duration_minutes from the service actually chosen; ends_at is always recomputed from starts_at + duration. phone is a shortcut to the linked client's record and updates it everywhere that client appears.
Parameters
id*pathstringAppointment id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| status | "pending" | "confirmed" | "cancelled" | "completed" | "no_show" | Transitioning INTO |
| serviceId | string (uuid) | Refused with a 409 on a multi-service visit (0054): the row's price/duration are TOTALS and the breakdown lives in book_appointment_services, so a single-service swap would orphan the itemization. Time, status, staff and notes edits all still work on such rows. |
| providerId | string (uuid) | Cannot be empty; an appointment with no provider is not a legal state. |
| startsAt | string (date-time) | |
| notes | string | null | (max length 1000) |
| phone | string | null | (max length 40) Writes to book_customers, not to this booking. May be corrected but not removed: an explicit null is a 400 when the guest already has a number, and a no-op when they do not, the same rule |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
Invalid status, invalid date, over-length notes, an unknown service, or "Nothing to change" when the body contained no recognised key.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No appointment with that id in this org, or a malformed uuid.
The new time collides with another booking for that staff member, or serviceId was sent for a multi-service visit (see its property note).
Raw OpenAPI operation
{
"summary": "Update an appointment",
"description": "A true partial update: only keys actually present in the body are written, so the drawer's `{status}`-only call cannot blank a field it never mentioned.\n\nDerived fields, none of which the caller may set: sending `serviceId` re-snapshots `price` and `duration_minutes` from the service actually chosen; `ends_at` is always recomputed from `starts_at` + duration. `phone` is a shortcut to the linked client's record and updates it everywhere that client appears.",
"tags": [
"Appointments engine"
],
"operationId": "updateAppointment",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Appointment id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"enum": [
"pending",
"confirmed",
"cancelled",
"completed",
"no_show"
],
"description": "Transitioning INTO `cancelled` sends the cancellation notification once; re-cancelling an already-cancelled booking does not."
},
"serviceId": {
"type": "string",
"format": "uuid",
"description": "Refused with a 409 on a multi-service visit (0054): the row's price/duration are TOTALS and the breakdown lives in book_appointment_services, so a single-service swap would orphan the itemization. Time, status, staff and notes edits all still work on such rows."
},
"providerId": {
"type": "string",
"format": "uuid",
"description": "Cannot be empty; an appointment with no provider is not a legal state."
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000
},
"phone": {
"type": [
"string",
"null"
],
"maxLength": 40,
"description": "Writes to book_customers, not to this booking. May be corrected but not removed: an explicit null is a 400 when the guest already has a number, and a no-op when they do not, the same rule `PATCH /api/reservations/{id}` and `PATCH /api/clients/{id}` apply to the same column."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "Invalid status, invalid date, over-length notes, an unknown service, or \"Nothing to change\" when the body contained no recognised key.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No appointment with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The new time collides with another booking for that staff member, or `serviceId` was sent for a multi-service visit (see its property note).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/v1/appointmentsList appointments (API key)
/api/v1/appointmentsThe appointments engine's bookings, for a program.
Ordered newest first, which is what a human wants and the wrong order for "what is on today": that is what from and to are for, and they are the reason this is one of only two v1 lists with filters. The configuration lists (services, staff, tables, service periods) are bounded by how many a business has, so one page is the whole list; bookings are unbounded and time-shaped.
The engine boundary holds here exactly as it does on the session routes: the gate takes the same BusinessType argument requireMember() takes, so a hospitality organization's key is refused with wrong_vertical. A key must not be a way around a boundary the session routes hold. It is enforced twice over, in fact: appointments:* scopes cannot even be granted to a hospitality org at issuance.
Tenancy comes from the key row's stored company_id and nothing else. There is no Supabase JWT behind an API key, so RLS has no claim to read and is not a second line of defence: the explicit company_id filter in the handler is the entire tenant boundary. A static check asserts that filter exists on every key-authenticated route.
Two clocks in one object. startsAt and endsAt are the venue's wall clock wearing a +00 suffix; createdAt is a true UTC instant. They serialise identically and there is no way to tell them apart from the payload, so a caller that parses the whole object one way is wrong about one of them by the venue's entire UTC offset. from and to filter on the same wall clock startsAt does. Resolve the venue's zone from timezone on GET /api/v1/organization, which needs the separate organization:read scope. See the timestamps section of docs/api-keys.md.
Ordered newest first.
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.fromquerystring (date-time)optionalInclusive lower bound on
startsAt. Any timestamp Postgres accepts; a value it rejects is a 400. Combine withtofor a day or a week.Compared against `startsAt`, so it is in the venue's wall clock, not UTC, whatever offset you write on it.
from=2026-08-20T00:00:00Zmeans midnight at the venue, not midnight UTC, and theZis not honoured. To ask for a venue's day, write that day's local midnight and ignore the offset. Asking in real UTC instead returns a window shifted by the venue's offset, which for an Australian venue is most of a different day.toquerystring (date-time)optionalEXCLUSIVE upper bound on
startsAt. Exclusive so thatfrom=2026-07-26T00:00:00&to=2026-07-27T00:00:00is exactly one day with no double-counting at the seam. That day is the venue's, not UTC: likefrom, this is compared against the venue's wall clock and any offset you write is ignored.
Responses
The appointments, newest first.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].startsAt | string (date-time) | The venue's local wall clock, not a UTC instant, despite the `+00` suffix. A 19:00 booking at a Sydney venue is returned as |
| data[].endsAt | string (date-time) | The venue's local wall clock, not a UTC instant, despite the `+00` suffix. A 19:00 booking at a Sydney venue is returned as |
| data[].status | "pending" | "confirmed" | "cancelled" | "completed" | "no_show" | |
| data[].durationMinutes | integer | |
| data[].price | number | null | |
| data[].notes | string | null | The customer's request for this booking, as they typed it. |
| data[].serviceId | string (uuid) | |
| data[].providerId | string | null (uuid) | |
| data[].customerId | string (uuid) | |
| data[].depositStatus | "none" | "pending" | "paid" | "refunded" | "forfeited" | |
| data[].depositAmountCents | integer | null | |
| data[].createdAt | string (date-time) | A true UTC instant (the row's |
| nextCursor* | string | null | Pass to |
Two causes, distinguishable by whether code is present. `invalid_cursor`: the cursor was not one this API issued. A malformed `from` or `to`: Postgres rejected the timestamp, and the body is its message with no `code`, since it did not come from the gate. Both are the caller's input, which is why neither is a 500.
| Field | Type |
|---|---|
| error* | string |
| code | "invalid_cursor" |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List appointments (API key)",
"description": "The appointments engine's bookings, for a program.\n\nOrdered newest first, which is what a human wants and the wrong order for \"what is on today\": that is what `from` and `to` are for, and they are the reason this is one of only two v1 lists with filters. The configuration lists (services, staff, tables, service periods) are bounded by how many a business has, so one page is the whole list; bookings are unbounded and time-shaped.\n\nThe engine boundary holds here exactly as it does on the session routes: the gate takes the same `BusinessType` argument `requireMember()` takes, so a hospitality organization's key is refused with `wrong_vertical`. A key must not be a way around a boundary the session routes hold. It is enforced twice over, in fact: `appointments:*` scopes cannot even be *granted* to a hospitality org at issuance.\n\nTenancy comes from the key row's stored `company_id` and nothing else. There is no Supabase JWT behind an API key, so RLS has no claim to read and is **not** a second line of defence: the explicit `company_id` filter in the handler is the entire tenant boundary. A static check asserts that filter exists on every key-authenticated route.\n\n**Two clocks in one object.** `startsAt` and `endsAt` are the venue's wall clock wearing a `+00` suffix; `createdAt` is a true UTC instant. They serialise identically and there is no way to tell them apart from the payload, so a caller that parses the whole object one way is wrong about one of them by the venue's entire UTC offset. `from` and `to` filter on the same wall clock `startsAt` does. Resolve the venue's zone from `timezone` on `GET /api/v1/organization`, which needs the separate `organization:read` scope. See the timestamps section of `docs/api-keys.md`.\n\nOrdered newest first.",
"tags": [
"Appointments engine"
],
"operationId": "listAppointmentsV1",
"security": [
{
"bearerApiKey": [
"appointments:read"
]
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
},
{
"name": "from",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "Inclusive lower bound on `startsAt`. Any timestamp Postgres accepts; a value it rejects is a 400. Combine with `to` for a day or a week.\n\n**Compared against `startsAt`, so it is in the venue's wall clock, not UTC**, whatever offset you write on it. `from=2026-08-20T00:00:00Z` means midnight at the venue, not midnight UTC, and the `Z` is not honoured. To ask for a venue's day, write that day's local midnight and ignore the offset. Asking in real UTC instead returns a window shifted by the venue's offset, which for an Australian venue is most of a different day."
},
{
"name": "to",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "EXCLUSIVE upper bound on `startsAt`. Exclusive so that `from=2026-07-26T00:00:00&to=2026-07-27T00:00:00` is exactly one day with no double-counting at the seam. **That day is the venue's, not UTC**: like `from`, this is compared against the venue's wall clock and any offset you write is ignored."
}
],
"responses": {
"200": {
"description": "The appointments, newest first.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase. There is no embedded service, provider or customer object; you get their ids, and `GET /api/v1/services` and `GET /api/v1/providers` resolve them. Three columns are withheld: `manage_token` (the entire credential for the guest self-service link, handing it to a program hands over the ability to act as the guest), `internal_note` (the venue's private note, excluded for the same reason `book_customers.notes` is), and `stripe_payment_intent_id` (an identifier in somebody else's system).",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"startsAt": {
"type": "string",
"format": "date-time",
"description": "**The venue's local wall clock, not a UTC instant, despite the `+00` suffix.** A 19:00 booking at a Sydney venue is returned as `2026-08-20T19:00:00+00`, not as the `09:00Z` that instant really is. Read the digits as local time and ignore the offset, or convert properly using `timezone` from `GET /api/v1/organization`. Do not hand it to a UTC-aware parser and render the result in the reader's zone: that shifts every booking by the venue's whole offset, ten or eleven hours for an Australian venue. `createdAt` in this same object IS a true instant, so the two cannot be treated alike. See the timestamps section of `docs/api-keys.md`."
},
"endsAt": {
"type": "string",
"format": "date-time",
"description": "**The venue's local wall clock, not a UTC instant, despite the `+00` suffix.** A 19:00 booking at a Sydney venue is returned as `2026-08-20T19:00:00+00`, not as the `09:00Z` that instant really is. Read the digits as local time and ignore the offset, or convert properly using `timezone` from `GET /api/v1/organization`. Do not hand it to a UTC-aware parser and render the result in the reader's zone: that shifts every booking by the venue's whole offset, ten or eleven hours for an Australian venue. `createdAt` in this same object IS a true instant, so the two cannot be treated alike. See the timestamps section of `docs/api-keys.md`."
},
"status": {
"type": "string",
"enum": [
"pending",
"confirmed",
"cancelled",
"completed",
"no_show"
]
},
"durationMinutes": {
"type": "integer"
},
"price": {
"type": [
"number",
"null"
]
},
"notes": {
"type": [
"string",
"null"
],
"description": "The customer's request for this booking, as they typed it."
},
"serviceId": {
"type": "string",
"format": "uuid"
},
"providerId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"customerId": {
"type": "string",
"format": "uuid"
},
"depositStatus": {
"type": "string",
"enum": [
"none",
"pending",
"paid",
"refunded",
"forfeited"
]
},
"depositAmountCents": {
"type": [
"integer",
"null"
]
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "A **true UTC instant** (the row's `now()` default), unlike `startsAt`/`endsAt` above. Parse and render this one normally."
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "Two causes, distinguishable by whether `code` is present. **`invalid_cursor`**: the `cursor` was not one this API issued. **A malformed `from` or `to`**: Postgres rejected the timestamp, and the body is its message with **no `code`**, since it did not come from the gate. Both are the caller's input, which is why neither is a 500.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"const": "invalid_cursor"
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}get/api/v1/servicesList services (API key)
/api/v1/servicesThe appointments engine's bookable services. Engine-guarded: a hospitality organization's key is refused with wrong_vertical even if it holds `services:read` and even if the org has `book_services` rows, the boundary is about what the organization IS, not about what happens to be in its tables.
Ordered by `id`, which is stable but arbitrary. book_services has no created_at (none of the four configuration tables do), and keyset paging needs a column that is both unique and stable, so id is the only candidate. Not worth a migration: a business sells tens of services, so the first page of 200 is the whole list in practice, and a caller wanting them alphabetical can sort them itself. Documented rather than hidden.
active: false rows ARE returned. A program reconciling against its own copy needs to see a service that was switched off, not have it silently disappear; the public booking widget is where active is filtered.
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.
Responses
The services.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].name | string | |
| data[].description | string | null | |
| data[].durationMinutes | integer | |
| data[].price | number | null | Null means "no price shown". |
| data[].color | string | null | One of SERVICE_COLORS: the swatch the dashboard and the widget draw this service in. |
| data[].active | boolean | |
| data[].imageUrl | string | null | |
| nextCursor* | string | null | Pass to |
invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "invalid_cursor" | |
| required_scope | string | Present only on |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List services (API key)",
"description": "The appointments engine's bookable services. Engine-guarded: a hospitality organization's key is refused with `wrong_vertical` **even if it holds `services:read` and even if the org has `book_services` rows**, the boundary is about what the organization IS, not about what happens to be in its tables.\n\n**Ordered by `id`, which is stable but arbitrary.** `book_services` has no `created_at` (none of the four configuration tables do), and keyset paging needs a column that is both unique and stable, so `id` is the only candidate. Not worth a migration: a business sells tens of services, so the first page of 200 is the whole list in practice, and a caller wanting them alphabetical can sort them itself. Documented rather than hidden.\n\n`active: false` rows ARE returned. A program reconciling against its own copy needs to see a service that was switched off, not have it silently disappear; the public booking widget is where `active` is filtered.",
"tags": [
"Appointments engine"
],
"operationId": "listServicesV1",
"security": [
{
"bearerApiKey": [
"services:read"
]
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
}
],
"responses": {
"200": {
"description": "The services.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase.",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"description": {
"type": [
"string",
"null"
]
},
"durationMinutes": {
"type": "integer"
},
"price": {
"type": [
"number",
"null"
],
"description": "Null means \"no price shown\"."
},
"color": {
"type": [
"string",
"null"
],
"description": "One of SERVICE_COLORS: the swatch the dashboard and the widget draw this service in."
},
"active": {
"type": "boolean"
},
"imageUrl": {
"type": [
"string",
"null"
]
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "`invalid_cursor`: the `cursor` was not one this API issued. Pass back `nextCursor` unchanged; do not construct one.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"invalid_cursor"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}get/api/v1/providersList staff (API key)
/api/v1/providersThe appointments engine's staff members. Engine-guarded, ordered by id: see GET /api/v1/services for why.
`serviceIds` is the one place a v1 list does more than select a row, and it earns the exception twice over: which services a staff member can perform is the fact a program needs before it can offer anyone a slot, and the field is not invented here. The public booking config already exposes serviceIds per provider, and POST /api/providers already accepts it. Omitting it would be the inconsistency.
It comes from a second query against the link table, filtered by the key's own company_id rather than trusting the ids from the first. The link table carries its own company_id for exactly that reason (migration 0013). Two queries rather than a PostgREST embed, so the link table's name never becomes part of this contract.
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.
Responses
The staff members.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].name | string | |
| data[].email | string | null (email) | |
| data[].bio | string | null | |
| data[].title | string | null | The public team page's role line, e.g. "Owner & Colorist". |
| data[].active | boolean | |
| data[].avatarUrl | string | null | |
| data[].serviceIds | string (uuid)[] | The services this staff member can perform. Empty array, never absent. |
| nextCursor* | string | null | Pass to |
invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "invalid_cursor" | |
| required_scope | string | Present only on |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List staff (API key)",
"description": "The appointments engine's staff members. Engine-guarded, ordered by `id`: see `GET /api/v1/services` for why.\n\n**`serviceIds` is the one place a v1 list does more than select a row**, and it earns the exception twice over: which services a staff member can perform is the fact a program needs before it can offer anyone a slot, and the field is not invented here. The public booking config already exposes `serviceIds` per provider, and `POST /api/providers` already *accepts* it. Omitting it would be the inconsistency.\n\nIt comes from a second query against the link table, filtered by the key's own `company_id` rather than trusting the ids from the first. The link table carries its own `company_id` for exactly that reason (migration 0013). Two queries rather than a PostgREST embed, so the link table's name never becomes part of this contract.",
"tags": [
"Appointments engine"
],
"operationId": "listProvidersV1",
"security": [
{
"bearerApiKey": [
"providers:read"
]
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
}
],
"responses": {
"200": {
"description": "The staff members.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase.",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"email": {
"type": [
"string",
"null"
],
"format": "email"
},
"bio": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
],
"description": "The public team page's role line, e.g. \"Owner & Colorist\"."
},
"active": {
"type": "boolean"
},
"avatarUrl": {
"type": [
"string",
"null"
]
},
"serviceIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"description": "The services this staff member can perform. Empty array, never absent."
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "`invalid_cursor`: the `cursor` was not one this API issued. Pass back `nextCursor` unchanged; do not construct one.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"invalid_cursor"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the appointments engine and the key belongs to a hospitality organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}post/api/productsCreate a product
/api/productsAdds a retail catalog item. company_id comes from the verified JWT claim, never the body. stockOnHand is the opening count, written directly ONLY on create: every change after this goes through POST /api/products/{id}/stock, which records a reason. Appointments only as of 2026-09-06: see migration 0200's own comment for why hospitality never had a real equivalent here at all, and /api/experiences for the screen it actually gets.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| sku | string | null | (max length 60) |
| price* | number | (min 0, max 100000) Dollars (or the venue currency's major unit). Coerced with Number(), converted to price_cents = round(price * 100). |
| categoryId | string | null (uuid) | The product category this sits in (migration 0176). Null or omitted means uncategorised. Tenancy is enforced by the composite FK, so another org's category id, or a deleted one, comes back as a 400 "Category not found". |
| lowStockAt | integer | null | (min 0, max 100000) The threshold the Products list flags as running low. Null/omitted means tracking stays quiet, distinct from 0, a real "tell me the moment this hits zero" threshold. |
| stockOnHand | integer | (min 0, default 0) The opening count. Defaults to 0 if omitted. |
Responses
Created.
| Field | Type |
|---|---|
| product* | object |
| product.id | string (uuid) |
A validation message from parseProductInput, "Stock on hand must be a whole number, 0 or more", "Category not found" for a categoryId that is not this org's (FK 23503), or the raw Postgres message if the insert itself failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
Raw OpenAPI operation
{
"summary": "Create a product",
"description": "Adds a retail catalog item. `company_id` comes from the verified JWT claim, never the body. `stockOnHand` is the opening count, written directly ONLY on create: every change after this goes through POST /api/products/{id}/stock, which records a reason. **Appointments only** as of 2026-09-06: see migration 0200's own comment for why hospitality never had a real equivalent here at all, and /api/experiences for the screen it actually gets.",
"tags": [
"Appointments engine"
],
"operationId": "createProduct",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"price"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"sku": {
"type": [
"string",
"null"
],
"maxLength": 60
},
"price": {
"type": "number",
"minimum": 0,
"maximum": 100000,
"description": "Dollars (or the venue currency's major unit). Coerced with Number(), converted to price_cents = round(price * 100)."
},
"categoryId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The product category this sits in (migration 0176). Null or omitted means uncategorised. Tenancy is enforced by the composite FK, so another org's category id, or a deleted one, comes back as a 400 \"Category not found\"."
},
"lowStockAt": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 100000,
"description": "The threshold the Products list flags as running low. Null/omitted means tracking stays quiet, distinct from 0, a real \"tell me the moment this hits zero\" threshold."
},
"stockOnHand": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "The opening count. Defaults to 0 if omitted."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"product"
],
"properties": {
"product": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseProductInput, \"Stock on hand must be a whole number, 0 or more\", \"Category not found\" for a categoryId that is not this org's (FK 23503), or the raw Postgres message if the insert itself failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/products/{id}Update a product, or archive/restore it
/api/products/{id}Two shapes in one route. A body of exactly {"archived": true|false} sets/clears archived_at and returns immediately, no other field required. Any other body is a full replacement of the catalog fields (parseProductInput's contract); stockOnHand is never accepted here, only through POST /api/products/{id}/stock. RLS scopes the update, so an id from another org matches zero rows and returns 404. Appointments only, see POST /api/products.
Parameters
id*pathstringProduct id.
Request body application/json
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseProductInput, or "Category not found" for a categoryId that is not this org's (FK 23503).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No product with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.
Raw OpenAPI operation
{
"summary": "Update a product, or archive/restore it",
"description": "Two shapes in one route. A body of exactly `{\"archived\": true|false}` sets/clears `archived_at` and returns immediately, no other field required. Any other body is a full replacement of the catalog fields (parseProductInput's contract); `stockOnHand` is never accepted here, only through POST /api/products/{id}/stock. RLS scopes the update, so an id from another org matches zero rows and returns 404. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "updateProduct",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Product id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"archived"
],
"additionalProperties": false,
"properties": {
"archived": {
"type": "boolean"
}
}
},
{
"type": "object",
"required": [
"name",
"price"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"sku": {
"type": [
"string",
"null"
],
"maxLength": 60
},
"price": {
"type": "number",
"minimum": 0,
"maximum": 100000,
"description": "Dollars (or the venue currency's major unit). Coerced with Number(), converted to price_cents = round(price * 100)."
},
"categoryId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The product category this sits in (migration 0176). Null or omitted means uncategorised. Tenancy is enforced by the composite FK, so another org's category id, or a deleted one, comes back as a 400 \"Category not found\"."
},
"lowStockAt": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 100000,
"description": "The threshold the Products list flags as running low. Null/omitted means tracking stays quiet, distinct from 0, a real \"tell me the moment this hits zero\" threshold."
}
}
}
]
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseProductInput, or \"Category not found\" for a categoryId that is not this org's (FK 23503).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No product with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/products/{id}/imageUpload a product image
/api/products/{id}/imageStores the file at {companyId}/{productId} in the product-images bucket and writes a cache-busted public URL onto the product row. Not gated on manage_services: a photo is part of editing the product, same posture as the equivalent service-image route. Ownership is checked BEFORE the upload, so a request naming a product the caller does not own is a 404 with nothing written. Appointments only, see POST /api/products.
Parameters
id*pathstringProduct id.
Request body multipart/form-data
The product image. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| imageUrl* | string (uri) | Public URL with a |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No product with that id in this org. Nothing was uploaded; the ownership check runs first.
Raw OpenAPI operation
{
"summary": "Upload a product image",
"description": "Stores the file at `{companyId}/{productId}` in the `product-images` bucket and writes a cache-busted public URL onto the product row. Not gated on `manage_services`: a photo is part of editing the product, same posture as the equivalent service-image route. Ownership is checked BEFORE the upload, so a request naming a product the caller does not own is a 404 with nothing written. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "uploadProductImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Product id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The product image. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"imageUrl"
],
"properties": {
"ok": {
"const": true
},
"imageUrl": {
"type": "string",
"format": "uri",
"description": "Public URL with a `?v=<timestamp>` cache-buster; the storage path itself never changes."
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No product with that id in this org. Nothing was uploaded; the ownership check runs first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/products/{id}/imageRemove a product image
/api/products/{id}/imageDeletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request. Appointments only, see POST /api/products.
Parameters
id*pathstringProduct id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
No product with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove a product image",
"description": "Deletes the stored object and nulls `image_url`. Storage removal is best-effort and its failure does not fail the request. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "deleteProductImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Product id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No product with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/products/{id}/stockA product's recent stock movements
/api/products/{id}/stockThe last 20 rows off the append-only book_product_stock_movements ledger, newest first: backs the Adjust stock dialog's own recent-movements list. RLS-scoped read; any signed-in member may read it, no manage_services gate. Appointments only, see POST /api/products.
Parameters
id*pathstringProduct id.
Responses
Up to 20 movements.
| Field | Type | Notes |
|---|---|---|
| movements* | object[] | (max items 20) |
| movements[].id | string (uuid) | |
| movements[].reason | "received" | "stocktake" | "damaged" | "adjustment" | "sale" | "sale_undo" | |
| movements[].delta | integer | Signed. Negative for a removal. |
| movements[].resulting_stock | integer | |
| movements[].note | string | null | |
| movements[].created_at | string (date-time) |
A Postgres error reading the rows.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "A product's recent stock movements",
"description": "The last 20 rows off the append-only book_product_stock_movements ledger, newest first: backs the Adjust stock dialog's own recent-movements list. RLS-scoped read; any signed-in member may read it, no `manage_services` gate. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "listProductStockMovements",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Product id."
}
],
"responses": {
"200": {
"description": "Up to 20 movements.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"movements"
],
"properties": {
"movements": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": "string",
"enum": [
"received",
"stocktake",
"damaged",
"adjustment",
"sale",
"sale_undo"
]
},
"delta": {
"type": "integer",
"description": "Signed. Negative for a removal."
},
"resulting_stock": {
"type": "integer"
},
"note": {
"type": [
"string",
"null"
]
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
}
}
},
"400": {
"description": "A Postgres error reading the rows.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/products/{id}/stockAdjust a product's stock
/api/products/{id}/stockThree reasons, two shapes. received and damaged send a signed delta (the route itself flips the sign for damaged client-side, BeNotely's own "how many you're removing" framing, never a typed minus). stocktake sends the counted count instead; book_product_stock_adjust() (migration 0176) computes the delta against the LIVE, row-locked current value, never a value the client read moments earlier. Every call writes a reasoned book_product_stock_movements row. Appointments only, see POST /api/products.
Parameters
id*pathstringProduct id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| reason* | "received" | "stocktake" | "damaged" | |
| delta | integer | Required (and must be non-zero) when reason is |
| count | integer | (min 0) Required (0 or more) when reason is |
| note | string | null | (max length 300) |
Responses
Adjusted.
| Field | Type | Notes |
|---|---|---|
| stockOnHand* | integer | The new count after this adjustment. |
Reason missing/unrecognised, delta/count missing or invalid for the chosen reason, or the RPC's own error message.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No product with that id in this org, or it is archived: book_product_stock_adjust() only matches a live row.
Raw OpenAPI operation
{
"summary": "Adjust a product's stock",
"description": "Three reasons, two shapes. `received` and `damaged` send a signed `delta` (the route itself flips the sign for `damaged` client-side, BeNotely's own \"how many you're removing\" framing, never a typed minus). `stocktake` sends the counted `count` instead; book_product_stock_adjust() (migration 0176) computes the delta against the LIVE, row-locked current value, never a value the client read moments earlier. Every call writes a reasoned book_product_stock_movements row. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "adjustProductStock",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Product id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"reason"
],
"properties": {
"reason": {
"type": "string",
"enum": [
"received",
"stocktake",
"damaged"
]
},
"delta": {
"type": "integer",
"description": "Required (and must be non-zero) when reason is `received` or `damaged`. Ignored for `stocktake`."
},
"count": {
"type": "integer",
"minimum": 0,
"description": "Required (0 or more) when reason is `stocktake`: the counted total on the shelf, not a delta."
},
"note": {
"type": [
"string",
"null"
],
"maxLength": 300
}
}
}
}
}
},
"responses": {
"200": {
"description": "Adjusted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"stockOnHand"
],
"properties": {
"stockOnHand": {
"type": "integer",
"description": "The new count after this adjustment."
}
}
}
}
}
},
"400": {
"description": "Reason missing/unrecognised, `delta`/`count` missing or invalid for the chosen reason, or the RPC's own error message.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No product with that id in this org, or it is archived: book_product_stock_adjust() only matches a live row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/products/categoriesCreate a product category
/api/products/categoriesA named shelf products hang off on the Products list ("Hair care", "Tools"), purely for finding things; it never changes a price (migration 0176). New categories land at the end of the shelf (sort_order = max+1). v1 is flat, name-only: no rename or reorder route exists yet. Appointments only, see POST /api/products.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 60) |
Responses
Created.
| Field | Type |
|---|---|
| category* | object |
| category.id | string (uuid) |
| category.name | string |
Name missing or over 60 characters, or the raw Postgres message if the insert itself failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
Raw OpenAPI operation
{
"summary": "Create a product category",
"description": "A named shelf products hang off on the Products list (\"Hair care\", \"Tools\"), purely for finding things; it never changes a price (migration 0176). New categories land at the end of the shelf (`sort_order = max+1`). v1 is flat, name-only: no rename or reorder route exists yet. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "createProductCategory",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 60
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"category"
],
"properties": {
"category": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
}
}
}
}
}
}
}
},
"400": {
"description": "Name missing or over 60 characters, or the raw Postgres message if the insert itself failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/products/categories/{id}Delete a product category
/api/products/categories/{id}Never blocks and never takes products with it: the composite FK's column-scoped on delete set null (category_id) (0176) un-groups them in the same statement, same shape book_service_groups' own DELETE takes. Appointments only, see POST /api/products.
Parameters
id*pathstringCategory id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error other than the not-found case below.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No category with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Delete a product category",
"description": "Never blocks and never takes products with it: the composite FK's column-scoped `on delete set null (category_id)` (0176) un-groups them in the same statement, same shape book_service_groups' own DELETE takes. **Appointments only**, see POST /api/products.",
"tags": [
"Appointments engine"
],
"operationId": "deleteProductCategory",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Category id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error other than the not-found case below.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No category with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/organization/hoursRead the venue opening hours (admin, or manage_org_settings)
/api/organization/hoursVenue-level opening hours (book_company_hours, migration 0027); "when is the business open", as distinct from a staff member's own working hours. computeAvailableSlots intersects the two, so a booking must fit inside both. Times come back as HH:MM, ordered by day then start time. Appointments only: a hospitality org uses book_service_periods, which already carries the lunch/dinner split plus turn time, last seating and covers caps. Gated to admin, or a staff login granted manage_org_settings (migration 0082).
Responses
The configured week. An empty array means no venue restriction.
| Field | Type | Notes |
|---|---|---|
| hours* | object[] | (max items 21) Up to 21 windows (3 a day for a week). Repeat a |
| hours[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| hours[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| hours[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
A Postgres error reading the rows.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_org_settings permission (migration 0082, Admin access required).
Raw OpenAPI operation
{
"summary": "Read the venue opening hours (admin, or manage_org_settings)",
"description": "Venue-level opening hours (`book_company_hours`, migration 0027); \"when is the business open\", as distinct from a staff member's own working hours. `computeAvailableSlots` intersects the two, so a booking must fit inside both. Times come back as HH:MM, ordered by day then start time. Appointments only: a hospitality org uses `book_service_periods`, which already carries the lunch/dinner split plus turn time, last seating and covers caps. Gated to admin, or a staff login granted `manage_org_settings` (migration 0082).",
"tags": [
"Appointments engine"
],
"operationId": "getOrganizationHours",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"responses": {
"200": {
"description": "The configured week. An empty array means no venue restriction.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"hours"
],
"properties": {
"hours": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
}
}
}
}
}
},
"400": {
"description": "A Postgres error reading the rows.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}put/api/organization/hoursReplace the venue opening hours (admin, or manage_org_settings)
/api/organization/hoursPUT, not PATCH: the editor holds the whole week and a window has no stable identity in it, so "these are the hours" is the only honest request shape. An empty array means NO VENUE RESTRICTION, not "closed all week": zero rows has to keep meaning unclamped, because that is the state every org is in until it saves hours here, and any other reading would take every existing booking page to zero availability. Delete-then-insert over PostgREST is two transactions; if the insert fails the org is left with no rows, which fails permissive (staff hours alone apply) and is reported as such. Gated to admin, or a staff login granted manage_org_settings (migration 0082).
Request body application/json
| Field | Type | Notes |
|---|---|---|
| hours* | object[] | (max items 21) Up to 21 windows (3 a day for a week). Repeat a |
| hours[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| hours[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| hours[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
Responses
Saved. Echoes the stored week back.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| hours* | object[] | (max items 21) Up to 21 windows (3 a day for a week). Repeat a |
| hours[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| hours[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| hours[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
A validation message from parseHoursWindows, or a Postgres error clearing the old rows.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_org_settings permission (migration 0082, Admin access required).
The insert failed after the clear; the opening hours are now unset and the message says so.
Raw OpenAPI operation
{
"summary": "Replace the venue opening hours (admin, or manage_org_settings)",
"description": "PUT, not PATCH: the editor holds the whole week and a window has no stable identity in it, so \"these are the hours\" is the only honest request shape. **An empty array means NO VENUE RESTRICTION, not \"closed all week\"**: zero rows has to keep meaning unclamped, because that is the state every org is in until it saves hours here, and any other reading would take every existing booking page to zero availability. Delete-then-insert over PostgREST is two transactions; if the insert fails the org is left with no rows, which fails permissive (staff hours alone apply) and is reported as such. Gated to admin, or a staff login granted `manage_org_settings` (migration 0082).",
"tags": [
"Appointments engine"
],
"operationId": "replaceOrganizationHours",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"hours"
],
"additionalProperties": false,
"properties": {
"hours": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved. Echoes the stored week back.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"hours"
],
"properties": {
"ok": {
"const": true
},
"hours": {
"type": "array",
"maxItems": 21,
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
}
}
},
"description": "Up to 21 windows (3 a day for a week). Repeat a `dayOfWeek` to give that day a second window; the gap between the two is not bookable. Windows are not checked for overlapping each other; an overlap costs nothing downstream."
}
}
}
}
}
},
"400": {
"description": "A validation message from parseHoursWindows, or a Postgres error clearing the old rows.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The insert failed after the clear; the opening hours are now unset and the message says so.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/site-pages/{type}Save a page's draft content (admin, or manage_org_settings)
/api/site-pages/{type}Writes draft_content ONLY; the live site (published_content) never moves until POST .../publish. book_site_pages (migration 0112), upserted rather than updated because a company that has never had this page edited before has no row yet. Gated the same way /api/organization/hours gates general org settings: admin, or a staff member holding manage_org_settings (0082). What a page SAYS is business-profile-shaped work, not a daily-operations action every staff login should get by default.
Parameters
type*path"home" | "services" | "about" | "team" | "gallery" | "reviews" | "contact"Which of the seven site pages (SITE_PAGE_TYPES). Anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| content* | object | |
| content.visible* | boolean | False hides the page: its route 404s and nav omits it. |
| content.heading* | string | null | (max length 120) Overrides the page's generated H1. About is the one exception: its heading is a standalone story headline instead; see SitePageContent's own comment. |
| content.intro* | string | null | (max length 600) A short paragraph under the heading. Null renders no intro at all. |
| content.body* | object[] | (max items 6) Long-form prose, one section per named subheading. At most 6 sections and 12 paragraphs combined across every section; each paragraph is 2000 characters or fewer. |
| content.body[].heading* | string | null | (max length 100) |
| content.body[].paragraphs* | string[] | |
| content.order* | number | null | This page's position among the six routable pages. Null falls back to ROUTABLE_SITE_PAGES' own declared order. |
| content.showOnHome* | boolean | Whether this page's teaser section appears on Home, independent of |
| content.textMotion* | string | null | A TEXT_MOTION_STYLES id (palette.ts) overriding the site-wide heading-motion preset for this page, or null for no override. |
| content.metaTitle* | string | null | (max length 60) Overrides the |
| content.metaDescription* | string | null | (max length 160) Overrides the meta description and OpenGraph/Twitter description. |
| content.noIndex* | boolean | True asks search engines not to index this page; it still renders and still books. |
Responses
Saved.
| Field | Type |
|---|---|
| ok* | true |
The body is missing or not an object, or a validateSitePageContent rejection (a field over its length cap, a malformed section, or a blocked term); see that function for the exact message.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster, and a staff member without the manage_org_settings permission (migration 0082, Admin access required).
type is not one of the seven SITE_PAGE_TYPES.
Raw OpenAPI operation
{
"summary": "Save a page's draft content (admin, or manage_org_settings)",
"description": "Writes `draft_content` ONLY; the live site (`published_content`) never moves until POST .../publish. `book_site_pages` (migration 0112), upserted rather than updated because a company that has never had this page edited before has no row yet. Gated the same way /api/organization/hours gates general org settings: admin, or a staff member holding `manage_org_settings` (0082). What a page SAYS is business-profile-shaped work, not a daily-operations action every staff login should get by default.",
"tags": [
"Appointments engine"
],
"operationId": "saveSitePageDraft",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "type",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"home",
"services",
"about",
"team",
"gallery",
"reviews",
"contact"
]
},
"description": "Which of the seven site pages (SITE_PAGE_TYPES). Anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"content"
],
"additionalProperties": false,
"properties": {
"content": {
"type": "object",
"required": [
"visible",
"heading",
"intro",
"body",
"order",
"showOnHome",
"textMotion",
"metaTitle",
"metaDescription",
"noIndex"
],
"additionalProperties": false,
"properties": {
"visible": {
"type": "boolean",
"description": "False hides the page: its route 404s and nav omits it."
},
"heading": {
"type": [
"string",
"null"
],
"maxLength": 120,
"description": "Overrides the page's generated H1. About is the one exception: its heading is a standalone story headline instead; see SitePageContent's own comment."
},
"intro": {
"type": [
"string",
"null"
],
"maxLength": 600,
"description": "A short paragraph under the heading. Null renders no intro at all."
},
"body": {
"type": [
"array",
"null"
],
"maxItems": 6,
"description": "Long-form prose, one section per named subheading. At most 6 sections and 12 paragraphs combined across every section; each paragraph is 2000 characters or fewer.",
"items": {
"type": "object",
"required": [
"heading",
"paragraphs"
],
"additionalProperties": false,
"properties": {
"heading": {
"type": [
"string",
"null"
],
"maxLength": 100
},
"paragraphs": {
"type": "array",
"items": {
"type": "string",
"maxLength": 2000
}
}
}
}
},
"order": {
"type": [
"number",
"null"
],
"description": "This page's position among the six routable pages. Null falls back to ROUTABLE_SITE_PAGES' own declared order."
},
"showOnHome": {
"type": "boolean",
"description": "Whether this page's teaser section appears on Home, independent of `visible`."
},
"textMotion": {
"type": [
"string",
"null"
],
"description": "A TEXT_MOTION_STYLES id (palette.ts) overriding the site-wide heading-motion preset for this page, or null for no override."
},
"metaTitle": {
"type": [
"string",
"null"
],
"maxLength": 60,
"description": "Overrides the `<title>` tag and OpenGraph/Twitter title."
},
"metaDescription": {
"type": [
"string",
"null"
],
"maxLength": 160,
"description": "Overrides the meta description and OpenGraph/Twitter description."
},
"noIndex": {
"type": "boolean",
"description": "True asks search engines not to index this page; it still renders and still books."
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "The body is missing or not an object, or a validateSitePageContent rejection (a field over its length cap, a malformed section, or a blocked term); see that function for the exact message.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "`type` is not one of the seven SITE_PAGE_TYPES.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/site-pages/{type}/publishPublish a page's draft content live (admin)
/api/site-pages/{type}/publishCopies draft_content over published_content and stamps published_at. Unconditionally admin-only, no manage_org_settings escape hatch (unlike the save route above): migration 0112 grants UPDATE on draft_content/updated_at only, deliberately never on published_content, so this is the ONLY place "publish" is actually enforced. A company that never touched this page's draft gets a clean 200 no-op: the live page already renders its generated default and there is nothing to copy forward.
Parameters
type*path"home" | "services" | "about" | "team" | "gallery" | "reviews" | "contact"Which of the seven site pages (SITE_PAGE_TYPES). Anything else is a 404.
Responses
Published (or a no-op success if nothing was ever drafted for this page).
| Field | Type |
|---|---|
| ok* | true |
A Postgres error on the read or the write.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
type is not one of the seven SITE_PAGE_TYPES.
Raw OpenAPI operation
{
"summary": "Publish a page's draft content live (admin)",
"description": "Copies `draft_content` over `published_content` and stamps `published_at`. Unconditionally admin-only, no `manage_org_settings` escape hatch (unlike the save route above): migration 0112 grants UPDATE on `draft_content`/`updated_at` only, deliberately never on `published_content`, so this is the ONLY place \"publish\" is actually enforced. A company that never touched this page's draft gets a clean 200 no-op: the live page already renders its generated default and there is nothing to copy forward.",
"tags": [
"Appointments engine"
],
"operationId": "publishSitePage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"parameters": [
{
"name": "type",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"home",
"services",
"about",
"team",
"gallery",
"reviews",
"contact"
]
},
"description": "Which of the seven site pages (SITE_PAGE_TYPES). Anything else is a 404."
}
],
"responses": {
"200": {
"description": "Published (or a no-op success if nothing was ever drafted for this page).",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error on the read or the write.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "`type` is not one of the seven SITE_PAGE_TYPES.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/organization/website/domainRead this company's connected custom domain
/api/organization/website/domainThe "Your domain" card on the Website status screen. domain is null until an admin connects one. Appointments only, same as every other route under the Website screen: the page itself already redirects a hospitality org away before this could ever be called. Readable by any member; only the writes below are admin-only.
Responses
The connected domain, or null.
| Field | Type | Notes |
|---|---|---|
| domain* | object | null | null means this company has no custom domain connected. |
| domain.hostname* | string | |
| domain.vercelVerified* | boolean | Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT "the domain works," see dnsMisconfigured. |
| domain.dnsMisconfigured* | boolean | Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing "Connected" for a hostname nobody had pointed anywhere. |
| domain.verificationChallenge* | object | null | A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding. |
| domain.verificationChallenge.type | string | |
| domain.verificationChallenge.domain | string | |
| domain.verificationChallenge.value | string | |
| domain.lastCheckedAt* | string (date-time) | |
| domain.createdAt* | string (date-time) |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
Not actually reachable from this GET (it reads no Vercel state), but scripts/check-openapi.ts scans status codes per FILE rather than per handler, and notConfigured() (used by POST/PATCH/DELETE below) lives in this same route.ts.
Raw OpenAPI operation
{
"summary": "Read this company's connected custom domain",
"description": "The \"Your domain\" card on the Website status screen. `domain` is null until an admin connects one. Appointments only, same as every other route under the Website screen: the page itself already redirects a hospitality org away before this could ever be called. Readable by any member; only the writes below are admin-only.",
"tags": [
"Appointments engine"
],
"operationId": "getWebsiteDomain",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"responses": {
"200": {
"description": "The connected domain, or null.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"domain"
],
"properties": {
"domain": {
"type": [
"object",
"null"
],
"required": [
"hostname",
"vercelVerified",
"dnsMisconfigured",
"verificationChallenge",
"lastCheckedAt",
"createdAt"
],
"properties": {
"hostname": {
"type": "string"
},
"vercelVerified": {
"type": "boolean",
"description": "Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT \"the domain works,\" see dnsMisconfigured."
},
"dnsMisconfigured": {
"type": "boolean",
"description": "Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing \"Connected\" for a hostname nobody had pointed anywhere."
},
"verificationChallenge": {
"type": [
"object",
"null"
],
"properties": {
"type": {
"type": "string"
},
"domain": {
"type": "string"
},
"value": {
"type": "string"
}
},
"description": "A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding."
},
"lastCheckedAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"description": "null means this company has no custom domain connected."
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Not actually reachable from this GET (it reads no Vercel state), but scripts/check-openapi.ts scans status codes per FILE rather than per handler, and notConfigured() (used by POST/PATCH/DELETE below) lives in this same route.ts.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/organization/website/domainConnect a custom domain (admin)
/api/organization/website/domainv1 is subdomain-CNAME only (migration 0148's own header has the full reasoning): an apex/root domain like "yourbusiness.com.au" is accepted the same way at this layer, the scope restriction is a UI/product decision, not a validation rule here. Attaches hostname to this Vercel project (src/lib/vercel/domains.ts) and upserts book_custom_domains, one row per company (migration 0148's unique(company_id)); connecting a second domain replaces the first. verificationChallenge in the response is a TXT record Vercel wants for ownership proof, present only when Vercel could not verify immediately (most often a domain seen elsewhere before); the CNAME instruction itself is constant and not part of the response, the UI renders it directly.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| hostname* | string | A bare hostname, e.g. "book.yourbusiness.com.au". Lowercased and trimmed before validation. |
Responses
Connected. Echoes the stored row, including Vercel's current verification state.
| Field | Type | Notes |
|---|---|---|
| domain* | object | null | null means this company has no custom domain connected. |
| domain.hostname* | string | |
| domain.vercelVerified* | boolean | Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT "the domain works," see dnsMisconfigured. |
| domain.dnsMisconfigured* | boolean | Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing "Connected" for a hostname nobody had pointed anywhere. |
| domain.verificationChallenge* | object | null | A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding. |
| domain.verificationChallenge.type | string | |
| domain.verificationChallenge.domain | string | |
| domain.verificationChallenge.value | string | |
| domain.lastCheckedAt* | string (date-time) | |
| domain.createdAt* | string (date-time) |
hostname failed format validation (HOSTNAME_RE, lib/booking/custom-domain.ts).
No valid session cookie. {"error":"Not signed in"}.
Connecting a custom domain needs the own_domain PlanFeature: included on Pro/Enterprise, a $10 AUD/mo add-on on Solo/Basic (src/lib/plan.ts).
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
This hostname is already connected to a different Gaplessly account, either caught by our own pre-check or by Vercel refusing a hostname already attached to another project (VercelDomainConflictError), or lost a same-hostname race after Vercel accepted it (the domain is rolled back off Vercel in that case).
Vercel's API rejected or failed the add call for a reason other than a conflict.
VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).
Raw OpenAPI operation
{
"summary": "Connect a custom domain (admin)",
"description": "v1 is subdomain-CNAME only (migration 0148's own header has the full reasoning): an apex/root domain like \"yourbusiness.com.au\" is accepted the same way at this layer, the scope restriction is a UI/product decision, not a validation rule here. Attaches `hostname` to this Vercel project (`src/lib/vercel/domains.ts`) and upserts `book_custom_domains`, one row per company (migration 0148's `unique(company_id)`); connecting a second domain replaces the first. `verificationChallenge` in the response is a TXT record Vercel wants for ownership proof, present only when Vercel could not verify immediately (most often a domain seen elsewhere before); the CNAME instruction itself is constant and not part of the response, the UI renders it directly.",
"tags": [
"Appointments engine"
],
"operationId": "connectWebsiteDomain",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"hostname"
],
"properties": {
"hostname": {
"type": "string",
"description": "A bare hostname, e.g. \"book.yourbusiness.com.au\". Lowercased and trimmed before validation."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Connected. Echoes the stored row, including Vercel's current verification state.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"domain"
],
"properties": {
"domain": {
"type": [
"object",
"null"
],
"required": [
"hostname",
"vercelVerified",
"dnsMisconfigured",
"verificationChallenge",
"lastCheckedAt",
"createdAt"
],
"properties": {
"hostname": {
"type": "string"
},
"vercelVerified": {
"type": "boolean",
"description": "Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT \"the domain works,\" see dnsMisconfigured."
},
"dnsMisconfigured": {
"type": "boolean",
"description": "Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing \"Connected\" for a hostname nobody had pointed anywhere."
},
"verificationChallenge": {
"type": [
"object",
"null"
],
"properties": {
"type": {
"type": "string"
},
"domain": {
"type": "string"
},
"value": {
"type": "string"
}
},
"description": "A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding."
},
"lastCheckedAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"description": "null means this company has no custom domain connected."
}
}
}
}
}
},
"400": {
"description": "`hostname` failed format validation (HOSTNAME_RE, lib/booking/custom-domain.ts).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "Connecting a custom domain needs the `own_domain` PlanFeature: included on Pro/Enterprise, a $10 AUD/mo add-on on Solo/Basic (src/lib/plan.ts).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "This hostname is already connected to a different Gaplessly account, either caught by our own pre-check or by Vercel refusing a hostname already attached to another project (VercelDomainConflictError), or lost a same-hostname race after Vercel accepted it (the domain is rolled back off Vercel in that case).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Vercel's API rejected or failed the add call for a reason other than a conflict.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/organization/website/domainRe-check this company's connected domain against Vercel (admin)
/api/organization/website/domainThe poll the UI drives every ~30s while a connected domain sits unverified, waiting on the operator's own DNS change to propagate. Vercel has no webhook for "DNS propagated" or "cert issued" (see src/lib/vercel/domains.ts), so re-checking on demand against Vercel's own verify + config endpoints is the documented pattern. No-op success is not possible here: 404 if nothing is connected yet.
Responses
The domain's refreshed state.
| Field | Type | Notes |
|---|---|---|
| domain* | object | null | null means this company has no custom domain connected. |
| domain.hostname* | string | |
| domain.vercelVerified* | boolean | Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT "the domain works," see dnsMisconfigured. |
| domain.dnsMisconfigured* | boolean | Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing "Connected" for a hostname nobody had pointed anywhere. |
| domain.verificationChallenge* | object | null | A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding. |
| domain.verificationChallenge.type | string | |
| domain.verificationChallenge.domain | string | |
| domain.verificationChallenge.value | string | |
| domain.lastCheckedAt* | string (date-time) | |
| domain.createdAt* | string (date-time) |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
No domain is connected for this company.
Vercel's verify or config call failed for a reason other than "already verified".
VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).
Raw OpenAPI operation
{
"summary": "Re-check this company's connected domain against Vercel (admin)",
"description": "The poll the UI drives every ~30s while a connected domain sits unverified, waiting on the operator's own DNS change to propagate. Vercel has no webhook for \"DNS propagated\" or \"cert issued\" (see src/lib/vercel/domains.ts), so re-checking on demand against Vercel's own verify + config endpoints is the documented pattern. No-op success is not possible here: 404 if nothing is connected yet.",
"tags": [
"Appointments engine"
],
"operationId": "recheckWebsiteDomain",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"responses": {
"200": {
"description": "The domain's refreshed state.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"domain"
],
"properties": {
"domain": {
"type": [
"object",
"null"
],
"required": [
"hostname",
"vercelVerified",
"dnsMisconfigured",
"verificationChallenge",
"lastCheckedAt",
"createdAt"
],
"properties": {
"hostname": {
"type": "string"
},
"vercelVerified": {
"type": "boolean",
"description": "Whether Vercel accepted the ownership claim. Often true immediately on a fresh, uncontested hostname, before any DNS record exists for it: this is NOT \"the domain works,\" see dnsMisconfigured."
},
"dnsMisconfigured": {
"type": "boolean",
"description": "Vercel's own live DNS-resolution check. A domain is only actually reachable when vercelVerified is true AND this is false; found live while testing 0148 that vercelVerified alone was misleading the UI into showing \"Connected\" for a hostname nobody had pointed anywhere."
},
"verificationChallenge": {
"type": [
"object",
"null"
],
"properties": {
"type": {
"type": "string"
},
"domain": {
"type": "string"
},
"value": {
"type": "string"
}
},
"description": "A TXT ownership challenge Vercel wants before it will verify, or null once none is outstanding."
},
"lastCheckedAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"description": "null means this company has no custom domain connected."
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No domain is connected for this company.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Vercel's verify or config call failed for a reason other than \"already verified\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/organization/website/domainDisconnect the connected custom domain (admin)
/api/organization/website/domainRemoves the domain from this Vercel project, then the book_custom_domains row. Idempotent: 200 with no error if nothing was connected in the first place, the end state ("no domain connected") is what the caller wants either way.
Responses
Disconnected, or already had nothing connected.
| Field | Type |
|---|---|
| ok* | true |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
Vercel's remove call failed for a reason other than the domain already being gone (404 from Vercel is treated as success).
VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).
Raw OpenAPI operation
{
"summary": "Disconnect the connected custom domain (admin)",
"description": "Removes the domain from this Vercel project, then the `book_custom_domains` row. Idempotent: 200 with no error if nothing was connected in the first place, the end state (\"no domain connected\") is what the caller wants either way.",
"tags": [
"Appointments engine"
],
"operationId": "disconnectWebsiteDomain",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"responses": {
"200": {
"description": "Disconnected, or already had nothing connected.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Vercel's remove call failed for a reason other than the domain already being gone (404 from Vercel is treated as success).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "VERCEL_API_TOKEN or VERCEL_PROJECT_ID is not configured yet (VercelDomainsNotConfiguredError).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/organization/website/analyticsThis website's traffic (self-hosted, cookieless)
/api/organization/website/analyticsThe "Your website" card's one data source: a self-hosted Umami instance (analytics.solvintia.com), one Umami "website" per company for clean per-tenant isolation (no cross-tenant prefix-summing to get wrong). Cookieless by design, see the Cookie Policy entry this shipped alongside for the exact claim. Lazily provisions the company's Umami website on first call if it has none yet (ensureUmamiWebsiteId, src/lib/booking/umami-website-admin.ts) rather than requiring a separate setup step. Included from Pro upward, or a $4.99/mo standalone add-on on Solo/Basic (src/lib/plan.ts, analytics), checked before any Umami call; readable by any member once past that gate, since this is a display of the business's own public traffic, not a setting.
Parameters
daysquery7 | 30 | 90optionalThe trailing window. Anything else silently falls back to 30, the same permissive-default posture holidays' own
yearparam takes.
Responses
Summary, a daily trend, and four ranked breakdowns, each capped at 8 rows.
| Field | Type |
|---|---|
| days* | integer |
| summary* | object |
| summary.views* | integer |
| summary.visitors* | integer |
| summary.visits* | integer |
| trend* | object[] |
| trend[].date* | string |
| trend[].views* | integer |
| trend[].visitors* | integer |
| popularPages* | object[] |
| popularPages[].label* | string |
| popularPages[].count* | integer |
| referrers* | object[] |
| referrers[].label* | string |
| referrers[].count* | integer |
| devices* | object[] |
| devices[].label* | string |
| devices[].count* | integer |
| countries* | object[] |
| countries[].label* | string |
| countries[].count* | integer |
No valid session cookie. {"error":"Not signed in"}.
The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, analytics).
Not permitted. Includes the engine-boundary refusal: this operation belongs to the appointments engine, so an org whose business_type is hospitality gets 403 with "This organization takes table reservations, not appointments." Also covers no linked organization and not being on the roster.
The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.
UMAMI_URL/UMAMI_USERNAME/UMAMI_PASSWORD is not configured yet (UmamiNotConfiguredError).
Raw OpenAPI operation
{
"summary": "This website's traffic (self-hosted, cookieless)",
"description": "The \"Your website\" card's one data source: a self-hosted Umami instance (analytics.solvintia.com), one Umami \"website\" per company for clean per-tenant isolation (no cross-tenant prefix-summing to get wrong). Cookieless by design, see the Cookie Policy entry this shipped alongside for the exact claim. Lazily provisions the company's Umami website on first call if it has none yet (ensureUmamiWebsiteId, src/lib/booking/umami-website-admin.ts) rather than requiring a separate setup step. Included from Pro upward, or a $4.99/mo standalone add-on on Solo/Basic (src/lib/plan.ts, `analytics`), checked before any Umami call; readable by any member once past that gate, since this is a display of the business's own public traffic, not a setting.",
"tags": [
"Appointments engine"
],
"operationId": "getWebsiteAnalytics",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"parameters": [
{
"name": "days",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"enum": [
7,
30,
90
]
},
"description": "The trailing window. Anything else silently falls back to 30, the same permissive-default posture holidays' own `year` param takes."
}
],
"responses": {
"200": {
"description": "Summary, a daily trend, and four ranked breakdowns, each capped at 8 rows.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"days",
"summary",
"trend",
"popularPages",
"referrers",
"devices",
"countries"
],
"properties": {
"days": {
"type": "integer"
},
"summary": {
"type": "object",
"required": [
"views",
"visitors",
"visits"
],
"properties": {
"views": {
"type": "integer"
},
"visitors": {
"type": "integer"
},
"visits": {
"type": "integer"
}
}
},
"trend": {
"type": "array",
"items": {
"type": "object",
"required": [
"date",
"views",
"visitors"
],
"properties": {
"date": {
"type": "string"
},
"views": {
"type": "integer"
},
"visitors": {
"type": "integer"
}
}
}
},
"popularPages": {
"type": "array",
"items": {
"type": "object",
"required": [
"label",
"count"
],
"properties": {
"label": {
"type": "string"
},
"count": {
"type": "integer"
}
}
}
},
"referrers": {
"type": "array",
"items": {
"type": "object",
"required": [
"label",
"count"
],
"properties": {
"label": {
"type": "string"
},
"count": {
"type": "integer"
}
}
}
},
"devices": {
"type": "array",
"items": {
"type": "object",
"required": [
"label",
"count"
],
"properties": {
"label": {
"type": "string"
},
"count": {
"type": "integer"
}
}
}
},
"countries": {
"type": "array",
"items": {
"type": "object",
"required": [
"label",
"count"
],
"properties": {
"label": {
"type": "string"
},
"count": {
"type": "integer"
}
}
}
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, `analytics`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the appointments engine, so an org whose `business_type` is `hospitality` gets 403 with \"This organization takes table reservations, not appointments.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "UMAMI_URL/UMAMI_USERNAME/UMAMI_PASSWORD is not configured yet (UmamiNotConfiguredError).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/organization/website/offlineTake the public site offline, or bring it back (admin)
/api/organization/website/offlineFlips book_companies.site_offline. Unconditionally admin-only, no manage_org_settings escape hatch, same posture the publish route (/api/site-pages/{type}/publish) takes: this makes or stops making the tenant's public site unreachable for every visitor, a bigger call than the general settings a staff member with that grant can already make. Appointments only, same as every other route under the Website screen: the page itself already redirects a hospitality org away before this control could ever render. Revalidates the public site's cache tag on success, keyed off the company's slug (fetched via the same .select('slug') the update itself performs, not a separate read).
Request body application/json
| Field | Type | Notes |
|---|---|---|
| offline* | boolean | true takes the site offline; false brings it back. |
Responses
The flag was updated.
| Field | Type |
|---|---|
| ok* | true |
offline was missing or not a boolean, or the update itself failed (e.g. the company row could not be found).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is hospitality cannot reach the appointments engine at all.
Raw OpenAPI operation
{
"summary": "Take the public site offline, or bring it back (admin)",
"description": "Flips `book_companies.site_offline`. Unconditionally admin-only, no `manage_org_settings` escape hatch, same posture the publish route (`/api/site-pages/{type}/publish`) takes: this makes or stops making the tenant's public site unreachable for every visitor, a bigger call than the general settings a staff member with that grant can already make. Appointments only, same as every other route under the Website screen: the page itself already redirects a hospitality org away before this control could ever render. Revalidates the public site's cache tag on success, keyed off the company's slug (fetched via the same `.select('slug')` the update itself performs, not a separate read).",
"tags": [
"Appointments engine"
],
"operationId": "setWebsiteOffline",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "appointments",
"x-required-role": "admin",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"offline"
],
"properties": {
"offline": {
"type": "boolean",
"description": "true takes the site offline; false brings it back."
}
}
}
}
}
},
"responses": {
"200": {
"description": "The flag was updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "`offline` was missing or not a boolean, or the update itself failed (e.g. the company row could not be found).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `hospitality` cannot reach the appointments engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}Hospitality engine
Tables, service periods and reservations. Session-authenticated, engine-guarded: an appointments org gets 403.
get/api/v1/reservationsList reservations (API key)
/api/v1/reservationsThe hospitality engine's table reservations. The mirror of GET /api/v1/appointments (same envelope, same paging, same filters, different table), and deliberately not the same endpoint with a flag. The two engines share plumbing and never booking logic (migration 0011), and a single /bookings endpoint would be the first place that line got crossed.
Engine-guarded: an appointments organization's key is refused with wrong_vertical before scope is even checked.
Ordered newest first; use from and to for a service, a day or a week. holdExpiresAt IS exposed, unlike the other withheld columns: a hold row whose hold has expired is not a booking, and a program reading reservations has to be able to tell.
Three clocks in one object, and they are not interchangeable. startsAt and endsAt are the venue's wall clock wearing a +00 suffix; holdExpiresAt and createdAt are true UTC instants. All four serialise identically, so nothing in the payload distinguishes them and a caller that parses them alike is wrong about some of them by the venue's entire UTC offset. The sharpest edge is comparing holdExpiresAt against startsAt, or evaluating it in venue-local terms: it is the one field here whose whole purpose is a comparison against the current real instant. from and to filter on the same wall clock startsAt does. Resolve the venue's zone from timezone on GET /api/v1/organization, which needs the separate organization:read scope. See the timestamps section of docs/api-keys.md. Three columns are withheld: manage_token (the entire credential for the guest self-service link, handing it to a program hands over the ability to act as the guest), internal_note (the venue's private note, excluded for the same reason book_customers.notes is), and stripe_payment_intent_id (an identifier in somebody else's system).
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.fromquerystring (date-time)optionalInclusive lower bound on
startsAt. Any timestamp Postgres accepts; a value it rejects is a 400. Combine withtofor a day or a week.Compared against `startsAt`, so it is in the venue's wall clock, not UTC, whatever offset you write on it.
from=2026-08-20T00:00:00Zmeans midnight at the venue, not midnight UTC, and theZis not honoured. To ask for a venue's day, write that day's local midnight and ignore the offset. Asking in real UTC instead returns a window shifted by the venue's offset, which for an Australian venue is most of a different day.toquerystring (date-time)optionalEXCLUSIVE upper bound on
startsAt. Exclusive so thatfrom=2026-07-26T00:00:00&to=2026-07-27T00:00:00is exactly one day with no double-counting at the seam. That day is the venue's, not UTC: likefrom, this is compared against the venue's wall clock and any offset you write is ignored.
Responses
The reservations, newest first.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].startsAt | string (date-time) | The venue's local wall clock, not a UTC instant, despite the `+00` suffix. A 19:00 booking at a Sydney venue is returned as |
| data[].endsAt | string (date-time) | The venue's local wall clock, not a UTC instant, despite the `+00` suffix. A 19:00 booking at a Sydney venue is returned as |
| data[].status | "hold" | "pending" | "confirmed" | "seated" | "completed" | "cancelled" | "no_show" | |
| data[].partySize | integer | |
| data[].turnMinutes | integer | How long the table is held for. |
| data[].occasion | string | null | |
| data[].notes | string | null | The guest's request for this booking. |
| data[].tableId | string | null (uuid) | |
| data[].customerId | string | null (uuid) | |
| data[].holdExpiresAt | string | null (date-time) | Set only while A true UTC instant, unlike |
| data[].depositStatus | "none" | "pending" | "paid" | "refunded" | "forfeited" | |
| data[].depositAmountCents | integer | null | |
| data[].createdAt | string (date-time) | A true UTC instant (the row's |
| nextCursor* | string | null | Pass to |
Two causes, distinguishable by whether code is present. `invalid_cursor`: the cursor was not one this API issued. A malformed `from` or `to`: Postgres rejected the timestamp, and the body is its message with no `code`, since it did not come from the gate. Both are the caller's input, which is why neither is a 500.
| Field | Type |
|---|---|
| error* | string |
| code | "invalid_cursor" |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List reservations (API key)",
"description": "The hospitality engine's table reservations. The mirror of `GET /api/v1/appointments` (same envelope, same paging, same filters, different table), and deliberately **not** the same endpoint with a flag. The two engines share plumbing and never booking logic (migration 0011), and a single `/bookings` endpoint would be the first place that line got crossed.\n\nEngine-guarded: an appointments organization's key is refused with `wrong_vertical` before scope is even checked.\n\nOrdered newest first; use `from` and `to` for a service, a day or a week. `holdExpiresAt` IS exposed, unlike the other withheld columns: a `hold` row whose hold has expired is not a booking, and a program reading reservations has to be able to tell.\n\n**Three clocks in one object, and they are not interchangeable.** `startsAt` and `endsAt` are the venue's wall clock wearing a `+00` suffix; `holdExpiresAt` and `createdAt` are true UTC instants. All four serialise identically, so nothing in the payload distinguishes them and a caller that parses them alike is wrong about some of them by the venue's entire UTC offset. The sharpest edge is comparing `holdExpiresAt` against `startsAt`, or evaluating it in venue-local terms: it is the one field here whose whole purpose is a comparison against the current real instant. `from` and `to` filter on the same wall clock `startsAt` does. Resolve the venue's zone from `timezone` on `GET /api/v1/organization`, which needs the separate `organization:read` scope. See the timestamps section of `docs/api-keys.md`. Three columns are withheld: `manage_token` (the entire credential for the guest self-service link, handing it to a program hands over the ability to act as the guest), `internal_note` (the venue's private note, excluded for the same reason `book_customers.notes` is), and `stripe_payment_intent_id` (an identifier in somebody else's system).",
"tags": [
"Hospitality engine"
],
"operationId": "listReservationsV1",
"security": [
{
"bearerApiKey": [
"reservations:read"
]
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
},
{
"name": "from",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "Inclusive lower bound on `startsAt`. Any timestamp Postgres accepts; a value it rejects is a 400. Combine with `to` for a day or a week.\n\n**Compared against `startsAt`, so it is in the venue's wall clock, not UTC**, whatever offset you write on it. `from=2026-08-20T00:00:00Z` means midnight at the venue, not midnight UTC, and the `Z` is not honoured. To ask for a venue's day, write that day's local midnight and ignore the offset. Asking in real UTC instead returns a window shifted by the venue's offset, which for an Australian venue is most of a different day."
},
{
"name": "to",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "EXCLUSIVE upper bound on `startsAt`. Exclusive so that `from=2026-07-26T00:00:00&to=2026-07-27T00:00:00` is exactly one day with no double-counting at the seam. **That day is the venue's, not UTC**: like `from`, this is compared against the venue's wall clock and any offset you write is ignored."
}
],
"responses": {
"200": {
"description": "The reservations, newest first.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase.",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"startsAt": {
"type": "string",
"format": "date-time",
"description": "**The venue's local wall clock, not a UTC instant, despite the `+00` suffix.** A 19:00 booking at a Sydney venue is returned as `2026-08-20T19:00:00+00`, not as the `09:00Z` that instant really is. Read the digits as local time and ignore the offset, or convert properly using `timezone` from `GET /api/v1/organization`. Do not hand it to a UTC-aware parser and render the result in the reader's zone: that shifts every booking by the venue's whole offset, ten or eleven hours for an Australian venue. `createdAt` in this same object IS a true instant, so the two cannot be treated alike. See the timestamps section of `docs/api-keys.md`."
},
"endsAt": {
"type": "string",
"format": "date-time",
"description": "**The venue's local wall clock, not a UTC instant, despite the `+00` suffix.** A 19:00 booking at a Sydney venue is returned as `2026-08-20T19:00:00+00`, not as the `09:00Z` that instant really is. Read the digits as local time and ignore the offset, or convert properly using `timezone` from `GET /api/v1/organization`. Do not hand it to a UTC-aware parser and render the result in the reader's zone: that shifts every booking by the venue's whole offset, ten or eleven hours for an Australian venue. `createdAt` in this same object IS a true instant, so the two cannot be treated alike. See the timestamps section of `docs/api-keys.md`."
},
"status": {
"type": "string",
"enum": [
"hold",
"pending",
"confirmed",
"seated",
"completed",
"cancelled",
"no_show"
]
},
"partySize": {
"type": "integer"
},
"turnMinutes": {
"type": "integer",
"description": "How long the table is held for. `endsAt` is `startsAt` plus this."
},
"occasion": {
"type": [
"string",
"null"
]
},
"notes": {
"type": [
"string",
"null"
],
"description": "The guest's request for this booking."
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"customerId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"holdExpiresAt": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Set only while `status` is `hold`. Past means the hold lapsed and the slot is free again.\n\n**A true UTC instant**, unlike `startsAt`/`endsAt` in this same object: it is written as a real `Date.now()` offset, not as venue wall clock. Compare it against the current UTC time (`Date.now()`), never against `startsAt`, and never after shifting it into the venue's zone. Getting this backwards reads every live hold as long expired, or every expired one as live, by the venue's whole UTC offset."
},
"depositStatus": {
"type": "string",
"enum": [
"none",
"pending",
"paid",
"refunded",
"forfeited"
]
},
"depositAmountCents": {
"type": [
"integer",
"null"
]
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "A **true UTC instant** (the row's `now()` default), like `holdExpiresAt` and unlike `startsAt`/`endsAt`. Parse and render this one normally."
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "Two causes, distinguishable by whether `code` is present. **`invalid_cursor`**: the `cursor` was not one this API issued. **A malformed `from` or `to`**: Postgres rejected the timestamp, and the body is its message with **no `code`**, since it did not come from the gate. Both are the caller's input, which is why neither is a 500.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"const": "invalid_cursor"
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}get/api/v1/tablesList tables (API key)
/api/v1/tablesThe hospitality engine's tables. Engine-guarded, ordered by id: see GET /api/v1/services for why.
areaId is returned but there is no `/api/v1/areas` or `/api/v1/levels`, and that is a recorded gap rather than an oversight: book_areas/book_levels have no scope in the vocabulary (dashboard-only /api/areas and /api/levels exist as of migration 0030, but neither is exposed to API keys), and inventing areas:read/levels:read would change what every future key can be granted. The id is enough to group tables by room.
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.
Responses
The tables.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].name | string | |
| data[].areaId | string | null (uuid) | |
| data[].seatsMin | integer | |
| data[].seatsMax | integer | A party larger than this cannot be seated here: the constraint reservation availability is computed against. |
| data[].active | boolean | |
| nextCursor* | string | null | Pass to |
invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "invalid_cursor" | |
| required_scope | string | Present only on |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List tables (API key)",
"description": "The hospitality engine's tables. Engine-guarded, ordered by `id`: see `GET /api/v1/services` for why.\n\n`areaId` is returned but **there is no `/api/v1/areas` or `/api/v1/levels`**, and that is a recorded gap rather than an oversight: `book_areas`/`book_levels` have no scope in the vocabulary (dashboard-only `/api/areas` and `/api/levels` exist as of migration 0030, but neither is exposed to API keys), and inventing `areas:read`/`levels:read` would change what every future key can be granted. The id is enough to group tables by room.",
"tags": [
"Hospitality engine"
],
"operationId": "listTablesV1",
"security": [
{
"bearerApiKey": [
"tables:read"
]
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
}
],
"responses": {
"200": {
"description": "The tables.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase.",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"seatsMin": {
"type": "integer"
},
"seatsMax": {
"type": "integer",
"description": "A party larger than this cannot be seated here: the constraint reservation availability is computed against."
},
"active": {
"type": "boolean"
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "`invalid_cursor`: the `cursor` was not one this API issued. Pass back `nextCursor` unchanged; do not construct one.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"invalid_cursor"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}get/api/v1/service-periodsList service periods (API key)
/api/v1/service-periodsThe hospitality engine's service periods: which days a venue serves, between which times, on what turn. Engine-guarded, ordered by id: see GET /api/v1/services for why.
This is the endpoint that makes reservation availability explicable rather than magic. No active period for a weekday means an empty grid that day, and a program told "no availability" with no way to see why will simply keep asking.
startTime, endTime and lastSeating are wall-clock HH:MM:SS in the organization's own timezone (on GET /api/v1/organization), not instants. A service period is a rule about clock time, not a moment, so converting them here would be lossy. Group by dayOfWeek for a week grid.
Parameters
limitqueryintegeroptionalClamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A
?limit=1e9from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.cursorquerystringoptionalThe
nextCursorfrom the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a400 invalid_cursorrather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.The scheme is keyset (seek) paging on
(order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters andlimitmay change between pages; the cursor only says where you got to.
Responses
The service periods.
| Field | Type | Notes |
|---|---|---|
| data* | object[] | |
| data[].id | string (uuid) | |
| data[].name | string | |
| data[].dayOfWeek | integer | (min 0, max 6) 0 = Sunday, matching both Postgres and JavaScript. |
| data[].startTime | string | Wall clock, |
| data[].endTime | string | Wall clock, |
| data[].lastSeating | string | null | The latest a party may be seated. Null means up to |
| data[].turnMinutes | integer | |
| data[].slotMinutes | integer | The granularity offered: 15 means quarter-past bookings. |
| data[].maxCovers | integer | null | A cap on concurrent covers across the whole venue, independent of table capacity. Null means uncapped. |
| data[].active | boolean | |
| nextCursor* | string | null | Pass to |
invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "invalid_cursor" | |
| required_scope | string | Present only on |
The credential itself is not usable. Stop and fix the key, do not retry. missing_authorization: no Authorization: Bearer header. invalid_key: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). key_revoked / key_expired: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. organization_inactive: the org itself is switched off, so none of its keys work.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive" | |
| required_scope | string | Present only on |
The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. insufficient_scope: the key does not hold the scope named in required_scope. wrong_vertical: the engine boundary, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with wrong_vertical.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "insufficient_scope" | "wrong_vertical" | |
| required_scope | string | Present only on |
Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is identified, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | "rate_limited" | |
| required_scope | string | Present only on |
The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.
| Field | Type | Notes |
|---|---|---|
| error* | string | |
| code* | ||
| required_scope | string | Present only on |
Raw OpenAPI operation
{
"summary": "List service periods (API key)",
"description": "The hospitality engine's service periods: which days a venue serves, between which times, on what turn. Engine-guarded, ordered by `id`: see `GET /api/v1/services` for why.\n\nThis is the endpoint that makes reservation availability **explicable** rather than magic. No active period for a weekday means an empty grid that day, and a program told \"no availability\" with no way to see why will simply keep asking.\n\n`startTime`, `endTime` and `lastSeating` are wall-clock `HH:MM:SS` in the organization's own `timezone` (on `GET /api/v1/organization`), not instants. A service period is a rule about clock time, not a moment, so converting them here would be lossy. Group by `dayOfWeek` for a week grid.",
"tags": [
"Hospitality engine"
],
"operationId": "listServicePeriodsV1",
"security": [
{
"bearerApiKey": [
"service-periods:read"
]
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"description": "Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A `?limit=1e9` from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "The `nextCursor` from the previous response, passed back unchanged. Omit it for the first page. **Opaque**: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a `400 invalid_cursor` rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.\n\nThe scheme is keyset (seek) paging on `(order column, id)`, not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and `limit` may change between pages; the cursor only says *where you got to*."
}
],
"responses": {
"200": {
"description": "The service periods.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data",
"nextCursor"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"description": "camelCase.",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching both Postgres and JavaScript."
},
"startTime": {
"type": "string",
"description": "Wall clock, `HH:MM:SS`."
},
"endTime": {
"type": "string",
"description": "Wall clock, `HH:MM:SS`."
},
"lastSeating": {
"type": [
"string",
"null"
],
"description": "The latest a party may be seated. Null means up to `endTime`."
},
"turnMinutes": {
"type": "integer"
},
"slotMinutes": {
"type": "integer",
"description": "The granularity offered: 15 means quarter-past bookings."
},
"maxCovers": {
"type": [
"integer",
"null"
],
"description": "A cap on concurrent covers across the whole venue, independent of table capacity. Null means uncapped."
},
"active": {
"type": "boolean"
}
}
}
},
"nextCursor": {
"type": [
"string",
"null"
],
"description": "Pass to `?cursor=` for the next page. **`null` means there are no more rows**: always present, never absent and never undefined, so a caller does not have to distinguish the two. It is exact rather than optimistic: the handler fetches `limit + 1` rows and reports the extra one as a cursor, so N pages take N requests, not N+1."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"400": {
"description": "`invalid_cursor`: the `cursor` was not one this API issued. Pass back `nextCursor` unchanged; do not construct one.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"invalid_cursor"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"401": {
"description": "The credential itself is not usable. Stop and fix the key, do not retry. `missing_authorization`: no `Authorization: Bearer` header. `invalid_key`: wrong format, unknown key id, or digest mismatch (these are one code on purpose: distinguishing them would confirm which key ids exist). `key_revoked` / `key_expired`: reported distinctly, which is safe because both are only reachable after the digest already matched, so only the legitimate holder ever sees them. `organization_inactive`: the org itself is switched off, so none of its keys work.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"missing_authorization",
"invalid_key",
"key_revoked",
"key_expired",
"organization_inactive"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
},
"403": {
"description": "The credential is fine but may not do this. Stop and fix the request, or re-issue the key with more scopes. `insufficient_scope`: the key does not hold the scope named in `required_scope`. `wrong_vertical`: **the engine boundary**, this operation belongs to the table-reservations engine and the key belongs to an appointments organization. Checked BEFORE scope, so the key is told the truth (wrong product) rather than being told to add a scope its org can never be granted, verified by attacking it: a key that DOES hold the scope is still refused with `wrong_vertical`.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"insufficient_scope",
"wrong_vertical"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"429": {
"description": "Per-key fixed-window rate limit exceeded. The counter is incremented as soon as the caller is *identified*, before scope is checked, so a key hammering endpoints it has no scope for is still throttled. Honest limitation: a fixed window allows up to 2x the limit across a boundary; the limit exists to stop a runaway script and bound database load, not to meter billing.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": [
"rate_limited"
]
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the window ends."
},
"X-RateLimit-Limit": {
"schema": {
"type": "integer"
},
"description": "Requests allowed in the current 60-second window."
},
"X-RateLimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "Requests left in it."
},
"X-RateLimit-Reset": {
"schema": {
"type": "integer"
},
"description": "Unix seconds at which the window rolls over."
}
}
},
"500": {
"description": "The query failed. The body is the raw Postgres message and carries no `code`, unlike the gate's own errors above.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"code": {
"type": "string",
"enum": []
},
"required_scope": {
"type": "string",
"description": "Present only on `insufficient_scope`: the scope the key would need."
}
}
}
}
}
}
}
}post/api/tablesCreate a table
/api/tablesUnlike services and staff, tables are not part of the cached public booking config; reservation availability is computed live per request, so nothing is revalidated here.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| seatsMin* | integer | (min 1, max 40) |
| seatsMax* | integer | (min 1, max 40) Must be >= seatsMin. |
| areaId | string | null (uuid) | Floor area (a room). A foreign org's area id is rejected by a composite foreign key (400). Mutually exclusive with levelId; sending both is a 400. |
| levelId | string | null (uuid) | A floor level, for a table that sits directly on it with no room subdivision. A foreign org's level id is rejected by a composite foreign key (400). Mutually exclusive with areaId; sending both is a 400. |
| linkGroupId | string | null (uuid) | A run of tables that can physically be pushed together (migration 0035). null; the default; means this table combines with nothing. A foreign org's group id is rejected by a composite foreign key (400), and so is a group whose other tables are in a different room, since two tables in different rooms cannot be pushed together. |
| active | boolean | (default true) |
| shape | "rect" | "round" | "square" | "booth" | "bar" | "bar_l" | "bar_u" | "bar_horseshoe" | "bar_island" | "bar_organic" | "bar_octagon" | "bar_chevron" | (default "rect") Floor-plan geometry (migration 0091, extended 0092/0206/0219/0220/0222/0223/0224). Omitting it on an EDIT resets the table to rect: send the table's current shape back, the same way every other field here is a full replace, not a merge. |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| table* | object |
| table.id | string (uuid) |
A validation message from parseTableInput, or a Postgres error (including a foreign areaId refused by the composite foreign key).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create a table",
"description": "Unlike services and staff, tables are not part of the cached public booking config; reservation availability is computed live per request, so nothing is revalidated here.",
"tags": [
"Hospitality engine"
],
"operationId": "createTable",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"seatsMin",
"seatsMax"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"seatsMin": {
"type": "integer",
"minimum": 1,
"maximum": 40
},
"seatsMax": {
"type": "integer",
"minimum": 1,
"maximum": 40,
"description": "Must be >= seatsMin."
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Floor area (a room). A foreign org's area id is rejected by a composite foreign key (400). Mutually exclusive with levelId; sending both is a 400."
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "A floor level, for a table that sits directly on it with no room subdivision. A foreign org's level id is rejected by a composite foreign key (400). Mutually exclusive with areaId; sending both is a 400."
},
"linkGroupId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "A run of tables that can physically be pushed together (migration 0035). null; the default; means this table combines with nothing. A foreign org's group id is rejected by a composite foreign key (400), and so is a group whose other tables are in a different room, since two tables in different rooms cannot be pushed together."
},
"active": {
"type": "boolean",
"default": true
},
"shape": {
"type": "string",
"enum": [
"rect",
"round",
"square",
"booth",
"bar",
"bar_l",
"bar_u",
"bar_horseshoe",
"bar_island",
"bar_organic",
"bar_octagon",
"bar_chevron"
],
"default": "rect",
"description": "Floor-plan geometry (migration 0091, extended 0092/0206/0219/0220/0222/0223/0224). Omitting it on an EDIT resets the table to rect: send the table's current shape back, the same way every other field here is a full replace, not a merge."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"table"
],
"properties": {
"ok": {
"const": true
},
"table": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseTableInput, or a Postgres error (including a foreign `areaId` refused by the composite foreign key).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/tables/bulkCreate a range of tables in one request
/api/tables/bulkGenerates prefix + separator + n for every n from from to to inclusive (capped at 50 per batch) and inserts them as one atomic statement. Rejects the whole batch; nothing is created; if any generated name already exists in this org; table names are not unique in the schema, so this is an app-level check on a fresh read, not a database constraint.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| prefix | string | (max length 40) Free text, e.g. "Booth". May be empty for pure numbers ("10", "11"...). |
| from* | integer | (min 0) First number in the run. Does not have to be 1. |
| to* | integer | (min 0) Last number in the run, inclusive. |
| separator | string | (max length 10) "Booth 10" vs "Booth-10" vs "Booth10". Defaults to "" (no separator) if omitted. |
| seatsMin* | integer | (min 1, max 40) |
| seatsMax* | integer | (min 1, max 40) Must be >= seatsMin. Applied to every table in the run. |
| areaId | string | null (uuid) | Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with levelId. |
| levelId | string | null (uuid) | Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with areaId. |
| linkGroupId | string | null (uuid) | Same rule as POST /api/tables, applied to every table in the run. |
| active | boolean | (default true) |
| shape | "rect" | "round" | "square" | "booth" | "bar" | "bar_l" | "bar_u" | "bar_horseshoe" | "bar_island" | "bar_organic" | "bar_octagon" | "bar_chevron" | (default "rect") Applied to every table in the run. |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| tables* | object[] |
| tables[].id | string (uuid) |
A validation message from parseBulkTableInput (a bad range, the 50-table cap, a generated name over 40 characters, or a link-group room mismatch), or a Postgres error (including a foreign areaId/levelId/linkGroupId refused by a composite foreign key).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
One or more generated names already exist in this org. The whole batch is rejected; nothing is created, and the message names which ones collided.
Raw OpenAPI operation
{
"summary": "Create a range of tables in one request",
"description": "Generates `prefix + separator + n` for every n from `from` to `to` inclusive (capped at 50 per batch) and inserts them as one atomic statement. Rejects the whole batch; nothing is created; if any generated name already exists in this org; table names are not unique in the schema, so this is an app-level check on a fresh read, not a database constraint.",
"tags": [
"Hospitality engine"
],
"operationId": "bulkCreateTables",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"from",
"to",
"seatsMin",
"seatsMax"
],
"additionalProperties": false,
"properties": {
"prefix": {
"type": "string",
"maxLength": 40,
"description": "Free text, e.g. \"Booth\". May be empty for pure numbers (\"10\", \"11\"...)."
},
"from": {
"type": "integer",
"minimum": 0,
"description": "First number in the run. Does not have to be 1."
},
"to": {
"type": "integer",
"minimum": 0,
"description": "Last number in the run, inclusive. `to - from + 1` is capped at 50."
},
"separator": {
"type": "string",
"maxLength": 10,
"description": "\"Booth 10\" vs \"Booth-10\" vs \"Booth10\". Defaults to \"\" (no separator) if omitted."
},
"seatsMin": {
"type": "integer",
"minimum": 1,
"maximum": 40
},
"seatsMax": {
"type": "integer",
"minimum": 1,
"maximum": 40,
"description": "Must be >= seatsMin. Applied to every table in the run."
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with levelId."
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with areaId."
},
"linkGroupId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Same rule as POST /api/tables, applied to every table in the run."
},
"active": {
"type": "boolean",
"default": true
},
"shape": {
"type": "string",
"enum": [
"rect",
"round",
"square",
"booth",
"bar",
"bar_l",
"bar_u",
"bar_horseshoe",
"bar_island",
"bar_organic",
"bar_octagon",
"bar_chevron"
],
"default": "rect",
"description": "Applied to every table in the run."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"tables"
],
"properties": {
"ok": {
"const": true
},
"tables": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseBulkTableInput (a bad range, the 50-table cap, a generated name over 40 characters, or a link-group room mismatch), or a Postgres error (including a foreign areaId/levelId/linkGroupId refused by a composite foreign key).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "One or more generated names already exist in this org. The whole batch is rejected; nothing is created, and the message names which ones collided.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/floor-planSave one floor's decor elements
/api/floor-planReplaces the decor (walls, doors, stairs, bar, stools, plants, text labels; migration 0092) for one canvas wholesale: levelId null means the venue's single implicit floor. Element geometry is in the same grid units as table geometry. Strict on write; the read side re-parses leniently and drops junk, so a malformed element here is a 400 naming the problem rather than a silent shrink.
The backdrop image (0094) is positioned and faded through the background* fields here, but its URL deliberately is NOT settable: background_url is written only by POST /api/floor-plan/background, from a file that route just placed in this app's own bucket, so no request can aim a venue's canvas at an arbitrary external image.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| levelId | string | null (uuid) | |
| backgroundX | integer | null | (min 0, max 2000) |
| backgroundY | integer | null | (min 0, max 2000) |
| backgroundWidth | integer | null | (min 1, max 400) |
| backgroundHeight | integer | null | (min 1, max 400) |
| backgroundOpacity | integer | (min 10, max 100) |
| backgroundInvertDark | boolean | Invert the backdrop in dark mode only (0095). Right for a black-on-white line drawing, wrong for a photo. |
| elements* | object[] | (max items 300) |
| elements[].id* | string | (max length 64) |
| elements[].kind* | "wall" | "door" | "stairs" | "bar" | "stool" | "plant" | "label" | |
| elements[].x | integer | (min 0, max 2000) |
| elements[].y | integer | (min 0, max 2000) |
| elements[].width | integer | (min 1, max 200) |
| elements[].height | integer | (min 1, max 200) |
| elements[].rotation | integer | (min 0, max 359) |
| elements[].text | string | (max length 60) Labels only; stripped from every other kind. |
| elements[].style | "solid" | "thin" | "dotted" | Walls only; stripped from every other kind. Omitted means solid. |
Responses
Saved.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseFloorPlanInput (a malformed element, the 300-element cap), or a foreign levelId refused by the composite foreign key.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Two tabs raced the first save of this canvas and the retry also failed. Save again.
Raw OpenAPI operation
{
"summary": "Save one floor's decor elements",
"description": "Replaces the decor (walls, doors, stairs, bar, stools, plants, text labels; migration 0092) for one canvas wholesale: `levelId` null means the venue's single implicit floor. Element geometry is in the same grid units as table geometry. Strict on write; the read side re-parses leniently and drops junk, so a malformed element here is a 400 naming the problem rather than a silent shrink.\n\nThe backdrop image (0094) is positioned and faded through the `background*` fields here, but its URL deliberately is NOT settable: `background_url` is written only by `POST /api/floor-plan/background`, from a file that route just placed in this app's own bucket, so no request can aim a venue's canvas at an arbitrary external image.",
"tags": [
"Hospitality engine"
],
"operationId": "saveFloorPlan",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"elements"
],
"properties": {
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"backgroundX": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"backgroundY": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"backgroundWidth": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 400
},
"backgroundHeight": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 400
},
"backgroundOpacity": {
"type": "integer",
"minimum": 10,
"maximum": 100
},
"backgroundInvertDark": {
"type": "boolean",
"description": "Invert the backdrop in dark mode only (0095). Right for a black-on-white line drawing, wrong for a photo."
},
"elements": {
"type": "array",
"maxItems": 300,
"items": {
"type": "object",
"required": [
"id",
"kind"
],
"properties": {
"id": {
"type": "string",
"maxLength": 64
},
"kind": {
"type": "string",
"enum": [
"wall",
"door",
"stairs",
"bar",
"stool",
"plant",
"label"
]
},
"x": {
"type": "integer",
"minimum": 0,
"maximum": 2000
},
"y": {
"type": "integer",
"minimum": 0,
"maximum": 2000
},
"width": {
"type": "integer",
"minimum": 1,
"maximum": 200
},
"height": {
"type": "integer",
"minimum": 1,
"maximum": 200
},
"rotation": {
"type": "integer",
"minimum": 0,
"maximum": 359
},
"text": {
"type": "string",
"maxLength": 60,
"description": "Labels only; stripped from every other kind."
},
"style": {
"type": "string",
"enum": [
"solid",
"thin",
"dotted"
],
"description": "Walls only; stripped from every other kind. Omitted means solid."
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseFloorPlanInput (a malformed element, the 300-element cap), or a foreign levelId refused by the composite foreign key.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Two tabs raced the first save of this canvas and the retry also failed. Save again.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/floor-plan/backgroundUpload the backdrop image for one floor
/api/floor-plan/backgroundThe venue's own plan (an architect's drawing, an evacuation diagram, a photo of the host-stand sheet) stored in the public floor-plans bucket at {companyId}/{levelId|none} and painted under the canvas so drawn tables can be aligned to it (migration 0094). multipart/form-data with file and an optional levelId. Re-uploading replaces the object at the same path and cache-busts the URL, keeping whatever box the venue already aligned; a first upload seeds a 40x30-unit box, which the editor then drags to fit.
Request body multipart/form-data
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPG or WebP, up to 5 MB. |
| levelId | string (uuid) | Omit for the single implicit floor. |
Responses
Uploaded.
| Field | Type |
|---|---|
| ok* | true |
| backgroundUrl* | string |
No file, an unsupported type, over 5 MB, a malformed levelId, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Upload the backdrop image for one floor",
"description": "The venue's own plan (an architect's drawing, an evacuation diagram, a photo of the host-stand sheet) stored in the public `floor-plans` bucket at `{companyId}/{levelId|none}` and painted under the canvas so drawn tables can be aligned to it (migration 0094). `multipart/form-data` with `file` and an optional `levelId`. Re-uploading replaces the object at the same path and cache-busts the URL, keeping whatever box the venue already aligned; a first upload seeds a 40x30-unit box, which the editor then drags to fit.",
"tags": [
"Hospitality engine"
],
"operationId": "uploadFloorPlanBackground",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPG or WebP, up to 5 MB."
},
"levelId": {
"type": "string",
"format": "uuid",
"description": "Omit for the single implicit floor."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"backgroundUrl"
],
"properties": {
"ok": {
"const": true
},
"backgroundUrl": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "No file, an unsupported type, over 5 MB, a malformed levelId, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/floor-plan/backgroundRemove a floor's backdrop image
/api/floor-plan/backgroundDeletes the stored object, then clears background_url and the box columns. Opacity is left alone, being a preference rather than part of the backdrop.
Request body application/json
| Field | Type |
|---|---|
| levelId | string | null (uuid) |
Responses
Removed (or was already absent).
| Field | Type |
|---|---|
| ok* | true |
A malformed levelId, or a database error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Remove a floor's backdrop image",
"description": "Deletes the stored object, then clears `background_url` and the box columns. Opacity is left alone, being a preference rather than part of the backdrop.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteFloorPlanBackground",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Removed (or was already absent).",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A malformed levelId, or a database error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/tables/layoutSave floor-plan geometry for a batch of tables and rooms
/api/tables/layoutWhere the floor-plan editor autosaves (migrations 0091 and 0093). A batch on purpose: a drag session touches several tables (and, since 0093, the room zones around them) and one debounced save carries them all. Positions and sizes are in grid units (one unit is a nominal 25cm; see lib/booking/floorplan.ts). Null posX/posY means "not placed" (both or neither, mirroring the database constraints); a table's null width/height means "keep deriving the size from seatsMax", while a DRAWN room must carry its size (there is no seat count to derive one from). An id deleted concurrently is skipped and simply not counted in updated, never an error.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| tables | object[] | (max items 200) |
| tables[].id* | string (uuid) | |
| tables[].posX | integer | null | (min 0, max 2000) |
| tables[].posY | integer | null | (min 0, max 2000) |
| tables[].width | integer | null | (min 1, max 200) |
| tables[].height | integer | null | (min 1, max 200) |
| tables[].rotation | integer | (min 0, max 359, default 0) |
| tables[].shape | "rect" | "round" | "square" | "booth" | "bar" | "bar_l" | "bar_u" | "bar_horseshoe" | "bar_island" | "bar_organic" | "bar_octagon" | "bar_chevron" | (default "rect") |
| rooms | object[] | (max items 200) book_areas zones (0093). Axis-aligned: no rotation, no shape. |
| rooms[].id* | string (uuid) | |
| rooms[].posX | integer | null | (min 0, max 2000) |
| rooms[].posY | integer | null | (min 0, max 2000) |
| rooms[].width | integer | null | (min 1, max 200) |
| rooms[].height | integer | null | (min 1, max 200) |
Responses
Saved. updated counts the rows actually written; an id that no longer exists is skipped.
| Field | Type |
|---|---|
| ok* | true |
| updated* | integer |
A validation message from parseLayoutInput (a half position, an out-of-bounds coordinate, an unknown shape, the 200-entry cap), or a Postgres constraint refusal.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Save floor-plan geometry for a batch of tables and rooms",
"description": "Where the floor-plan editor autosaves (migrations 0091 and 0093). A batch on purpose: a drag session touches several tables (and, since 0093, the room zones around them) and one debounced save carries them all. Positions and sizes are in grid units (one unit is a nominal 25cm; see lib/booking/floorplan.ts). Null `posX`/`posY` means \"not placed\" (both or neither, mirroring the database constraints); a table's null `width`/`height` means \"keep deriving the size from `seatsMax`\", while a DRAWN room must carry its size (there is no seat count to derive one from). An id deleted concurrently is skipped and simply not counted in `updated`, never an error.",
"tags": [
"Hospitality engine"
],
"operationId": "updateTableLayout",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "At least one of `tables` / `rooms` must be non-empty.",
"properties": {
"tables": {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"posX": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"posY": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"width": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 200
},
"height": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 200
},
"rotation": {
"type": "integer",
"minimum": 0,
"maximum": 359,
"default": 0
},
"shape": {
"type": "string",
"enum": [
"rect",
"round",
"square",
"booth",
"bar",
"bar_l",
"bar_u",
"bar_horseshoe",
"bar_island",
"bar_organic",
"bar_octagon",
"bar_chevron"
],
"default": "rect"
}
}
}
},
"rooms": {
"type": "array",
"maxItems": 200,
"description": "book_areas zones (0093). Axis-aligned: no rotation, no shape.",
"items": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"posX": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"posY": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 2000
},
"width": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 200
},
"height": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 200
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved. `updated` counts the rows actually written; an id that no longer exists is skipped.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"updated"
],
"properties": {
"ok": {
"const": true
},
"updated": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "A validation message from parseLayoutInput (a half position, an out-of-bounds coordinate, an unknown shape, the 200-entry cap), or a Postgres constraint refusal.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/tables/{id}Update a table
/api/tables/{id}Full replacement of the table's fields, same as its services twin.
Parameters
id*pathstringTable id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| seatsMin* | integer | (min 1, max 40) |
| seatsMax* | integer | (min 1, max 40) Must be >= seatsMin. |
| areaId | string | null (uuid) | Floor area (a room). A foreign org's area id is rejected by a composite foreign key (400). Mutually exclusive with levelId; sending both is a 400. |
| levelId | string | null (uuid) | A floor level, for a table that sits directly on it with no room subdivision. A foreign org's level id is rejected by a composite foreign key (400). Mutually exclusive with areaId; sending both is a 400. |
| linkGroupId | string | null (uuid) | A run of tables that can physically be pushed together (migration 0035). null; the default; means this table combines with nothing. A foreign org's group id is rejected by a composite foreign key (400), and so is a group whose other tables are in a different room, since two tables in different rooms cannot be pushed together. |
| active | boolean | (default true) |
| shape | "rect" | "round" | "square" | "booth" | "bar" | "bar_l" | "bar_u" | "bar_horseshoe" | "bar_island" | "bar_organic" | "bar_octagon" | "bar_chevron" | (default "rect") Floor-plan geometry (migration 0091, extended 0092/0206/0219/0220/0222/0223/0224). Omitting it on an EDIT resets the table to rect: send the table's current shape back, the same way every other field here is a full replace, not a merge. |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseTableInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No table with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Update a table",
"description": "Full replacement of the table's fields, same as its services twin.",
"tags": [
"Hospitality engine"
],
"operationId": "updateTable",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Table id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"seatsMin",
"seatsMax"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"seatsMin": {
"type": "integer",
"minimum": 1,
"maximum": 40
},
"seatsMax": {
"type": "integer",
"minimum": 1,
"maximum": 40,
"description": "Must be >= seatsMin."
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Floor area (a room). A foreign org's area id is rejected by a composite foreign key (400). Mutually exclusive with levelId; sending both is a 400."
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "A floor level, for a table that sits directly on it with no room subdivision. A foreign org's level id is rejected by a composite foreign key (400). Mutually exclusive with areaId; sending both is a 400."
},
"linkGroupId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "A run of tables that can physically be pushed together (migration 0035). null; the default; means this table combines with nothing. A foreign org's group id is rejected by a composite foreign key (400), and so is a group whose other tables are in a different room, since two tables in different rooms cannot be pushed together."
},
"active": {
"type": "boolean",
"default": true
},
"shape": {
"type": "string",
"enum": [
"rect",
"round",
"square",
"booth",
"bar",
"bar_l",
"bar_u",
"bar_horseshoe",
"bar_island",
"bar_organic",
"bar_octagon",
"bar_chevron"
],
"default": "rect",
"description": "Floor-plan geometry (migration 0091, extended 0092/0206/0219/0220/0222/0223/0224). Omitting it on an EDIT resets the table to rect: send the table's current shape back, the same way every other field here is a full replace, not a merge."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseTableInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No table with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/tables/{id}Delete a table (admin)
/api/tables/{id}book_reservations.table_id is ON DELETE RESTRICT, so a table with sittings against it keeps its history and cannot be deleted.
Parameters
id*pathstringTable id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is appointments cannot reach the hospitality engine at all.
No table with that id in this org.
The table has reservations against it. "Mark it inactive instead of deleting".
Raw OpenAPI operation
{
"summary": "Delete a table (admin)",
"description": "`book_reservations.table_id` is ON DELETE RESTRICT, so a table with sittings against it keeps its history and cannot be deleted.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteTable",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Table id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `appointments` cannot reach the hospitality engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No table with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The table has reservations against it. \"Mark it inactive instead of deleting\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/levelsCreate a level (floor)
/api/levelsThe first tier of the floor hierarchy; a level groups areas and/or tables that sit directly on it. Lands at the end of the list (sort_order = max+1, migration 0111) and busts the cached public booking config, matching services/groups. Levels are only ever actually IN that config when locationPickerEnabled is on, same reasoning as tables.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| bookable | boolean | (default true) false lets staff seat/assign here without ever offering it to a guest. |
| description | string | null | (max length 500) Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description. |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| level* | object |
| level.id | string (uuid) |
A validation message from parseLevelInput, or an unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Floors and multiple spaces are Custom-plan only (src/lib/plan.ts, floors); a Basic Table or Venue Pro org stays single-space.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create a level (floor)",
"description": "The first tier of the floor hierarchy; a level groups areas and/or tables that sit directly on it. Lands at the end of the list (`sort_order = max+1`, migration 0111) and busts the cached public booking config, matching services/groups. Levels are only ever actually IN that config when `locationPickerEnabled` is on, same reasoning as tables.",
"tags": [
"Hospitality engine"
],
"operationId": "createLevel",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"bookable": {
"type": "boolean",
"default": true,
"description": "false lets staff seat/assign here without ever offering it to a guest."
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"level"
],
"properties": {
"ok": {
"const": true
},
"level": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseLevelInput, or an unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "Floors and multiple spaces are Custom-plan only (src/lib/plan.ts, `floors`); a Basic Table or Venue Pro org stays single-space.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/levelsReorder levels (floors)
/api/levelsThe body is every level in its new order, and array position IS the new sort_order (migration 0111): the same shape and write pattern as PATCH /api/areas. Not re-gated by planAllows('floors'): that check belongs to CREATING a level; every level being reordered here already exists and already cleared it.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| ids* | string (uuid)[] | (min items 1, max items 100) Every area (or level) id in this org, in the order they should appear. |
Responses
Reordered.
| Field | Type |
|---|---|
| ok* | true |
ids missing, empty, over 100 entries, or containing a non-uuid.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Reorder levels (floors)",
"description": "The body is every level in its new order, and array position IS the new sort_order (migration 0111): the same shape and write pattern as PATCH /api/areas. Not re-gated by `planAllows('floors')`: that check belongs to CREATING a level; every level being reordered here already exists and already cleared it.",
"tags": [
"Hospitality engine"
],
"operationId": "reorderLevels",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ids"
],
"additionalProperties": false,
"properties": {
"ids": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Every area (or level) id in this org, in the order they should appear."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Reordered.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "`ids` missing, empty, over 100 entries, or containing a non-uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/levels/{id}Update a level
/api/levels/{id}Full replacement of the level's fields, same as its table twin.
Parameters
id*pathstringLevel id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| bookable | boolean | (default true) false lets staff seat/assign here without ever offering it to a guest. |
| description | string | null | (max length 500) Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description. |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseLevelInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No level with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Update a level",
"description": "Full replacement of the level's fields, same as its table twin.",
"tags": [
"Hospitality engine"
],
"operationId": "updateLevel",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Level id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"bookable": {
"type": "boolean",
"default": true,
"description": "false lets staff seat/assign here without ever offering it to a guest."
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseLevelInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No level with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/levels/{id}Delete a level (admin)
/api/levels/{id}Unlike a table, this never 409s: book_areas.level_id and book_tables.level_id are both ON DELETE SET NULL (migration 0030), so deleting a level always succeeds and simply ungroups whatever areas or tables sat directly on it.
Parameters
id*pathstringLevel id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is appointments cannot reach the hospitality engine at all.
No level with that id in this org.
Raw OpenAPI operation
{
"summary": "Delete a level (admin)",
"description": "Unlike a table, this never 409s: `book_areas.level_id` and `book_tables.level_id` are both ON DELETE SET NULL (migration 0030), so deleting a level always succeeds and simply ungroups whatever areas or tables sat directly on it.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteLevel",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Level id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `appointments` cannot reach the hospitality engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No level with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/levels/{id}/imageUpload a level photo
/api/levels/{id}/imageA line-for-line mirror of POST /api/services/{id}/image, stored in level-photos (migration 0110) instead. Not admin-gated, and not re-gated by planAllows('floors') either: that check belongs to CREATING a level; one that already exists already cleared it, same as PATCH/DELETE on this row.
Parameters
id*pathstringLevel id.
Request body multipart/form-data
The level photo. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| imageUrl* | string (uri) | Public URL with a |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No level with that id in this org. Nothing was uploaded; the ownership check runs first.
Raw OpenAPI operation
{
"summary": "Upload a level photo",
"description": "A line-for-line mirror of POST /api/services/{id}/image, stored in `level-photos` (migration 0110) instead. Not admin-gated, and not re-gated by `planAllows('floors')` either: that check belongs to CREATING a level; one that already exists already cleared it, same as PATCH/DELETE on this row.",
"tags": [
"Hospitality engine"
],
"operationId": "uploadLevelImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Level id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The level photo. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"imageUrl"
],
"properties": {
"ok": {
"const": true
},
"imageUrl": {
"type": "string",
"format": "uri",
"description": "Public URL with a `?v=<timestamp>` cache-buster; the storage path itself never changes."
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No level with that id in this org. Nothing was uploaded; the ownership check runs first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/levels/{id}/imageRemove a level photo
/api/levels/{id}/imageDeletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.
Parameters
id*pathstringLevel id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No level with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove a level photo",
"description": "Deletes the stored object and nulls `image_url`. Storage removal is best-effort and its failure does not fail the request.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteLevelImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Level id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No level with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/waitlistList guests waiting for a table
/api/waitlistThe unified Waitlist (migration 0232): entries with status open or notified, for today onward, soonest first. The three terminal statuses are deliberately absent; they are history, and this is a list a member acts on during service.
notified rows are included on purpose rather than filtered out. A member needs to see who has already been offered something, or the second table to come free that evening goes to the same guest.
source tells online (a guest joined THEMSELVES on the booking widget, for a future date that was full) from staff (a host added them at the door, right now). email is nullable since 0232: a staff entry commonly has none.
Responses
The list.
| Field | Type | Notes |
|---|---|---|
| entries* | object[] | |
| entries[].id | string (uuid) | |
| entries[].requestedDate | string (date) | |
| entries[].requestedFrom | string | null | |
| entries[].requestedTo | string | null | |
| entries[].partySize | integer | null | Null only for an appointments entry, which nothing writes yet; see 0044's one-subject constraint. |
| entries[].name | string | |
| entries[].email | string | null | Nullable since 0232; a |
| entries[].phone | string | null | |
| entries[].notes | string | null | |
| entries[].status | "open" | "notified" | |
| entries[].notifiedAt | string | null (date-time) | |
| entries[].source | "online" | "staff" | |
| entries[].quotedMinutes | integer | null | What a host TOLD a |
| entries[].createdAt | string (date-time) |
The read failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "List guests waiting for a table",
"description": "The unified Waitlist (migration 0232): entries with status `open` or `notified`, for today onward, soonest first. The three terminal statuses are deliberately absent; they are history, and this is a list a member acts on during service.\n\n`notified` rows are included on purpose rather than filtered out. A member needs to see who has already been offered something, or the second table to come free that evening goes to the same guest.\n\n`source` tells `online` (a guest joined THEMSELVES on the booking widget, for a future date that was full) from `staff` (a host added them at the door, right now). `email` is nullable since 0232: a `staff` entry commonly has none.",
"tags": [
"Hospitality engine"
],
"operationId": "listWaitlist",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"responses": {
"200": {
"description": "The list.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"entries"
],
"properties": {
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"requestedDate": {
"type": "string",
"format": "date"
},
"requestedFrom": {
"type": [
"string",
"null"
]
},
"requestedTo": {
"type": [
"string",
"null"
]
},
"partySize": {
"type": [
"integer",
"null"
],
"description": "Null only for an appointments entry, which nothing writes yet; see 0044's one-subject constraint."
},
"name": {
"type": "string"
},
"email": {
"type": [
"string",
"null"
],
"description": "Nullable since 0232; a `staff`-sourced entry commonly has none."
},
"phone": {
"type": [
"string",
"null"
]
},
"notes": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string",
"enum": [
"open",
"notified"
]
},
"notifiedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"source": {
"type": "string",
"enum": [
"online",
"staff"
]
},
"quotedMinutes": {
"type": [
"integer",
"null"
],
"description": "What a host TOLD a `staff` entry. Always null for `online`."
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
}
}
},
"400": {
"description": "The read failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/waitlistAdd a party waiting now at the door
/api/waitlistThe source: 'staff' half of the unified Waitlist (migration 0232): a host taking a name and a number at the door, the retired walk-in queue's own job folded into this one table. Any member, not admin-only: taking a name at the door is the job of whoever is on the door.
A phone number is REQUIRED, unlike a source: 'online' entry where an email carries the offer. The entire value here is the guest being somewhere else when the table frees, so an entry that cannot be texted is a person standing at the host stand, which is what this removes. The number is NOT required to be E.164: toE164 resolves whatever the host typed against the company's own country and timezone at send time, so "0412 345 678" works.
requestedDate is always the venue's own local today: there is no date picker at the door, only "now" (book_waitlist_entries_staff_shape).
There is deliberately no public route onto this half. A guest joins by standing in front of a host who reads the room and quotes a wait; a public one would let anyone put any number in any venue's queue from anywhere, and the venue would then text them.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| phone* | string | (min length 1, max length 40) Any format the host types. Resolved to E.164 against the company's country/timezone at send time, so "0412 345 678" is fine. |
| party_size* | integer | (min 1, max 30) |
| quoted_minutes | integer | (min 0, max 480) What the host TOLD them. Null is a real answer: a made-up number is worse than none. |
| notes | string | (max length 2000) |
Responses
Added.
| Field | Type |
|---|---|
| ok* | true |
| id* | string (uuid) |
A validation message written for the host to read, e.g. a missing contact number or a party size outside 1-30.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
The row could not be written. The constraint name is logged rather than echoed.
Raw OpenAPI operation
{
"summary": "Add a party waiting now at the door",
"description": "The `source: 'staff'` half of the unified Waitlist (migration 0232): a host taking a name and a number at the door, the retired walk-in queue's own job folded into this one table. Any member, not admin-only: taking a name at the door is the job of whoever is on the door.\n\n**A phone number is REQUIRED**, unlike a `source: 'online'` entry where an email carries the offer. The entire value here is the guest being somewhere else when the table frees, so an entry that cannot be texted is a person standing at the host stand, which is what this removes. The number is NOT required to be E.164: `toE164` resolves whatever the host typed against the company's own country and timezone at send time, so \"0412 345 678\" works.\n\n`requestedDate` is always the venue's own local today: there is no date picker at the door, only \"now\" (`book_waitlist_entries_staff_shape`).\n\nThere is deliberately **no public route** onto this half. A guest joins by standing in front of a host who reads the room and quotes a wait; a public one would let anyone put any number in any venue's queue from anywhere, and the venue would then text them.",
"tags": [
"Hospitality engine"
],
"x-engine": "hospitality",
"operationId": "addStaffWaitlistEntry",
"security": [
{
"sessionCookie": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"phone",
"party_size"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Any format the host types. Resolved to E.164 against the company's country/timezone at send time, so \"0412 345 678\" is fine."
},
"party_size": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"quoted_minutes": {
"type": "integer",
"minimum": 0,
"maximum": 480,
"nullable": true,
"description": "What the host TOLD them. Null is a real answer: a made-up number is worse than none."
},
"notes": {
"type": "string",
"maxLength": 2000,
"nullable": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "Added.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"id"
],
"properties": {
"ok": {
"const": true
},
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"400": {
"description": "A validation message written for the host to read, e.g. a missing contact number or a party size outside 1-30.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The row could not be written. The constraint name is logged rather than echoed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/waitlist/{id}/offerOffer a waitlisted guest a table
/api/waitlist/{id}/offerThe staff-triggered half of the waitlist, in one button. Emails (and texts, if the guest left an E.164 number and the org has SMS on) a link back into the booking flow prefilled with the date and party size, then sets the entry to notified with notifiedAt.
Send first, mark second, deliberately. Marking first would be the tidier transaction, but notified would then be a lie whenever the mail provider is down: the list would show the guest as offered, nobody would offer them again, and the table would go empty. Failing the other way produces at worst a duplicate "a table has come free" for a table that is in fact still free.
Every attempt is recorded in book_notification_log against waitlistEntryId with type waitlist_offer; the third subject 0044 added to that table. There is no dedupe index on this subject, so re-offering is legal and expected: the 7pm cancellation the guest did not answer is followed by an 8:30 one an hour later.
v1 is staff-triggered and there is no auto-broadcast. "First to reply Y wins" needs inbound SMS, which does not exist anywhere in this codebase.
Parameters
id*pathstringWaitlist entry id.
Responses
Offered.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| email* | "sent" | "failed" | "skipped" | |
| sms* | "sent" | "failed" | "skipped" |
|
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No waitlist entry with that id in this org.
The entry is converted, expired or cancelled; offering one would mail a guest who has already booked or already said no.
Nothing reached the guest, so the entry is left where it was and can be retried. The message says which: guest notifications switched off for this business, or a delivery failure.
Raw OpenAPI operation
{
"summary": "Offer a waitlisted guest a table",
"description": "The staff-triggered half of the waitlist, in one button. Emails (and texts, if the guest left an E.164 number and the org has SMS on) a link back into the booking flow prefilled with the date and party size, then sets the entry to `notified` with `notifiedAt`.\n\n**Send first, mark second**, deliberately. Marking first would be the tidier transaction, but `notified` would then be a lie whenever the mail provider is down: the list would show the guest as offered, nobody would offer them again, and the table would go empty. Failing the other way produces at worst a duplicate \"a table has come free\" for a table that is in fact still free.\n\nEvery attempt is recorded in `book_notification_log` against `waitlistEntryId` with type `waitlist_offer`; the third subject 0044 added to that table. There is **no dedupe index** on this subject, so re-offering is legal and expected: the 7pm cancellation the guest did not answer is followed by an 8:30 one an hour later.\n\n**v1 is staff-triggered and there is no auto-broadcast.** \"First to reply Y wins\" needs inbound SMS, which does not exist anywhere in this codebase.",
"tags": [
"Hospitality engine"
],
"operationId": "offerWaitlistTable",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Waitlist entry id."
}
],
"responses": {
"200": {
"description": "Offered.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"email",
"sms"
],
"properties": {
"ok": {
"const": true
},
"email": {
"type": "string",
"enum": [
"sent",
"failed",
"skipped"
]
},
"sms": {
"type": "string",
"enum": [
"sent",
"failed",
"skipped"
],
"description": "`skipped` when the guest left no phone number or the org has SMS off."
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No waitlist entry with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The entry is converted, expired or cancelled; offering one would mail a guest who has already booked or already said no.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Nothing reached the guest, so the entry is left where it was and can be retried. The message says which: guest notifications switched off for this business, or a delivery failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/waitlist/{id}/table-readyText a "waiting now" guest that their table is ready
/api/waitlist/{id}/table-readyThe source: 'staff' mirror of POST .../offer: the same member-scoped tenant check, refusal shape and send-then-mark ordering, but texts via SMS rather than mailing, since a source: 'staff' entry carries a phone and commonly no email at all. Refuses a source: 'online' entry (409): it has no guaranteed phone number, and "your table is ready" makes no sense for a future date nobody has actually booked yet.
Send first, mark second, deliberately: marking first would make notified a lie whenever the SMS provider is down. Logged into book_notification_log against waitlistEntryId with type table_ready.
Parameters
id*pathstringWaitlist entry id.
Responses
Texted.
| Field | Type |
|---|---|
| ok* | true |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No waitlist entry with that id in this org.
The entry is source: 'online' (no route into this action), or is converted/expired/cancelled already.
Nothing reached the guest, so the entry is left where it was. The message says why: SMS not activated on this plan, or a delivery failure; either way, go and get them instead.
Raw OpenAPI operation
{
"summary": "Text a \"waiting now\" guest that their table is ready",
"description": "The `source: 'staff'` mirror of POST .../offer: the same member-scoped tenant check, refusal shape and send-then-mark ordering, but texts via SMS rather than mailing, since a `source: 'staff'` entry carries a phone and commonly no email at all. Refuses a `source: 'online'` entry (409): it has no guaranteed phone number, and \"your table is ready\" makes no sense for a future date nobody has actually booked yet.\n\n**Send first, mark second**, deliberately: marking first would make `notified` a lie whenever the SMS provider is down. Logged into `book_notification_log` against `waitlistEntryId` with type `table_ready`.",
"tags": [
"Hospitality engine"
],
"operationId": "sendWaitlistTableReady",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Waitlist entry id."
}
],
"responses": {
"200": {
"description": "Texted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No waitlist entry with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The entry is `source: 'online'` (no route into this action), or is converted/expired/cancelled already.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Nothing reached the guest, so the entry is left where it was. The message says why: SMS not activated on this plan, or a delivery failure; either way, go and get them instead.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/waitlist/{id}Close out a waitlist entry
/api/waitlist/{id}Sets status to converted, expired or cancelled.
`notified` is deliberately NOT settable here, and it is the only interesting rule on this route. That status is a claim about a message that left the building; setting it from a plain PATCH would let the list show an offer that was never sent, with the guest waiting at home for an email nobody dispatched. Only POST ./offer writes it, after the send.
The three end states are terminal; a converted entry cannot be dragged back to open and offered again to a guest who is already booked. No minRole: clearing a waitlist is ordinary floor work.
Parameters
id*pathstringWaitlist entry id.
Request body application/json
| Field | Type |
|---|---|
| status* | "converted" | "expired" | "cancelled" |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
An unknown status, or one this route refuses (open, notified).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No waitlist entry with that id in this org, or a malformed uuid.
The entry is already in a terminal status.
Raw OpenAPI operation
{
"summary": "Close out a waitlist entry",
"description": "Sets `status` to `converted`, `expired` or `cancelled`.\n\n**`notified` is deliberately NOT settable here**, and it is the only interesting rule on this route. That status is a claim about a message that left the building; setting it from a plain PATCH would let the list show an offer that was never sent, with the guest waiting at home for an email nobody dispatched. Only POST ./offer writes it, after the send.\n\nThe three end states are terminal; a `converted` entry cannot be dragged back to `open` and offered again to a guest who is already booked. No minRole: clearing a waitlist is ordinary floor work.",
"tags": [
"Hospitality engine"
],
"operationId": "updateWaitlistEntry",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Waitlist entry id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"status"
],
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"enum": [
"converted",
"expired",
"cancelled"
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unknown status, or one this route refuses (`open`, `notified`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No waitlist entry with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The entry is already in a terminal status.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/enquiries/{id}/convertPencil in or book a big-group enquiry
/api/enquiries/{id}/convertCreates a reservation from the enquiry, links the two (book_group_enquiries.reservation_id, migration 0038), and moves the enquiry along.
Its own route rather than the client calling POST /api/reservations and then PATCHing the enquiry: those are two writes with a gap, and a failure in the gap leaves a booking nobody can reach from the thread it came from. The enquiry is claimed with where reservation_id is null, so two staff converting at once produces one booking and a 409; the partial unique index is the backstop, not the button being hidden.
Two stages, chosen by `status`. A big group is agreed over days of back-and-forth, and the thing a venue needs on message two is not a confirmed booking but the tables taken off the market while the conversation happens.
pendingpencils it in. It holds its tables against the exclusion constraint exactly like a confirmed sitting (0011 releases onlycancelledandno_show), sets the enquiry toopenrather thanwon, and posts NOTHING into the thread, because the guest has not been promised anything yet. Undo it with the DELETE below, or finish it with POST /confirm.confirmed(the default) is the one-shot, for a group that agreed everything in one reply. Sets the enquiry towonand posts the "Booked" line.
`tableIds` is ordered and may hold up to 20. The first becomes the reservation's own table_id; the rest are written to book_reservation_tables (0035) so the whole combination is inside the double-booking constraint. This is the normal case here, not an edge one: a party over the venue's online cap rarely fits on a single table, and a venue will hand a big group a whole room. Twenty rather than the allocator's four: that four bounds a combinatorial subset search on an anonymous request, while here a signed-in host names the tables and each one costs a single insert. Seat limits (staffSeatLimitEnforced, 0036) are measured across the whole combination, not the primary alone.
The guest is resolved into a client by email, so a returning one lands on the row they already have. partySize may be corrected on the way through, and is bounded by the same 500 the public enquiry form accepts, not by the 30 that bounds the public booking path. Otherwise an enquiry too big to book online would be too big to book at all.
Parameters
id*pathstringEnquiry id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| startsAt* | string (date-time) | |
| turnMinutes* | integer | (min 5, max 600) |
| partySize | integer | (min 1, max 500) Defaults to what the guest asked for. |
| status | "pending" | "confirmed" | Defaults to |
| tableIds | string (uuid)[] | (max items 20) Ordered; the first is the primary table. Empty is legal, a capacity-only booking that holds no tables. |
| tableId | string | null (uuid) | The single-table form, still accepted. Ignored when |
| notes | string | null | (max length 1000) Defaults to the message the guest sent with the enquiry. |
Responses
Pencilled in, or booked.
| Field | Type |
|---|---|
| ok* | true |
| reservationId* | string (uuid) |
| status* | "pending" | "confirmed" |
A validation message, or the tables cannot seat the party while the venue holds staff to seat limits.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No enquiry with that id in this org.
It already has a booking, one of those tables is taken at that time, or another member converted it first.
The reservation insert failed for some other reason.
Raw OpenAPI operation
{
"summary": "Pencil in or book a big-group enquiry",
"description": "Creates a reservation from the enquiry, links the two (`book_group_enquiries.reservation_id`, migration 0038), and moves the enquiry along.\n\nIts own route rather than the client calling POST /api/reservations and then PATCHing the enquiry: those are two writes with a gap, and a failure in the gap leaves a booking nobody can reach from the thread it came from. The enquiry is claimed with `where reservation_id is null`, so two staff converting at once produces one booking and a 409; the partial unique index is the backstop, not the button being hidden.\n\n**Two stages, chosen by `status`.** A big group is agreed over days of back-and-forth, and the thing a venue needs on message two is not a confirmed booking but the tables taken off the market while the conversation happens.\n\n- `pending` pencils it in. It holds its tables against the exclusion constraint exactly like a confirmed sitting (0011 releases only `cancelled` and `no_show`), sets the enquiry to `open` rather than `won`, and posts NOTHING into the thread, because the guest has not been promised anything yet. Undo it with the DELETE below, or finish it with POST /confirm.\n- `confirmed` (the default) is the one-shot, for a group that agreed everything in one reply. Sets the enquiry to `won` and posts the \"Booked\" line.\n\n**`tableIds` is ordered and may hold up to 20.** The first becomes the reservation's own `table_id`; the rest are written to `book_reservation_tables` (0035) so the whole combination is inside the double-booking constraint. This is the normal case here, not an edge one: a party over the venue's online cap rarely fits on a single table, and a venue will hand a big group a whole room. Twenty rather than the allocator's four: that four bounds a combinatorial subset search on an anonymous request, while here a signed-in host names the tables and each one costs a single insert. Seat limits (`staffSeatLimitEnforced`, 0036) are measured across the whole combination, not the primary alone.\n\nThe guest is resolved into a client by email, so a returning one lands on the row they already have. `partySize` may be corrected on the way through, and is bounded by the same 500 the public enquiry form accepts, not by the 30 that bounds the public booking path. Otherwise an enquiry too big to book online would be too big to book at all.",
"tags": [
"Hospitality engine"
],
"operationId": "convertGroupEnquiry",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Enquiry id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"startsAt",
"turnMinutes"
],
"additionalProperties": false,
"properties": {
"startsAt": {
"type": "string",
"format": "date-time"
},
"turnMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Defaults to what the guest asked for."
},
"status": {
"type": "string",
"enum": [
"pending",
"confirmed"
],
"description": "Defaults to `confirmed`. `pending` pencils the tables in without telling the guest anything."
},
"tableIds": {
"type": "array",
"maxItems": 20,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Ordered; the first is the primary table. Empty is legal, a capacity-only booking that holds no tables."
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "The single-table form, still accepted. Ignored when `tableIds` is present."
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000,
"description": "Defaults to the message the guest sent with the enquiry."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Pencilled in, or booked.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"reservationId",
"status"
],
"properties": {
"ok": {
"const": true
},
"reservationId": {
"type": "string",
"format": "uuid"
},
"status": {
"type": "string",
"enum": [
"pending",
"confirmed"
]
}
}
}
}
}
},
"400": {
"description": "A validation message, or the tables cannot seat the party while the venue holds staff to seat limits.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No enquiry with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "It already has a booking, one of those tables is taken at that time, or another member converted it first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The reservation insert failed for some other reason.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/enquiries/{id}/convertRelease a pencilled-in hold
/api/enquiries/{id}/convertDeletes the reservation this enquiry was pencilled in on and hands the enquiry back as one with no live booking, ready to be pencilled in somewhere else. The group went elsewhere, or the date moved.
Delete, not cancel, and that is the point of it existing. Cancelling enqueues reservation_cancelled, which emails the guest that their booking is off, for a hold they were never told about. Deleting frees the tables silently, cascades book_reservation_tables (0035), and lets 0039's on delete set null (reservation_id) clear the link.
Refuses anything a guest has been told about or has paid for: only a pending or already-cancelled reservation with no payment intent. A confirmed booking is cancelled from the Reservations screen, where the guest is notified and the deposit is resolved.
Parameters
id*pathstringEnquiry id.
Responses
Released. Also the answer when the reservation had already gone.
| Field | Type |
|---|---|
| ok* | true |
The delete failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No enquiry with that id in this org.
Nothing is pencilled in, or the booking is confirmed or carries a payment.
Raw OpenAPI operation
{
"summary": "Release a pencilled-in hold",
"description": "Deletes the reservation this enquiry was pencilled in on and hands the enquiry back as one with no live booking, ready to be pencilled in somewhere else. The group went elsewhere, or the date moved.\n\n**Delete, not cancel, and that is the point of it existing.** Cancelling enqueues `reservation_cancelled`, which emails the guest that their booking is off, for a hold they were never told about. Deleting frees the tables silently, cascades `book_reservation_tables` (0035), and lets 0039's `on delete set null (reservation_id)` clear the link.\n\nRefuses anything a guest has been told about or has paid for: only a `pending` or already-`cancelled` reservation with no payment intent. A confirmed booking is cancelled from the Reservations screen, where the guest is notified and the deposit is resolved.",
"tags": [
"Hospitality engine"
],
"operationId": "releaseGroupEnquiryHold",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Enquiry id."
}
],
"responses": {
"200": {
"description": "Released. Also the answer when the reservation had already gone.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "The delete failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No enquiry with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Nothing is pencilled in, or the booking is confirmed or carries a payment.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/enquiries/{id}/confirmConfirm a pencilled-in big-group booking
/api/enquiries/{id}/confirmThe second half of the two-stage conversion: the hold becomes a booking the venue has actually promised. Flips the reservation from pending to confirmed (the trigger in 0035 carries every companion table with it), sets the enquiry to won, and posts the "Booked" line into the thread.
Three writes that must agree, which is why this is one route and not three fetches from the browser. A client could PATCH the reservation and then PATCH the enquiry; what it could not do is guarantee the second happens, and the failure that leaves behind is a confirmed booking whose conversation never mentions it, the exact thing this feature exists to prevent.
Compare-and-set on where status = 'pending', so two hosts hitting Confirm at once produce one confirmation and one 409, not two "Booked" lines.
No notification is sent, deliberately, matching the one-shot conversion. Confirming a big group ends a conversation the staff are already having in this thread; the reply they are about to type is the confirmation.
Parameters
id*pathstringEnquiry id.
Responses
Booked.
| Field | Type |
|---|---|
| ok* | true |
| reservationId* | string (uuid) |
The update failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No enquiry with that id in this org, or its booking has gone.
Nothing is pencilled in, it is not pending any more, the tables are no longer free, or another member confirmed it first.
Raw OpenAPI operation
{
"summary": "Confirm a pencilled-in big-group booking",
"description": "The second half of the two-stage conversion: the hold becomes a booking the venue has actually promised. Flips the reservation from `pending` to `confirmed` (the trigger in 0035 carries every companion table with it), sets the enquiry to `won`, and posts the \"Booked\" line into the thread.\n\nThree writes that must agree, which is why this is one route and not three fetches from the browser. A client could PATCH the reservation and then PATCH the enquiry; what it could not do is guarantee the second happens, and the failure that leaves behind is a confirmed booking whose conversation never mentions it, the exact thing this feature exists to prevent.\n\nCompare-and-set on `where status = 'pending'`, so two hosts hitting Confirm at once produce one confirmation and one 409, not two \"Booked\" lines.\n\n**No notification is sent, deliberately**, matching the one-shot conversion. Confirming a big group ends a conversation the staff are already having in this thread; the reply they are about to type is the confirmation.",
"tags": [
"Hospitality engine"
],
"operationId": "confirmGroupEnquiryBooking",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Enquiry id."
}
],
"responses": {
"200": {
"description": "Booked.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"reservationId"
],
"properties": {
"ok": {
"const": true
},
"reservationId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"400": {
"description": "The update failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No enquiry with that id in this org, or its booking has gone.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Nothing is pencilled in, it is not pending any more, the tables are no longer free, or another member confirmed it first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/enquiries/{id}Move a big-group enquiry along
/api/enquiries/{id}Sets status to new, open, won or lost. The ONLY field staff can change: everything else on the row is what the guest sent, and an enquiry is a record of what was asked for, not a form the venue edits.
No minRole; dealing with an enquiry is ordinary floor work, the same reasoning that lets any member reply to a booking message.
Parameters
id*pathstringEnquiry id.
Request body application/json
| Field | Type |
|---|---|
| status* | "new" | "open" | "won" | "lost" |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
An unknown status.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No enquiry with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Move a big-group enquiry along",
"description": "Sets `status` to new, open, won or lost. The ONLY field staff can change: everything else on the row is what the guest sent, and an enquiry is a record of what was asked for, not a form the venue edits.\n\nNo minRole; dealing with an enquiry is ordinary floor work, the same reasoning that lets any member reply to a booking message.",
"tags": [
"Hospitality engine"
],
"operationId": "updateGroupEnquiry",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Enquiry id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"status"
],
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"enum": [
"new",
"open",
"won",
"lost"
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unknown status.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No enquiry with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/link-groupsCreate a link group
/api/link-groupsA named run of tables the venue can physically push together to seat a party too big for any single table (migration 0035). Tables join a group through linkGroupId on the table itself, so this endpoint only names the group.
Two behaviours worth knowing before turning this on. Grouped tables are allocated LAST: when a small party fits either a grouped table or an ungrouped one of the same size, the ungrouped one wins, so the run stays whole for a big booking. And a combination never crosses a group and never exceeds 4 tables; the cap bounds a subset search reachable from the public availability endpoint.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| linkGroup* | object |
| linkGroup.id | string (uuid) |
A validation message from parseLinkGroupInput, or an unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Table combining is Venue Pro+ only (src/lib/plan.ts, table_combining).
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create a link group",
"description": "A named run of tables the venue can physically push together to seat a party too big for any single table (migration 0035). Tables join a group through `linkGroupId` on the table itself, so this endpoint only names the group.\n\nTwo behaviours worth knowing before turning this on. **Grouped tables are allocated LAST**: when a small party fits either a grouped table or an ungrouped one of the same size, the ungrouped one wins, so the run stays whole for a big booking. And **a combination never crosses a group** and never exceeds 4 tables; the cap bounds a subset search reachable from the public availability endpoint.",
"tags": [
"Hospitality engine"
],
"operationId": "createLinkGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"linkGroup"
],
"properties": {
"ok": {
"const": true
},
"linkGroup": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseLinkGroupInput, or an unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "Table combining is Venue Pro+ only (src/lib/plan.ts, `table_combining`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/link-groups/{id}Rename a link group
/api/link-groups/{id}A group carries nothing but a name, so this is the whole of it. Membership is changed by editing the tables.
Parameters
id*pathstringLink group id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseLinkGroupInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No link group with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Rename a link group",
"description": "A group carries nothing but a name, so this is the whole of it. Membership is changed by editing the tables.",
"tags": [
"Hospitality engine"
],
"operationId": "updateLinkGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Link group id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseLinkGroupInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No link group with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/link-groups/{id}Delete a link group (admin)
/api/link-groups/{id}Never 409s, for the same reason deleting a level does not: book_tables.link_group_id is ON DELETE SET NULL (migration 0035), so this un-links its tables and deletes nothing else. Past bookings are unaffected; a reservation holds TABLES (book_reservation_tables), never the group they were combined through.
Parameters
id*pathstringLink group id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is appointments cannot reach the hospitality engine at all.
No link group with that id in this org.
Raw OpenAPI operation
{
"summary": "Delete a link group (admin)",
"description": "Never 409s, for the same reason deleting a level does not: `book_tables.link_group_id` is ON DELETE SET NULL (migration 0035), so this un-links its tables and deletes nothing else. Past bookings are unaffected; a reservation holds TABLES (`book_reservation_tables`), never the group they were combined through.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteLinkGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Link group id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `appointments` cannot reach the hospitality engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No link group with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/locksArea locks touching a date
/api/locksEvery lock overlapping one UTC calendar day, whatever tier it names. A lock running 22:00 into tomorrow's 02:00 is returned for both days; a host looking at either one needs to see it.
The overlap test is the same half-open comparison the allocator applies to instants and the public booking routes apply in SQL, so this screen and the booking page can never disagree about whether a lock covers a day.
Parameters
date*querystringUTC calendar date,
YYYY-MM-DD.
Responses
The locks. Exactly one of levelId/areaId/tableId is non-null on each; the database enforces it.
| Field | Type |
|---|---|
| locks* | object[] |
| locks[].id | string (uuid) |
| locks[].levelId | string | null (uuid) |
| locks[].areaId | string | null (uuid) |
| locks[].tableId | string | null (uuid) |
| locks[].startsAt | string (date-time) |
| locks[].endsAt | string (date-time) |
| locks[].reason | string | null |
Missing or malformed date.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Area locks touching a date",
"description": "Every lock overlapping one UTC calendar day, whatever tier it names. A lock running 22:00 into tomorrow's 02:00 is returned for **both** days; a host looking at either one needs to see it.\n\nThe overlap test is the same half-open comparison the allocator applies to instants and the public booking routes apply in SQL, so this screen and the booking page can never disagree about whether a lock covers a day.",
"tags": [
"Hospitality engine"
],
"operationId": "listAreaLocks",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "date",
"in": "query",
"required": true,
"schema": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"description": "UTC calendar date, `YYYY-MM-DD`."
}
],
"responses": {
"200": {
"description": "The locks. Exactly one of `levelId`/`areaId`/`tableId` is non-null on each; the database enforces it.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"locks"
],
"properties": {
"locks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"endsAt": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": [
"string",
"null"
]
}
}
}
}
}
}
}
}
},
"400": {
"description": "Missing or malformed `date`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/locksLock a floor, room or table
/api/locksHolds part of the room back from booking; a private function on the mezzanine, a table kept for the owner's guests; without shutting the venue.
This is not a closure. A book_blocked_periods row with no provider (0011) takes the WHOLE venue off sale; this takes one floor, room or table. Migration 0047 keeps them in separate tables deliberately: a lock necessarily has no provider, so had it been a fourth nullable column on book_blocked_periods, the four public routes that spell "fetch the venue closures" as provider_id is null would have started shutting the venue over one held two-top.
Enforced, not decorative. Locks reach the allocator as occupancy, so all four public reservation paths honour them: the availability grid, the checkout POST, the table picker and guest self-service reschedule. A date left with nothing bookable by locks alone reports reason: "locked" on the availability endpoint and is never offered a waitlist, no cancellation frees a table the venue deliberately held back.
Staff, not admin, matching every sibling in this hierarchy. Deleting is the admin half.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| date* | string | (pattern ^\d{4}-\d{2}-\d{2}$) The day being locked, as a UTC calendar date. Today or later; a lock over time that has already passed cannot stop a booking. At most 365 days ahead. |
| scope* | "level" | "area" | "table" | Which tier of the floor hierarchy |
| id* | string (uuid) | The level, area or table being locked. A foreign org's id is rejected by a composite foreign key (400), which matters more here than elsewhere, since a cross-tenant lock would take a competitor's tables off sale. |
| fullDay | boolean | (default true) Midnight to midnight. This is also what an omitted |
| from | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) Start of a part-day lock. Send with |
| to | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) End of a part-day lock. Earlier than `from` means the lock runs past midnight: a bar holding its mezzanine 22:00-02:00. Equal to |
| reason | string | null | (max length 200) What the floor screen shows on the badge; "Smith wedding", "Deep clean". |
Responses
Locked.
| Field | Type |
|---|---|
| ok* | true |
| lock* | object |
| lock.id | string (uuid) |
A validation message from parseAreaLockInput (past date, over 365 days ahead, unknown scope, malformed id, bad times, zero-length window), or a Postgres error; most usefully the composite foreign key refusing a subject that belongs to another organization.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Lock a floor, room or table",
"description": "Holds part of the room back from booking; a private function on the mezzanine, a table kept for the owner's guests; without shutting the venue.\n\n**This is not a closure.** A `book_blocked_periods` row with no provider (0011) takes the WHOLE venue off sale; this takes one floor, room or table. Migration 0047 keeps them in separate tables deliberately: a lock necessarily has no provider, so had it been a fourth nullable column on `book_blocked_periods`, the four public routes that spell \"fetch the venue closures\" as `provider_id is null` would have started shutting the venue over one held two-top.\n\n**Enforced, not decorative.** Locks reach the allocator as occupancy, so all four public reservation paths honour them: the availability grid, the checkout POST, the table picker and guest self-service reschedule. A date left with nothing bookable by locks alone reports `reason: \"locked\"` on the availability endpoint and is **never** offered a waitlist, no cancellation frees a table the venue deliberately held back.\n\nStaff, not admin, matching every sibling in this hierarchy. Deleting is the admin half.",
"tags": [
"Hospitality engine"
],
"operationId": "createAreaLock",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"date",
"scope",
"id"
],
"additionalProperties": false,
"properties": {
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": "The day being locked, as a UTC calendar date. Today or later; a lock over time that has already passed cannot stop a booking. At most 365 days ahead."
},
"scope": {
"type": "string",
"enum": [
"level",
"area",
"table"
],
"description": "Which tier of the floor hierarchy `id` names."
},
"id": {
"type": "string",
"format": "uuid",
"description": "The level, area or table being locked. A foreign org's id is rejected by a composite foreign key (400), which matters more here than elsewhere, since a cross-tenant lock would take a competitor's tables off sale."
},
"fullDay": {
"type": "boolean",
"default": true,
"description": "Midnight to midnight. This is also what an omitted `from`/`to` pair means, so a half-filled form locks the whole day rather than a random window."
},
"from": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "Start of a part-day lock. Send with `to`."
},
"to": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "End of a part-day lock. **Earlier than `from` means the lock runs past midnight**: a bar holding its mezzanine 22:00-02:00. Equal to `from` is rejected: the lock would cover no time."
},
"reason": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "What the floor screen shows on the badge; \"Smith wedding\", \"Deep clean\"."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Locked.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"lock"
],
"properties": {
"ok": {
"const": true
},
"lock": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from `parseAreaLockInput` (past date, over 365 days ahead, unknown scope, malformed id, bad times, zero-length window), or a Postgres error; most usefully the composite foreign key refusing a subject that belongs to another organization.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/locks/{id}Remove an area lock (admin)
/api/locks/{id}Puts the floor, room or table back on sale. Admin where creating one is staff, matching book_tables/book_areas/book_levels/link groups; it reads backwards until you notice which direction is dangerous: adding a lock is protective and reversible, removing one is what lets a booking land on top of the function it was holding.
There is no PATCH. 0047 grants no UPDATE and writes no update policy: a lock is two instants and a subject with nothing derived from it, so "move it an hour" is a delete and an insert either way.
Parameters
id*pathstringArea lock id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is appointments cannot reach the hospitality engine at all.
No lock with that id in this organization. Reported from the deleted row count rather than trusted: PostgREST answers a zero-row write with success, so a delete RLS silently filtered to nothing would otherwise report that it worked.
Raw OpenAPI operation
{
"summary": "Remove an area lock (admin)",
"description": "Puts the floor, room or table back on sale. Admin where creating one is staff, matching `book_tables`/`book_areas`/`book_levels`/link groups; it reads backwards until you notice which direction is dangerous: adding a lock is protective and reversible, removing one is what lets a booking land on top of the function it was holding.\n\nThere is no PATCH. 0047 grants no UPDATE and writes no update policy: a lock is two instants and a subject with nothing derived from it, so \"move it an hour\" is a delete and an insert either way.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteAreaLock",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Area lock id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `appointments` cannot reach the hospitality engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No lock with that id in this organization. Reported from the deleted row count rather than trusted: PostgREST answers a zero-row write with success, so a delete RLS silently filtered to nothing would otherwise report that it worked.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/hospitality/floor/service-notesRead the floor's shared note for a date
/api/hospitality/floor/service-notesThe day's shared, guest-hidden note about running the floor (migration 0230): one row per company+date when servicePeriodId is omitted, or one specific service's own note alongside it when given. Every member reads the same row; "private" means hidden from guests and the public booking page, not from teammates.
Returns an empty note, never a 404, when nothing has been written yet; the absence of a note is not an error.
Parameters
date*querystringYYYY-MM-DD.
servicePeriodIdquerystring (uuid)optionalOmit for the day note. A specific service period id for that service's own note, offered alongside the day note.
Responses
The note, or an empty one if nothing has been written for this date/service yet.
| Field | Type |
|---|---|
| note* | string |
| updatedByEmail* | string | null |
| updatedAt* | string | null (date-time) |
Missing or malformed date, or a malformed servicePeriodId.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Read the floor's shared note for a date",
"description": "The day's shared, guest-hidden note about running the floor (migration 0230): one row per company+date when `servicePeriodId` is omitted, or one specific service's own note alongside it when given. Every member reads the same row; \"private\" means hidden from guests and the public booking page, not from teammates.\n\nReturns an empty note, never a 404, when nothing has been written yet; the absence of a note is not an error.",
"tags": [
"Hospitality engine"
],
"operationId": "getServiceNote",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "date",
"in": "query",
"required": true,
"schema": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"description": "YYYY-MM-DD."
},
{
"name": "servicePeriodId",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Omit for the day note. A specific service period id for that service's own note, offered alongside the day note."
}
],
"responses": {
"200": {
"description": "The note, or an empty one if nothing has been written for this date/service yet.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"note",
"updatedByEmail",
"updatedAt"
],
"properties": {
"note": {
"type": "string"
},
"updatedByEmail": {
"type": [
"string",
"null"
]
},
"updatedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
}
}
}
}
}
},
"400": {
"description": "Missing or malformed `date`, or a malformed `servicePeriodId`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}put/api/hospitality/floor/service-notesSave the floor's shared note for a date
/api/hospitality/floor/service-notesUpserts through book_upsert_service_note() rather than PostgREST's own upsert(), because the one-day-note/one-service-note invariant is two PARTIAL unique indexes with different predicates, not one plain unique constraint on_conflict's column list could target. companyId and updatedBy are derived server-side from the caller's own session, never taken as request fields.
An empty string is how a note is cleared; there is no DELETE.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| date* | string | (pattern ^\d{4}-\d{2}-\d{2}$) |
| servicePeriodId | string | null (uuid) | Omit or null for the day note. |
| note | string | (max length 2000) |
Responses
Saved.
| Field | Type |
|---|---|
| note* | string |
| updatedByEmail* | string | null |
| updatedAt* | string | null (date-time) |
Missing or malformed date, a malformed servicePeriodId, or a Postgres error from the RPC.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Save the floor's shared note for a date",
"description": "Upserts through `book_upsert_service_note()` rather than PostgREST's own upsert(), because the one-day-note/one-service-note invariant is two PARTIAL unique indexes with different predicates, not one plain unique constraint `on_conflict`'s column list could target. `companyId` and `updatedBy` are derived server-side from the caller's own session, never taken as request fields.\n\nAn empty string is how a note is cleared; there is no DELETE.",
"tags": [
"Hospitality engine"
],
"operationId": "putServiceNote",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"date"
],
"properties": {
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"servicePeriodId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Omit or null for the day note."
},
"note": {
"type": "string",
"maxLength": 2000
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"note",
"updatedByEmail",
"updatedAt"
],
"properties": {
"note": {
"type": "string"
},
"updatedByEmail": {
"type": [
"string",
"null"
]
},
"updatedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
}
}
}
}
}
},
"400": {
"description": "Missing or malformed `date`, a malformed `servicePeriodId`, or a Postgres error from the RPC.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/areasCreate an area (room)
/api/areasThe product's first real area-creation API; book_areas has existed since migration 0011, but until 0030 the only write path was assigning an EXISTING area to a table. Optionally nests under a level via levelId. Lands at the end of the list (sort_order = max+1, migration 0111) and busts the cached public booking config, matching services/groups.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| levelId | string | null (uuid) | Nests this room under a level. A foreign org's level id is rejected by a composite foreign key (400). Null (the default) is an ordinary flat area, unchanged from before levels existed. |
| bookable | boolean | (default true) false lets staff seat/assign here without ever offering it to a guest. |
| description | string | null | (max length 500) Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description. |
Responses
Created.
| Field | Type |
|---|---|
| ok* | true |
| area* | object |
| area.id | string (uuid) |
A validation message from parseAreaInput, or a Postgres error (including a foreign levelId refused by the composite foreign key).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Create an area (room)",
"description": "The product's first real area-creation API; `book_areas` has existed since migration 0011, but until 0030 the only write path was assigning an EXISTING area to a table. Optionally nests under a level via `levelId`. Lands at the end of the list (`sort_order = max+1`, migration 0111) and busts the cached public booking config, matching services/groups.",
"tags": [
"Hospitality engine"
],
"operationId": "createArea",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Nests this room under a level. A foreign org's level id is rejected by a composite foreign key (400). Null (the default) is an ordinary flat area, unchanged from before levels existed."
},
"bookable": {
"type": "boolean",
"default": true,
"description": "false lets staff seat/assign here without ever offering it to a guest."
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"area"
],
"properties": {
"ok": {
"const": true
},
"area": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseAreaInput, or a Postgres error (including a foreign `levelId` refused by the composite foreign key).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/areasReorder areas (rooms)
/api/areasThe body is every area in its new order, and array position IS the new sort_order (migration 0111): byte-for-byte the same shape and write pattern (a loop of individual .update({sort_order}).eq('id') calls, not a single statement) as PATCH /api/service-groups. Not admin-gated: reordering a room is not more sensitive than renaming one, the same permission level as PATCH /api/areas/{id}.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| ids* | string (uuid)[] | (min items 1, max items 100) Every area (or level) id in this org, in the order they should appear. |
Responses
Reordered.
| Field | Type |
|---|---|
| ok* | true |
ids missing, empty, over 100 entries, or containing a non-uuid.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Reorder areas (rooms)",
"description": "The body is every area in its new order, and array position IS the new sort_order (migration 0111): byte-for-byte the same shape and write pattern (a loop of individual `.update({sort_order}).eq('id')` calls, not a single statement) as PATCH /api/service-groups. Not admin-gated: reordering a room is not more sensitive than renaming one, the same permission level as PATCH /api/areas/{id}.",
"tags": [
"Hospitality engine"
],
"operationId": "reorderAreas",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ids"
],
"additionalProperties": false,
"properties": {
"ids": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Every area (or level) id in this org, in the order they should appear."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Reordered.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "`ids` missing, empty, over 100 entries, or containing a non-uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/areas/{id}Update an area
/api/areas/{id}Full replacement of the area's fields, same as its table twin.
Parameters
id*pathstringArea id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
| levelId | string | null (uuid) | Nests this room under a level. A foreign org's level id is rejected by a composite foreign key (400). Null (the default) is an ordinary flat area, unchanged from before levels existed. |
| bookable | boolean | (default true) false lets staff seat/assign here without ever offering it to a guest. |
| description | string | null | (max length 500) Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description. |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseAreaInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No area with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Update an area",
"description": "Full replacement of the area's fields, same as its table twin.",
"tags": [
"Hospitality engine"
],
"operationId": "updateArea",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Area id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Nests this room under a level. A foreign org's level id is rejected by a composite foreign key (400). Null (the default) is an ordinary flat area, unchanged from before levels existed."
},
"bookable": {
"type": "boolean",
"default": true,
"description": "false lets staff seat/assign here without ever offering it to a guest."
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseAreaInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No area with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/areas/{id}Delete an area (admin)
/api/areas/{id}book_tables.area_id is ON DELETE SET NULL (migration 0011), so deleting an area never 409s; it just ungroups whatever tables sat inside it, same as a level delete.
Parameters
id*pathstringArea id.
Responses
Deleted.
| Field | Type |
|---|---|
| ok* | true |
An unhandled Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Covers three refusals: a staff member (this action is admin-only), an account with no organization or not on the roster, and the engine boundary: an org whose business_type is appointments cannot reach the hospitality engine at all.
No area with that id in this org.
Raw OpenAPI operation
{
"summary": "Delete an area (admin)",
"description": "`book_tables.area_id` is ON DELETE SET NULL (migration 0011), so deleting an area never 409s; it just ungroups whatever tables sat inside it, same as a level delete.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteArea",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Area id."
}
],
"responses": {
"200": {
"description": "Deleted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unhandled Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. Covers three refusals: a `staff` member (this action is admin-only), an account with no organization or not on the roster, and **the engine boundary**: an org whose `business_type` is `appointments` cannot reach the hospitality engine at all.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No area with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/areas/{id}/imageUpload an area (room) photo
/api/areas/{id}/imageA line-for-line mirror of POST /api/services/{id}/image, stored in area-photos (migration 0110) instead. Not admin-gated: a photo is part of editing the area, same permission level as PATCH /api/areas/{id}.
Parameters
id*pathstringArea id.
Request body multipart/form-data
The area photo. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| imageUrl* | string (uri) | Public URL with a |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No area with that id in this org. Nothing was uploaded; the ownership check runs first.
Raw OpenAPI operation
{
"summary": "Upload an area (room) photo",
"description": "A line-for-line mirror of POST /api/services/{id}/image, stored in `area-photos` (migration 0110) instead. Not admin-gated: a photo is part of editing the area, same permission level as PATCH /api/areas/{id}.",
"tags": [
"Hospitality engine"
],
"operationId": "uploadAreaImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Area id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The area photo. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"imageUrl"
],
"properties": {
"ok": {
"const": true
},
"imageUrl": {
"type": "string",
"format": "uri",
"description": "Public URL with a `?v=<timestamp>` cache-buster; the storage path itself never changes."
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No area with that id in this org. Nothing was uploaded; the ownership check runs first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/areas/{id}/imageRemove an area photo
/api/areas/{id}/imageDeletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.
Parameters
id*pathstringArea id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No area with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove an area photo",
"description": "Deletes the stored object and nulls `image_url`. Storage removal is best-effort and its failure does not fail the request.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteAreaImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Area id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No area with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}put/api/service-periodsReplace a named service period (admin, or manage_services)
/api/service-periodsWrites a whole period at once: "the period called name now runs on exactly these days, with these settings". No active period for a weekday means an empty availability grid that day, so this is the endpoint a venue's bookable times live or die by.
PUT, and wholesale, on purpose. A day of a period is not an entity an operator names; the PERIOD is, and it is identified by its name. There is nothing here for a per-row create to be idempotent about, and the old POST (one weekday per call) is what made a restaurant serving lunch and dinner all week fourteen separate writes.
One statement, therefore one transaction. The whole replacement happens inside book_replace_service_period_group() (migration 0029) rather than as a PostgREST delete followed by an insert. That is not tidiness: computeReservationSlots returns nothing for a day with no periods, so a delete that landed without its insert would take the venue's booking page offline until someone re-saved, not merely degrade it. On any error nothing is written and nothing is lost.
Gated to admin, or a staff login granted manage_services (migration 0082), hospitality's equivalent of the appointments services routes, and gated the same way: an empty days array deletes the group, so this one call is as destructive as it is creative.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) The name the group will have after the write. |
| oldName | string | (min length 1, max length 40) The name the group has NOW. Defaults to |
| days | object[] | (max items 7) One entry per day this period runs. An empty array (or an omitted `days`) DELETES the group: that is how the screen removes a period, not an error. Repeating a |
| days[].dayOfWeek* | integer | (min 0, max 6) 0 = Sunday, matching |
| days[].startTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM. |
| days[].endTime* | string | (pattern ^([01]\d|2[0-3]):[0-5]\d$) HH:MM, strictly after startTime. |
| days[].lastSeating | string | null | HH:MM, must fall between startTime and endTime. Null means "up to endTime". |
| turnMinutes | integer | (min 15, max 480) How long a sitting occupies its table. Required whenever |
| slotMinutes | integer | (min 5, max 120) Booking interval within the period. Required whenever |
| maxCovers | integer | null | (min 1) Total covers seated at once in this period, on top of table capacity. Null = uncapped. |
| active | boolean | (default true) |
Responses
Written. days echoes how many rows the period now has, 0 for a delete.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| name* | string | |
| days* | integer | (min 0, max 7) |
A validation message from parsePeriodGroupInput, prefixed with the day it is about when one day of the request is at fault ("Tuesday: End time must be after start time"), or a Postgres error, in which case the period is untouched.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
The insert reported a different number of rows than were sent. Cannot happen (one array insert writes every element or throws) and asserted rather than assumed, because the alternative is reporting success for a week that differs from the one echoed back.
Raw OpenAPI operation
{
"summary": "Replace a named service period (admin, or manage_services)",
"description": "Writes a whole period at once: \"the period called `name` now runs on exactly these days, with these settings\". No active period for a weekday means an empty availability grid that day, so this is the endpoint a venue's bookable times live or die by.\n\n**PUT, and wholesale, on purpose.** A day of a period is not an entity an operator names; the PERIOD is, and it is identified by its `name`. There is nothing here for a per-row create to be idempotent about, and the old `POST` (one weekday per call) is what made a restaurant serving lunch and dinner all week fourteen separate writes.\n\n**One statement, therefore one transaction.** The whole replacement happens inside `book_replace_service_period_group()` (migration 0029) rather than as a PostgREST delete followed by an insert. That is not tidiness: `computeReservationSlots` returns nothing for a day with no periods, so a delete that landed without its insert would take the venue's booking page **offline** until someone re-saved, not merely degrade it. On any error nothing is written and nothing is lost.\n\nGated to admin, or a staff login granted `manage_services` (migration 0082), hospitality's equivalent of the appointments services routes, and gated the same way: an empty `days` array deletes the group, so this one call is as destructive as it is creative.",
"tags": [
"Hospitality engine"
],
"operationId": "replaceServicePeriodGroup",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "The name the group will have after the write."
},
"oldName": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "The name the group has NOW. Defaults to `name`, which is the ordinary save and also the safe create (it replaces a same-named group rather than adding a second one). Send a different value to RENAME: the delete and the insert are one statement, so a rename cannot half-land. **Renaming onto a name another group already uses leaves both sets of rows in place**, only `oldName` is deleted, so the dashboard refuses that before sending."
},
"days": {
"type": "array",
"maxItems": 7,
"description": "One entry per day this period runs. **An empty array (or an omitted `days`) DELETES the group**: that is how the screen removes a period, not an error. Repeating a `dayOfWeek` is not rejected: overlapping windows are something `computeReservationSlots` handles on purpose.",
"items": {
"type": "object",
"required": [
"dayOfWeek",
"startTime",
"endTime"
],
"additionalProperties": false,
"properties": {
"dayOfWeek": {
"type": "integer",
"minimum": 0,
"maximum": 6,
"description": "0 = Sunday, matching `Date#getUTCDay()`. The dashboard lists the week Monday-first; that is display order only."
},
"startTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM."
},
"endTime": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "HH:MM, strictly after startTime."
},
"lastSeating": {
"type": [
"string",
"null"
],
"description": "HH:MM, must fall between startTime and endTime. Null means \"up to endTime\"."
}
}
}
},
"turnMinutes": {
"type": "integer",
"minimum": 15,
"maximum": 480,
"description": "How long a sitting occupies its table. Required whenever `days` is non-empty."
},
"slotMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 120,
"description": "Booking interval within the period. Required whenever `days` is non-empty."
},
"maxCovers": {
"type": [
"integer",
"null"
],
"minimum": 1,
"description": "Total covers seated at once in this period, on top of table capacity. Null = uncapped."
},
"active": {
"type": "boolean",
"default": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "Written. `days` echoes how many rows the period now has, 0 for a delete.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"name",
"days"
],
"properties": {
"ok": {
"const": true
},
"name": {
"type": "string"
},
"days": {
"type": "integer",
"minimum": 0,
"maximum": 7
}
}
}
}
}
},
"400": {
"description": "A validation message from parsePeriodGroupInput, prefixed with the day it is about when one day of the request is at fault (\"Tuesday: End time must be after start time\"), or a Postgres error, in which case the period is untouched.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The insert reported a different number of rows than were sent. Cannot happen (one array insert writes every element or throws) and asserted rather than assumed, because the alternative is reporting success for a week that differs from the one echoed back.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/reservationsCreate a reservation (staff side)
/api/reservationsA sitting taken at the host stand or over the phone. Always inserted as confirmed, never hold; a hold is a guest's half-finished checkout with an expiry and has no meaning for something staff enter deliberately.
turnMinutes is a free field here, unlike an appointment's duration: a reservation has no service to snapshot a length from, which is exactly why 0011 gave it its own tables. ends_at is derived from it. tableId may be null; a venue that caps covers without assigning tables works as-is.
Request body application/json
The client is given one of two ways, matching the picker on the dashboard: either customerId for someone already on the roster, or customerName and customerPhone (plus optionally an email) to find-or-create one. When customerId is present the typed fields are ignored entirely.
customerPhone is required on the typed branch and only there. The check lives in resolveOrCreateCustomer, the single writer that creates a client from typed details, so it applies identically here, on POST /api/reservations and on POST /api/clients. Picking an existing client short-circuits to their id and never reaches it, which is what keeps a regular who predates the rule bookable.
| Field | Type | Notes |
|---|---|---|
| partySize* | integer | (min 1, max 30) |
| turnMinutes* | integer | (min 5, max 600) |
| startsAt* | string (date-time) | |
| tableId | string | null (uuid) | Null is legal and common: a venue that caps covers without assigning tables works as-is. |
| occasion | string | null | (max length 60) |
| notes | string | null | (max length 1000) |
| walkIn | boolean | True creates the sitting with NO customer at all (0096): the floor's quick-seat gesture. Every customer field is ignored; deliberately, no book_customers row is created, so the client directory never fills with rows named "Walk-in". |
| seated | boolean | True inserts the sitting already |
| customerId | string (uuid) | An existing client in this org. A foreign or unknown id is a 400 ("Client not found"). |
| customerName | string | (min length 1, max length 120) Required when |
| customerEmail | string | null (email) | (max length 254) Lowercased. Matches an existing client on (company_id, email) and updates that record rather than creating a second one. Omit and a fresh, undeduplicated record is always created: there is no other dedup key. |
| customerPhone | string | (min length 1, max length 40) Required when |
Responses
Created. Same bare {id} shape as POST /api/appointments.
| Field | Type |
|---|---|
| id* | string (uuid) |
A validation message, "Client not found", or "That table no longer exists" from the composite foreign key (which is also what rejects another org's table id).
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
book_reservations_no_overlap fired; that table is already booked for an overlapping window.
Raw OpenAPI operation
{
"summary": "Create a reservation (staff side)",
"description": "A sitting taken at the host stand or over the phone. Always inserted as `confirmed`, never `hold`; a hold is a guest's half-finished checkout with an expiry and has no meaning for something staff enter deliberately.\n\n`turnMinutes` is a free field here, unlike an appointment's duration: a reservation has no service to snapshot a length from, which is exactly why 0011 gave it its own tables. `ends_at` is derived from it. `tableId` may be null; a venue that caps covers without assigning tables works as-is.",
"tags": [
"Hospitality engine"
],
"operationId": "createReservation",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"description": "The client is given one of two ways, matching the picker on the dashboard: **either** `customerId` for someone already on the roster, **or** `customerName` and `customerPhone` (plus optionally an email) to find-or-create one. When `customerId` is present the typed fields are ignored entirely.\n\n`customerPhone` is required on the typed branch and only there. The check lives in `resolveOrCreateCustomer`, the single writer that creates a client from typed details, so it applies identically here, on `POST /api/reservations` and on `POST /api/clients`. Picking an existing client short-circuits to their id and never reaches it, which is what keeps a regular who predates the rule bookable.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"partySize",
"turnMinutes",
"startsAt"
],
"properties": {
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"turnMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Null is legal and common: a venue that caps covers without assigning tables works as-is."
},
"occasion": {
"type": [
"string",
"null"
],
"maxLength": 60
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000
},
"walkIn": {
"type": "boolean",
"description": "True creates the sitting with NO customer at all (0096): the floor's quick-seat gesture. Every customer field is ignored; deliberately, no book_customers row is created, so the client directory never fills with rows named \"Walk-in\"."
},
"seated": {
"type": "boolean",
"description": "True inserts the sitting already `seated` rather than `confirmed`: a walk-in being shown to their table is already sitting down."
},
"customerId": {
"type": "string",
"format": "uuid",
"description": "An existing client in this org. A foreign or unknown id is a 400 (\"Client not found\")."
},
"customerName": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"description": "Required when `customerId` is absent."
},
"customerEmail": {
"type": [
"string",
"null"
],
"format": "email",
"maxLength": 254,
"description": "Lowercased. Matches an existing client on (company_id, email) and updates that record rather than creating a second one. Omit and a fresh, undeduplicated record is always created: there is no other dedup key."
},
"customerPhone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Required when `customerId` is absent; see the note above. A blank or missing number is a 400 (\"A contact number is required\") from `resolveOrCreateCustomer`, after the other field validation."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created. Same bare `{id}` shape as POST /api/appointments.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"400": {
"description": "A validation message, \"Client not found\", or \"That table no longer exists\" from the composite foreign key (which is also what rejects another org's table id).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "`book_reservations_no_overlap` fired; that table is already booked for an overlapping window.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/serversAdd a floor server
/api/serversA server (0097) is a label with a section colour: a name on the floor map, bookable by nothing. The colour is assigned here, round-robin over a fixed palette chosen so the map's white initials stay readable in both themes; callers do not pick colours.
Front-of-house work, so no admin gate: the same trust level as moving a party between tables.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
Responses
Created.
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | |
| name* | string | |
| color* | string | #rrggbb, assigned by the server. |
Missing or over-long name.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Add a floor server",
"description": "A server (0097) is a label with a section colour: a name on the floor map, bookable by nothing. The colour is assigned here, round-robin over a fixed palette chosen so the map's white initials stay readable in both themes; callers do not pick colours.\n\nFront-of-house work, so no admin gate: the same trust level as moving a party between tables.",
"tags": [
"Hospitality engine"
],
"operationId": "createServer",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"id",
"name",
"color"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"color": {
"type": "string",
"description": "#rrggbb, assigned by the server."
}
}
}
}
}
},
"400": {
"description": "Missing or over-long name.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/servers/{id}Rename a floor server
/api/servers/{id}Name only. The section colour stays assigned by POST /api/servers, round-robin over a fixed palette: it exists to keep sections visually distinct on the map rather than to be chosen, and letting two servers land on the same hue would quietly break the thing it is for.
This endpoint did not exist until 2026-08-10, on the reasoning that "rename is delete-and-re-add, and nothing else references a server by id". The second half was wrong: book_server_assignments references it, and those are exactly what the DELETE below cascades. So fixing a typo in a name wiped every table that server held for that date, mid-service. Renaming in place keeps the assignments.
Parameters
id*pathstringServer id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 40) |
Responses
The updated server.
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | |
| name* | string | |
| color* | string | #rrggbb, unchanged by this call. |
Missing or over-long name.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No server with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Rename a floor server",
"description": "Name only. The section colour stays assigned by `POST /api/servers`, round-robin over a fixed palette: it exists to keep sections visually distinct on the map rather than to be chosen, and letting two servers land on the same hue would quietly break the thing it is for.\n\nThis endpoint did not exist until 2026-08-10, on the reasoning that \"rename is delete-and-re-add, and nothing else references a server by id\". The second half was wrong: `book_server_assignments` references it, and those are exactly what the DELETE below cascades. So fixing a typo in a name wiped every table that server held for that date, mid-service. Renaming in place keeps the assignments.",
"tags": [
"Hospitality engine"
],
"operationId": "renameServer",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Server id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
}
}
}
}
},
"responses": {
"200": {
"description": "The updated server.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"id",
"name",
"color"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"color": {
"type": "string",
"description": "#rrggbb, unchanged by this call."
}
}
}
}
}
},
"400": {
"description": "Missing or over-long name.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No server with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/servers/{id}Remove a floor server
/api/servers/{id}Hard delete: the row is a label, and its table assignments cascade away with it (0097). Use the PATCH above to rename rather than delete-and-re-add, precisely because of that cascade.
Parameters
id*pathstringServer id.
Responses
Deleted, assignments included.
| Field | Type |
|---|---|
| ok* | true |
An unexpected database error, surfaced verbatim.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No server with that id in this org, or a malformed uuid.
Raw OpenAPI operation
{
"summary": "Remove a floor server",
"description": "Hard delete: the row is a label, and its table assignments cascade away with it (0097). Use the PATCH above to rename rather than delete-and-re-add, precisely because of that cascade.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteServer",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Server id."
}
],
"responses": {
"200": {
"description": "Deleted, assignments included.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An unexpected database error, surfaced verbatim.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No server with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}put/api/server-assignmentsAssign a table to a server for a date
/api/server-assignmentsThe whole assignment API in one verb: a serverId gives the table to that server for the date (upsert on the one-server-per-table-per-date key), an explicit null takes it back. Per-DATE, not per-service: sections are chalked up once for the night.
No same-tenant checks in the route. Both foreign keys are composite over company_id (0011's pattern), so a cross-tenant table or server id is rejected by the database itself.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| date* | string | (pattern ^\d{4}-\d{2}-\d{2}$) |
| tableId* | string (uuid) | |
| serverId* | string | null (uuid) | Null clears the assignment; clearing one that does not exist is a no-op, not an error. |
Responses
Assigned (or cleared).
| Field | Type |
|---|---|
| ok* | true |
Missing fields, or a composite FK refusing a table/server id that does not exist in this org.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "Assign a table to a server for a date",
"description": "The whole assignment API in one verb: a `serverId` gives the table to that server for the date (upsert on the one-server-per-table-per-date key), an explicit null takes it back. Per-DATE, not per-service: sections are chalked up once for the night.\n\nNo same-tenant checks in the route. Both foreign keys are composite over company_id (0011's pattern), so a cross-tenant table or server id is rejected by the database itself.",
"tags": [
"Hospitality engine"
],
"operationId": "putServerAssignment",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"date",
"tableId",
"serverId"
],
"properties": {
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"tableId": {
"type": "string",
"format": "uuid"
},
"serverId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Null clears the assignment; clearing one that does not exist is a no-op, not an error."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Assigned (or cleared).",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "Missing fields, or a composite FK refusing a table/server id that does not exist in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/reservations/{id}Update a reservation
/api/reservations/{id}The reservations twin of PATCH /api/appointments/{id}: partial, only present keys are written.
Deliberately NOT re-checked on edit: the table's seat range and the period's cover cap. A host seating five on a four-top is a real thing venues do and the reason a manual override screen exists. The double-booking constraint is the one rule staff cannot override, and Postgres enforces it.
Parameters
id*pathstringReservation id.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| status | "pending" | "confirmed" | "seated" | "completed" | "cancelled" | "no_show" |
|
| partySize | integer | (min 1, max 30) |
| tableId | string | null (uuid) | Explicit null unassigns the table. |
| startsAt | string (date-time) | |
| turnMinutes | integer | (min 5, max 600) |
| occasion | string | null | (max length 60) |
| notes | string | null | (max length 1000) |
| phone | string | null | (max length 40) Writes to the guest's client record, not to this sitting. May be corrected but not removed: an explicit null is a 400 when the guest already has a number, and a no-op when they do not, the same rule |
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message, an attempt to set hold by hand, or "Nothing to change".
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No reservation with that id in this org, or a malformed uuid.
Three causes share this code: the table is already booked for that window (book_reservations_no_overlap); the row is an abandoned hold with no guest attached, so it cannot be given any status (book_reservations_customer_required); or a phone edit was attempted against such a hold.
Raw OpenAPI operation
{
"summary": "Update a reservation",
"description": "The reservations twin of PATCH /api/appointments/{id}: partial, only present keys are written.\n\nDeliberately NOT re-checked on edit: the table's seat range and the period's cover cap. A host seating five on a four-top is a real thing venues do and the reason a manual override screen exists. The double-booking constraint is the one rule staff cannot override, and Postgres enforces it.",
"tags": [
"Hospitality engine"
],
"operationId": "updateReservation",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Reservation id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"enum": [
"pending",
"confirmed",
"seated",
"completed",
"cancelled",
"no_show"
],
"description": "`hold` is a valid reservation status but NOT a valid target here; it is set by the booking widget together with an expiry, and setting it alone would violate a biconditional check constraint. Asking for it is a 400 with an explanation. Any accepted status also clears `hold_expires_at` in the same statement, which is what makes promoting a live hold work."
},
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Explicit null unassigns the table."
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"turnMinutes": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"occasion": {
"type": [
"string",
"null"
],
"maxLength": 60
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000
},
"phone": {
"type": [
"string",
"null"
],
"maxLength": 40,
"description": "Writes to the guest's client record, not to this sitting. May be corrected but not removed: an explicit null is a 400 when the guest already has a number, and a no-op when they do not, the same rule `PATCH /api/clients/{id}` applies to the same column from the roster side."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message, an attempt to set `hold` by hand, or \"Nothing to change\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No reservation with that id in this org, or a malformed uuid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Three causes share this code: the table is already booked for that window (`book_reservations_no_overlap`); the row is an abandoned hold with no guest attached, so it cannot be given any status (`book_reservations_customer_required`); or a phone edit was attempted against such a hold.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/experiencesCreate an experience
/api/experiencesAdds a special night, set menu or ticketed event to the catalog. company_id comes from the verified JWT claim, never the body.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 120) |
| description | string | null | (max length 500) |
| category | string | null | (max length 60) A free-form label ("Special night", "Set menu"). No categories table for v1, unlike Products. |
| price | number | null | (min 0, max 100000) Dollars (or the venue currency's major unit). Null or omitted means "not ticketed", distinct from 0, a real free-but-ticketed price. |
| eventDate | string | null (date) | A one-off dated event ("2026-12-25"). Null or omitted means an evergreen/recurring item with no fixed date. A present but malformed or non-existent calendar date (e.g. "2026-02-30") is a 400. |
Responses
Created.
| Field | Type |
|---|---|
| experience* | object |
| experience.id | string (uuid) |
A validation message from parseExperienceInput, or the raw Postgres message if the insert itself failed.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
Raw OpenAPI operation
{
"summary": "Create an experience",
"description": "Adds a special night, set menu or ticketed event to the catalog. `company_id` comes from the verified JWT claim, never the body.",
"tags": [
"Hospitality engine"
],
"operationId": "createExperience",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"category": {
"type": [
"string",
"null"
],
"maxLength": 60,
"description": "A free-form label (\"Special night\", \"Set menu\"). No categories table for v1, unlike Products."
},
"price": {
"type": [
"number",
"null"
],
"minimum": 0,
"maximum": 100000,
"description": "Dollars (or the venue currency's major unit). Null or omitted means \"not ticketed\", distinct from 0, a real free-but-ticketed price."
},
"eventDate": {
"type": [
"string",
"null"
],
"format": "date",
"description": "A one-off dated event (\"2026-12-25\"). Null or omitted means an evergreen/recurring item with no fixed date. A present but malformed or non-existent calendar date (e.g. \"2026-02-30\") is a 400."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Created.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"experience"
],
"properties": {
"experience": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"400": {
"description": "A validation message from parseExperienceInput, or the raw Postgres message if the insert itself failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/experiences/{id}Update an experience, or archive/restore it
/api/experiences/{id}Two shapes in one route, same posture PATCH /api/products/{id} takes. A body of exactly {"archived": true|false} sets/clears archived_at and returns immediately, no other field required. Any other body is a full replacement of the catalog fields (parseExperienceInput's contract). RLS scopes the update, so an id from another org matches zero rows and returns 404.
Parameters
id*pathstringExperience id.
Request body application/json
Responses
Updated.
| Field | Type |
|---|---|
| ok* | true |
A validation message from parseExperienceInput.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster, and a staff member without the manage_services permission (migration 0082, Admin access required).
No experience with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.
Raw OpenAPI operation
{
"summary": "Update an experience, or archive/restore it",
"description": "Two shapes in one route, same posture PATCH /api/products/{id} takes. A body of exactly `{\"archived\": true|false}` sets/clears `archived_at` and returns immediately, no other field required. Any other body is a full replacement of the catalog fields (parseExperienceInput's contract). RLS scopes the update, so an id from another org matches zero rows and returns 404.",
"tags": [
"Hospitality engine"
],
"operationId": "updateExperience",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Experience id."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"archived"
],
"additionalProperties": false,
"properties": {
"archived": {
"type": "boolean"
}
}
},
{
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": [
"string",
"null"
],
"maxLength": 500
},
"category": {
"type": [
"string",
"null"
],
"maxLength": 60,
"description": "A free-form label (\"Special night\", \"Set menu\"). No categories table for v1, unlike Products."
},
"price": {
"type": [
"number",
"null"
],
"minimum": 0,
"maximum": 100000,
"description": "Dollars (or the venue currency's major unit). Null or omitted means \"not ticketed\", distinct from 0, a real free-but-ticketed price."
},
"eventDate": {
"type": [
"string",
"null"
],
"format": "date",
"description": "A one-off dated event (\"2026-12-25\"). Null or omitted means an evergreen/recurring item with no fixed date. A present but malformed or non-existent calendar date (e.g. \"2026-02-30\") is a 400."
}
}
}
]
}
}
}
},
"responses": {
"200": {
"description": "Updated.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A validation message from parseExperienceInput.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster, and a `staff` member without the `manage_services` permission (migration 0082, `Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No experience with that id in this org. A malformed uuid (Postgres 22P02) is mapped here rather than surfacing as a 500.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/experiences/{id}/imageUpload an experience image
/api/experiences/{id}/imageStores the file at {companyId}/{experienceId} in the experience-images bucket and writes a cache-busted public URL onto the experience row. Not gated on manage_services: a photo is part of editing the experience, same posture as the equivalent product-image route. Ownership is checked BEFORE the upload, so a request naming an experience the caller does not own is a 404 with nothing written.
Parameters
id*pathstringExperience id.
Request body multipart/form-data
The experience image. Sent as multipart/form-data under the field name file.
| Field | Type | Notes |
|---|---|---|
| file* | string (binary) | PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400. |
Responses
Uploaded.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| imageUrl* | string (uri) | Public URL with a |
No file, wrong MIME type, over 2 MB, or a storage error.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No experience with that id in this org. Nothing was uploaded; the ownership check runs first.
Raw OpenAPI operation
{
"summary": "Upload an experience image",
"description": "Stores the file at `{companyId}/{experienceId}` in the `experience-images` bucket and writes a cache-busted public URL onto the experience row. Not gated on `manage_services`: a photo is part of editing the experience, same posture as the equivalent product-image route. Ownership is checked BEFORE the upload, so a request naming an experience the caller does not own is a 404 with nothing written.",
"tags": [
"Hospitality engine"
],
"operationId": "uploadExperienceImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Experience id."
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400."
}
}
},
"encoding": {
"file": {
"contentType": "image/png, image/jpeg, image/webp"
}
}
}
},
"description": "The experience image. Sent as multipart/form-data under the field name `file`."
},
"responses": {
"200": {
"description": "Uploaded.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"imageUrl"
],
"properties": {
"ok": {
"const": true
},
"imageUrl": {
"type": "string",
"format": "uri",
"description": "Public URL with a `?v=<timestamp>` cache-buster; the storage path itself never changes."
}
}
}
}
}
},
"400": {
"description": "No file, wrong MIME type, over 2 MB, or a storage error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No experience with that id in this org. Nothing was uploaded; the ownership check runs first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/experiences/{id}/imageRemove an experience image
/api/experiences/{id}/imageDeletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.
Parameters
id*pathstringExperience id.
Responses
Removed.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error updating the row.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No experience with that id in this org.
Raw OpenAPI operation
{
"summary": "Remove an experience image",
"description": "Deletes the stored object and nulls `image_url`. Storage removal is best-effort and its failure does not fail the request.",
"tags": [
"Hospitality engine"
],
"operationId": "deleteExperienceImage",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Experience id."
}
],
"responses": {
"200": {
"description": "Removed.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error updating the row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No experience with that id in this org.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/experiences/{id}/ticketsThe door-staff guestlist for one experience
/api/experiences/{id}/ticketsEvery ticket sold for this experience, who it's for, and whether it has been admitted. No manage_services gate, unlike the catalog routes above: checking a guest in is an ordinary front-of-house task every role needs at the door. A cancelled booking's ticket stays in the list (a cancelled guest should not simply vanish from what staff see) but never counts toward admitted/total.
Parameters
id*pathstringExperience id.
Responses
The guestlist.
| Field | Type | Notes |
|---|---|---|
| tickets* | object[] | |
| tickets[].ticketId* | string (uuid) | |
| tickets[].token* | string (uuid) | The QR payload (ticketScanUrl), for the manual "enter a code" fallback on /scanner. |
| tickets[].quantity* | integer | How many guests this ticket admits: party size for a group ticket, 1 for an individual seat. |
| tickets[].seatLabel* | string | null | "Seat 2 of 4" for an individually-sold ticket, null for a group ticket. |
| tickets[].checkedInAt* | string | null (date-time) | |
| tickets[].reservationId* | string (uuid) | |
| tickets[].reservationStatus* | string | |
| tickets[].partySize* | integer | |
| tickets[].customerName* | string | |
| tickets[].customerPhone* | string | null | |
| admitted* | integer | Total headcount checked in so far, across all non-cancelled tickets. |
| total* | integer | Total headcount sold, across all non-cancelled tickets. |
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
Raw OpenAPI operation
{
"summary": "The door-staff guestlist for one experience",
"description": "Every ticket sold for this experience, who it's for, and whether it has been admitted. No `manage_services` gate, unlike the catalog routes above: checking a guest in is an ordinary front-of-house task every role needs at the door. A cancelled booking's ticket stays in the list (a cancelled guest should not simply vanish from what staff see) but never counts toward `admitted`/`total`.",
"tags": [
"Hospitality engine"
],
"operationId": "getExperienceGuestlist",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Experience id."
}
],
"responses": {
"200": {
"description": "The guestlist.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"tickets",
"admitted",
"total"
],
"properties": {
"tickets": {
"type": "array",
"items": {
"type": "object",
"required": [
"ticketId",
"token",
"quantity",
"seatLabel",
"checkedInAt",
"reservationId",
"reservationStatus",
"partySize",
"customerName",
"customerPhone"
],
"properties": {
"ticketId": {
"type": "string",
"format": "uuid"
},
"token": {
"type": "string",
"format": "uuid",
"description": "The QR payload (ticketScanUrl), for the manual \"enter a code\" fallback on /scanner."
},
"quantity": {
"type": "integer",
"description": "How many guests this ticket admits: party size for a group ticket, 1 for an individual seat."
},
"seatLabel": {
"type": [
"string",
"null"
],
"description": "\"Seat 2 of 4\" for an individually-sold ticket, null for a group ticket."
},
"checkedInAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"reservationId": {
"type": "string",
"format": "uuid"
},
"reservationStatus": {
"type": "string"
},
"partySize": {
"type": "integer"
},
"customerName": {
"type": "string"
},
"customerPhone": {
"type": [
"string",
"null"
]
}
}
}
},
"admitted": {
"type": "integer",
"description": "Total headcount checked in so far, across all non-cancelled tickets."
},
"total": {
"type": "integer",
"description": "Total headcount sold, across all non-cancelled tickets."
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/experiences/tickets/checkinScan or manually check in a ticket
/api/experiences/tickets/checkinOne action, two callers: a live QR scan on /scanner sends token (extracted from the decoded ticket URL); the guestlist's own manual "Check in"/"Undo" button sends ticketId directly. Exactly one of the two is required. A cancelled or never-confirmed booking's ticket is refused outright. Scanning an already-admitted ticket is not an error: the response reports alreadyCheckedIn: true rather than re-admitting or refusing, which is the single most useful thing for the door to know (a duplicate ticket, a guest who stepped back out). undo: true clears a check-in instead (a mis-scan correction).
Request body application/json
| Field | Type | Notes |
|---|---|---|
| token | string | The ticket QR payload, or its bare token. |
| ticketId | string (uuid) | |
| undo | boolean | Default false. True clears an existing check-in. |
Responses
The ticket, and whether it was already checked in before this call.
| Field | Type |
|---|---|
| ticket* | object |
| ticket.ticketId | string (uuid) |
| ticket.token | string (uuid) |
| ticket.quantity | integer |
| ticket.seatLabel | string | null |
| ticket.checkedInAt | string | null (date-time) |
| ticket.reservationId | string (uuid) |
| ticket.reservationStatus | string |
| ticket.partySize | integer |
| ticket.customerName | string |
| ticket.customerPhone | string | null |
| ticket.experienceName | string |
| alreadyCheckedIn* | boolean |
Neither token nor ticketId was given.
No valid session cookie. {"error":"Not signed in"}.
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
No ticket found for that token/id, or the underlying booking was cancelled or never confirmed.
Raw OpenAPI operation
{
"summary": "Scan or manually check in a ticket",
"description": "One action, two callers: a live QR scan on /scanner sends `token` (extracted from the decoded ticket URL); the guestlist's own manual \"Check in\"/\"Undo\" button sends `ticketId` directly. Exactly one of the two is required. A cancelled or never-confirmed booking's ticket is refused outright. Scanning an already-admitted ticket is not an error: the response reports `alreadyCheckedIn: true` rather than re-admitting or refusing, which is the single most useful thing for the door to know (a duplicate ticket, a guest who stepped back out). `undo: true` clears a check-in instead (a mis-scan correction).",
"tags": [
"Hospitality engine"
],
"operationId": "checkInTicket",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"token": {
"type": "string",
"description": "The ticket QR payload, or its bare token."
},
"ticketId": {
"type": "string",
"format": "uuid"
},
"undo": {
"type": "boolean",
"description": "Default false. True clears an existing check-in."
}
}
}
}
}
},
"responses": {
"200": {
"description": "The ticket, and whether it was already checked in before this call.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ticket",
"alreadyCheckedIn"
],
"properties": {
"ticket": {
"type": "object",
"properties": {
"ticketId": {
"type": "string",
"format": "uuid"
},
"token": {
"type": "string",
"format": "uuid"
},
"quantity": {
"type": "integer"
},
"seatLabel": {
"type": [
"string",
"null"
]
},
"checkedInAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"reservationId": {
"type": "string",
"format": "uuid"
},
"reservationStatus": {
"type": "string"
},
"partySize": {
"type": "integer"
},
"customerName": {
"type": "string"
},
"customerPhone": {
"type": [
"string",
"null"
]
},
"experienceName": {
"type": "string"
}
}
},
"alreadyCheckedIn": {
"type": "boolean"
}
}
}
}
}
},
"400": {
"description": "Neither token nor ticketId was given.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No ticket found for that token/id, or the underlying booking was cancelled or never confirmed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/organization/reservations/funnelHospitality's "Your booking funnel" (self-hosted, cookieless)
/api/organization/reservations/funnelThe hospitality twin of getWebsiteAnalytics above, scoped to the one panel that's actually about the booking widget rather than a marketing site: no popular pages, referrers, devices or domain/publish status, since hospitality has no site pages for any of that to describe yet (see organization/website/page.tsx's own redirect). Same Umami plumbing (ensureUmamiWebsiteId, getUmamiEventCounts) and the same buildBookingFunnelSteps ranking/drop-off maths as the appointments funnel, over hospitality's own step vocabulary (RESERVATION_FUNNEL_STEP_ORDER, funnel-events.ts). The terminal "Booked" row is the reservation_completed Umami event count, not a DB ground-truth count the way appointments' via_website column gives it, since hospitality has no site to distinguish "arrived via the site" from "direct" (there is nothing that column would be truthful about). Surfaced on /reports, the one screen both verticals already share, not a hospitality-only "Website" nav item that does not exist.
Parameters
daysquery7 | 30 | 90optionalThe trailing window. Anything else silently falls back to 30, same as getWebsiteAnalytics' identical parameter.
Responses
The funnel, step by step, ending on the real number of completed reservations.
| Field | Type | Notes |
|---|---|---|
| days* | integer | |
| bookingFunnel* | object[] | |
| bookingFunnel[].key* | string | |
| bookingFunnel[].label* | string | |
| bookingFunnel[].visitors* | integer | |
| bookingFunnel[].dropoffPct* | number | Null on the first row, or when the previous step itself was 0. |
No valid session cookie. {"error":"Not signed in"}.
The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, analytics).
Not permitted. Includes the engine-boundary refusal: this operation belongs to the table-reservations engine, so an org whose business_type is appointments gets 403 with "This organization takes appointments, not table reservations." Also covers no linked organization and not being on the roster.
The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.
UMAMI_URL/UMAMI_USERNAME/UMAMI_PASSWORD is not configured yet (UmamiNotConfiguredError).
Raw OpenAPI operation
{
"summary": "Hospitality's \"Your booking funnel\" (self-hosted, cookieless)",
"description": "The hospitality twin of getWebsiteAnalytics above, scoped to the one panel that's actually about the booking widget rather than a marketing site: no popular pages, referrers, devices or domain/publish status, since hospitality has no site pages for any of that to describe yet (see organization/website/page.tsx's own redirect). Same Umami plumbing (ensureUmamiWebsiteId, getUmamiEventCounts) and the same buildBookingFunnelSteps ranking/drop-off maths as the appointments funnel, over hospitality's own step vocabulary (RESERVATION_FUNNEL_STEP_ORDER, funnel-events.ts). The terminal \"Booked\" row is the reservation_completed Umami event count, not a DB ground-truth count the way appointments' via_website column gives it, since hospitality has no site to distinguish \"arrived via the site\" from \"direct\" (there is nothing that column would be truthful about). Surfaced on /reports, the one screen both verticals already share, not a hospitality-only \"Website\" nav item that does not exist.",
"tags": [
"Hospitality engine"
],
"operationId": "getReservationFunnel",
"security": [
{
"sessionCookie": []
}
],
"x-engine": "hospitality",
"parameters": [
{
"name": "days",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"enum": [
7,
30,
90
]
},
"description": "The trailing window. Anything else silently falls back to 30, same as getWebsiteAnalytics' identical parameter."
}
],
"responses": {
"200": {
"description": "The funnel, step by step, ending on the real number of completed reservations.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"days",
"bookingFunnel"
],
"properties": {
"days": {
"type": "integer"
},
"bookingFunnel": {
"type": "array",
"items": {
"type": "object",
"required": [
"key",
"label",
"visitors",
"dropoffPct"
],
"properties": {
"key": {
"type": "string"
},
"label": {
"type": "string"
},
"visitors": {
"type": "integer"
},
"dropoffPct": {
"type": "number",
"nullable": true,
"description": "Null on the first row, or when the previous step itself was 0."
}
}
}
}
}
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"402": {
"description": "The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, `analytics`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not permitted. **Includes the engine-boundary refusal**: this operation belongs to the table-reservations engine, so an org whose `business_type` is `appointments` gets 403 with \"This organization takes appointments, not table reservations.\" Also covers no linked organization and not being on the roster.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "UMAMI_URL/UMAMI_USERNAME/UMAMI_PASSWORD is not configured yet (UmamiNotConfiguredError).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}Public booking (appointments)
Unauthenticated. The widget a customer uses at /book/{slug} (or /{slug}/book: both are real, permanently-live addresses for the same page). Not engine-guarded: see the 403 note above.
get/api/book/{slug}/availabilityList bookable appointment slots
/api/book/{slug}/availabilityThe public availability grid. Reads through the service-role client inside this route rather than through an anon RLS policy, so there is one security model to reason about rather than two.
Slot allocation is first-come: for a given start time the first provider found free wins it. There is no fairness or least-booked balancing. Slots whose start time has already passed are filtered out, because the booking POST rejects them anyway.
Ranged mode (`days`). Answers up to 14 consecutive days from date in one request, for the wizard's landing walk, which used to fire one request per day (up to 14) hunting for the first open one. The single-date shape ({slots}) is unchanged and stays the default; sending days greater than 1 switches the response to {days: {"<date>": {slots}, ...}}, one entry per requested date.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.service_id*querystringOne active service id of this org, or up to six comma-separated for a multi-service visit (0054). The slot maths runs on the SUM of the durations, and a specific provider must offer EVERY id; with
any, eligibility is the intersection; one person carries the whole block. Any id unknown, inactive, or not this org's makes the whole request a 404; more than six is a 400.date*querystring (date)YYYY-MM-DD. Interpreted as a UTC wall clock, the same convention computeAvailableSlots uses. The first day answered in ranged mode.
provider_idquerystringoptionalOmit, or send
any, to search every provider offering the service. A specific id that is not an active provider of this org offering this service returns an EMPTY slot list, not a 404; returning 404 would let a caller probe which provider ids exist.daysqueryintegeroptionalHow many consecutive days from
dateto answer in one call. Omitted or1keeps the original single-date{slots}shape; 2 to 14 switches the response to{days: {"<date>": {slots}, ...}}.manage_tokenquerystring (uuid)optionalPresent only when the guest manage page calls this route to pick a new time for an existing booking. Governs which notice policy gates the slots returned, since a reschedule cannot reuse the ordinary minimum-notice rule unchanged.
Responses
The free slots for the requested day, or one entry per day in ranged mode. May legitimately be empty.
service_id or date missing, more than six service ids, or days outside 1-14.
No active org with that slug, or ANY of the requested ids is not an active service in it.
The provider-link query failed. Returned instead of an empty slot list on purpose: a broken embed must never be indistinguishable from "fully booked".
Raw OpenAPI operation
{
"summary": "List bookable appointment slots",
"description": "The public availability grid. Reads through the service-role client inside this route rather than through an anon RLS policy, so there is one security model to reason about rather than two.\n\nSlot allocation is first-come: for a given start time the first provider found free wins it. There is no fairness or least-booked balancing. Slots whose start time has already passed are filtered out, because the booking POST rejects them anyway.\n\n**Ranged mode (`days`).** Answers up to 14 consecutive days from `date` in one request, for the wizard's landing walk, which used to fire one request per day (up to 14) hunting for the first open one. The single-date shape (`{slots}`) is unchanged and stays the default; sending `days` greater than 1 switches the response to `{days: {\"<date>\": {slots}, ...}}`, one entry per requested date.",
"tags": [
"Public booking (appointments)"
],
"operationId": "getAppointmentAvailability",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "service_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "One active service id of this org, or up to six comma-separated for a multi-service visit (0054). The slot maths runs on the SUM of the durations, and a specific provider must offer EVERY id; with `any`, eligibility is the intersection; one person carries the whole block. Any id unknown, inactive, or not this org's makes the whole request a 404; more than six is a 400."
},
{
"name": "date",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"description": "YYYY-MM-DD. Interpreted as a UTC wall clock, the same convention computeAvailableSlots uses. The first day answered in ranged mode."
},
{
"name": "provider_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Omit, or send `any`, to search every provider offering the service. A specific id that is not an active provider of this org offering this service returns an EMPTY slot list, not a 404; returning 404 would let a caller probe which provider ids exist."
},
{
"name": "days",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 14
},
"description": "How many consecutive days from `date` to answer in one call. Omitted or `1` keeps the original single-date `{slots}` shape; 2 to 14 switches the response to `{days: {\"<date>\": {slots}, ...}}`."
},
{
"name": "manage_token",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Present only when the guest manage page calls this route to pick a new time for an existing booking. Governs which notice policy gates the slots returned, since a reschedule cannot reuse the ordinary minimum-notice rule unchanged."
}
],
"responses": {
"200": {
"description": "The free slots for the requested day, or one entry per day in ranged mode. May legitimately be empty.",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"slots"
],
"properties": {
"slots": {
"type": "array",
"items": {
"type": "object",
"required": [
"startsAt",
"providerId"
],
"properties": {
"startsAt": {
"type": "string",
"format": "date-time"
},
"providerId": {
"type": "string",
"format": "uuid",
"description": "The provider who would take this slot."
}
}
}
}
}
},
{
"type": "object",
"required": [
"days"
],
"properties": {
"days": {
"type": "object",
"description": "Keyed by YYYY-MM-DD, one key per requested date, present only when `days` was sent as 2 or more.",
"additionalProperties": {
"type": "object",
"required": [
"slots"
],
"properties": {
"slots": {
"type": "array",
"items": {
"type": "object",
"required": [
"startsAt",
"providerId"
],
"properties": {
"startsAt": {
"type": "string",
"format": "date-time"
},
"providerId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
}
}
]
}
}
}
},
"400": {
"description": "`service_id` or `date` missing, more than six service ids, or `days` outside 1-14.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, or ANY of the requested ids is not an active service in it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The provider-link query failed. Returned instead of an empty slot list on purpose: a broken embed must never be indistinguishable from \"fully booked\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/book/{slug}/appointmentsBook an appointment (guest checkout)
/api/book/{slug}/appointmentsPublic guest checkout. The requested slot is re-derived server-side, not in the past, inside the provider's working hours, not inside a blocked period; because the wizard only offers valid slots but nothing stops a direct POST inventing one. Overlap is enforced by the database, not here.
The client record is upserted on (company_id, email), so a returning customer accumulates history on one row. notes goes on the appointment, never on the client record.
Engine-boundary note. This route checks only that the org is active; it does NOT check business_type. Its hospitality counterparts go through hospitalityCompany(), which refuses an appointments org outright. So the public surface enforces the engine boundary in one direction only: a hospitality org that has any active book_services row is publicly bookable here. See the "Two engines" section.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| serviceId | string (uuid) | The single-service spelling. Exactly one of |
| serviceIds | string (uuid)[] | (min items 1, max items 6) A multi-service visit (0054), in the guest's pick order; the first id becomes the row's primary service_id, the row's price/duration snapshots become the visit TOTALS, and the full breakdown is written to book_appointment_services (only when 2+). One appointment row, one calendar block, one manage token either way. |
| providerId* | string (uuid) | Must belong to this org, be active, and offer EVERY requested service. Unlike the availability route, |
| startsAt* | string (date-time) | |
| customer* | object | |
| customer.name* | string | (min length 1, max length 120) |
| customer.email* | string (email) | (max length 254) Lowercased before use; (company_id, email) is the client identity, so a returning guest lands back on their existing record. |
| customer.phone* | string | (min length 1, max length 40) Required, on both engines. Written to the client record unconditionally; it used to be omitted-when-blank so a later phone-less booking could not erase a number on file, and that request is now refused upstream instead. The column itself stays nullable: enforcement is the parser, not a constraint, so records that predate the rule stay readable. |
| customer.notes | string | null | (max length 1000) The guest's request for THIS booking. Written to the booking, never to the client record; book_customers.notes is the venue's own private note. |
| promoCode | string | Promotions (0163). Optional. Resolved server-side (resolveBestPromotion, src/lib/booking/promotions-server.ts) against the SAME rules the preview endpoint (POST /api/book/{slug}/promo-codes/validate) uses: an invalid/expired/exhausted/out-of-scope code refuses the whole booking with a 400 rather than silently booking at full price. An eligible automatic sale is found and applied even with no |
Responses
Booked. A confirmation notification is queued after the response is sent.
| Field | Type | Notes |
|---|---|---|
| appointment* | object | Snake_case, because it is the database row selected straight back. The only response in the API that is not camelCase. |
| appointment.id | string (uuid) | |
| appointment.starts_at | string (date-time) | |
| appointment.ends_at | string (date-time) | |
| calendar | object | "Add to calendar" for the confirmation screen, the same two links the confirmation email offers, built from the same booking so the two surfaces can never disagree. Both null when APP_URL/VERCEL_PROJECT_PRODUCTION_URL is unset (no absolute base to build them from), same convention every other emailed link in this API follows. |
| calendar.googleCalendarUrl | string | null (uri) | A pre-filled "create event" link on calendar.google.com. The guest saves it themselves, no OAuth, no token stored. |
| calendar.icsUrl | string | null (uri) | Downloads the same event as an .ics file, for Apple Calendar/Outlook/anything that is not Google. Served by GET /{slug}/book/manage/{token}/calendar.ics. |
| promotion | object | null | Promotions (0163). Null when nothing applied. |
| promotion.kind | "sale" | "code" | |
| promotion.discountCents | integer |
Missing fields, more than six services, an invalid email, an unparseable start time, a time in the past, a time outside the provider's working hours, a time inside a blocked period, or promoCode not found/inactive/out of scope/expired/at its usage limit.
No active org with that slug, any requested id not an active service in it, or the provider does not exist / is inactive / does not offer every requested service.
book_appointments_no_overlap fired; someone booked that exact slot first.
The client record could not be saved, the appointment insert failed for a reason other than an overlap, or the line-item insert failed (the appointment is deleted again; a booking never survives losing its itemization).
Raw OpenAPI operation
{
"summary": "Book an appointment (guest checkout)",
"description": "Public guest checkout. The requested slot is re-derived server-side, not in the past, inside the provider's working hours, not inside a blocked period; because the wizard only offers valid slots but nothing stops a direct POST inventing one. Overlap is enforced by the database, not here.\n\nThe client record is upserted on `(company_id, email)`, so a returning customer accumulates history on one row. `notes` goes on the appointment, never on the client record.\n\n**Engine-boundary note.** This route checks only that the org is `active`; it does NOT check `business_type`. Its hospitality counterparts go through `hospitalityCompany()`, which refuses an appointments org outright. So the public surface enforces the engine boundary in one direction only: a hospitality org that has any active `book_services` row is publicly bookable here. See the \"Two engines\" section.",
"tags": [
"Public booking (appointments)"
],
"operationId": "createGuestAppointment",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"providerId",
"startsAt",
"customer"
],
"properties": {
"serviceId": {
"type": "string",
"format": "uuid",
"description": "The single-service spelling. Exactly one of `serviceId` / `serviceIds` is required; if both are sent, `serviceIds` wins."
},
"serviceIds": {
"type": "array",
"minItems": 1,
"maxItems": 6,
"items": {
"type": "string",
"format": "uuid"
},
"description": "A multi-service visit (0054), in the guest's pick order; the first id becomes the row's primary service_id, the row's price/duration snapshots become the visit TOTALS, and the full breakdown is written to book_appointment_services (only when 2+). One appointment row, one calendar block, one manage token either way."
},
"providerId": {
"type": "string",
"format": "uuid",
"description": "Must belong to this org, be active, and offer EVERY requested service. Unlike the availability route, `any` is not accepted; pick one from the slot list."
},
"startsAt": {
"type": "string",
"format": "date-time"
},
"customer": {
"type": "object",
"required": [
"name",
"email",
"phone"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254,
"description": "Lowercased before use; (company_id, email) is the client identity, so a returning guest lands back on their existing record."
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Required, on both engines. Written to the client record unconditionally; it used to be omitted-when-blank so a later phone-less booking could not erase a number on file, and that request is now refused upstream instead. The column itself stays nullable: enforcement is the parser, not a constraint, so records that predate the rule stay readable."
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000,
"description": "The guest's request for THIS booking. Written to the booking, never to the client record; book_customers.notes is the venue's own private note."
}
}
},
"promoCode": {
"type": "string",
"description": "Promotions (0163). Optional. Resolved server-side (resolveBestPromotion, src/lib/booking/promotions-server.ts) against the SAME rules the preview endpoint (POST /api/book/{slug}/promo-codes/validate) uses: an invalid/expired/exhausted/out-of-scope code refuses the whole booking with a 400 rather than silently booking at full price. An eligible automatic sale is found and applied even with no `promoCode` sent at all; whichever discounts more wins when both apply."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Booked. A confirmation notification is queued after the response is sent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"appointment"
],
"properties": {
"appointment": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"starts_at": {
"type": "string",
"format": "date-time"
},
"ends_at": {
"type": "string",
"format": "date-time"
}
},
"description": "Snake_case, because it is the database row selected straight back. The only response in the API that is not camelCase."
},
"calendar": {
"type": "object",
"properties": {
"googleCalendarUrl": {
"type": [
"string",
"null"
],
"format": "uri",
"description": "A pre-filled \"create event\" link on calendar.google.com. The guest saves it themselves, no OAuth, no token stored."
},
"icsUrl": {
"type": [
"string",
"null"
],
"format": "uri",
"description": "Downloads the same event as an .ics file, for Apple Calendar/Outlook/anything that is not Google. Served by GET /{slug}/book/manage/{token}/calendar.ics."
}
},
"description": "\"Add to calendar\" for the confirmation screen, the same two links the confirmation email offers, built from the same booking so the two surfaces can never disagree. Both null when APP_URL/VERCEL_PROJECT_PRODUCTION_URL is unset (no absolute base to build them from), same convention every other emailed link in this API follows."
},
"promotion": {
"type": [
"object",
"null"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"sale",
"code"
]
},
"discountCents": {
"type": "integer"
}
},
"description": "Promotions (0163). Null when nothing applied. `appointment`'s own `price` (and any deposit charged) already reflects this discount: these two fields are for LABELLING what was applied, not for recomputing anything client-side."
}
}
}
}
}
},
"400": {
"description": "Missing fields, more than six services, an invalid email, an unparseable start time, a time in the past, a time outside the provider's working hours, a time inside a blocked period, or promoCode not found/inactive/out of scope/expired/at its usage limit.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, any requested id not an active service in it, or the provider does not exist / is inactive / does not offer every requested service.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "`book_appointments_no_overlap` fired; someone booked that exact slot first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The client record could not be saved, the appointment insert failed for a reason other than an overlap, or the line-item insert failed (the appointment is deleted again; a booking never survives losing its itemization).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/book/{slug}/promo-codes/validatePreview a promo code before checkout (public, unauthenticated)
/api/book/{slug}/promo-codes/validateNever creates or charges anything: a preview so the widget can say "that code works, $12 off" before the guest fills in their details. POST /api/book/{slug}/appointments (the real checkout) independently re-resolves the SAME code via the same resolveBestPromotion() (src/lib/booking/promotions-server.ts) and is the only answer ever actually charged; a "valid" preview here can still be refused at checkout if, for example, the guest turns out to have already used a once-per-customer code (this route has no customer id yet to check that against). Rate-limited on the tighter 'write' guest bucket (30/min): a code is a short, guessable string, and this is the one endpoint that lets someone probe a guess without booking anything.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| code* | string | |
| serviceIds* | string (uuid)[] | (min items 1) |
Responses
The code applies.
| Field | Type |
|---|---|
| discountCents* | integer |
No code/serviceIds, a service not found or unpriced, the code not found/inactive/out of scope/expired/at its usage limit, or an active sale already beats this code (told to the guest so a real code never reads as broken).
Unknown or inactive company slug.
Raw OpenAPI operation
{
"summary": "Preview a promo code before checkout (public, unauthenticated)",
"description": "Never creates or charges anything: a preview so the widget can say \"that code works, $12 off\" before the guest fills in their details. POST /api/book/{slug}/appointments (the real checkout) independently re-resolves the SAME code via the same resolveBestPromotion() (src/lib/booking/promotions-server.ts) and is the only answer ever actually charged; a \"valid\" preview here can still be refused at checkout if, for example, the guest turns out to have already used a once-per-customer code (this route has no customer id yet to check that against). Rate-limited on the tighter 'write' guest bucket (30/min): a code is a short, guessable string, and this is the one endpoint that lets someone probe a guess without booking anything.",
"tags": [
"Public booking (appointments)"
],
"operationId": "validatePromoCode",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"code",
"serviceIds"
],
"properties": {
"code": {
"type": "string"
},
"serviceIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"minItems": 1
}
}
}
}
}
},
"responses": {
"200": {
"description": "The code applies.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"discountCents"
],
"properties": {
"discountCents": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "No code/serviceIds, a service not found or unpriced, the code not found/inactive/out of scope/expired/at its usage limit, or an active sale already beats this code (told to the guest so a real code never reads as broken).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Unknown or inactive company slug.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}Public booking (hospitality)
Unauthenticated, two-step (hold, then confirm). Refuses an appointments org with 404.
post/api/book/{slug}/enquiriesSend a big-group enquiry (public)
/api/book/{slug}/enquiriesWhat a guest sends when their party is over the maxOnlinePartySize a venue has set (migration 0036). Creates a book_group_enquiries row and opens a thread on it, which lands in the dashboard inbox beside its booking conversations.
This is precisely the public unauthenticated write surface migration 0024 declined to build for intake forms; "a standalone shareable form URL would be a new unauthenticated write surface needing its own rate limiting and spam story". It inherits that whole obligation: guestRateLimit on the write budget runs FIRST, before the slug is even resolved, so this cannot be used to probe which slugs exist either.
A party at or below the ceiling is refused, and a venue that has set no ceiling at all refuses everything here. Without that check this endpoint would be an open "email the business" box on every hospitality org.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| partySize* | integer | (min 1, max 500) Must be ABOVE the |
| preferredDate | string | null (date) | A hint only; nothing allocates from it. A malformed value is a 400 rather than a silent drop. |
| preferredTime | string | null | (pattern ^([01]\d|2[0-3]):[0-5]\d$) A hint only, same as preferredDate. |
| name* | string | (min length 1, max length 120) |
| email* | string | (max length 254) Lowercased on the way in, same as guest checkout. |
| phone* | string | (min length 1, max length 40) |
| message | string | null | (max length 2000) |
| answers | object | The extra questions this venue asks, keyed by field key and validated against its saved |
Responses
Sent.
| Field | Type |
|---|---|
| ok* | true |
| enquiryId* | string (uuid) |
A validation message, an answer that does not match the saved form, or a party size that does not need an enquiry.
No active org with that slug, or the org is not a hospitality business.
The enquiry could not be stored.
Raw OpenAPI operation
{
"summary": "Send a big-group enquiry (public)",
"description": "What a guest sends when their party is over the `maxOnlinePartySize` a venue has set (migration 0036). Creates a `book_group_enquiries` row and opens a thread on it, which lands in the dashboard inbox beside its booking conversations.\n\nThis is precisely the public unauthenticated write surface migration 0024 declined to build for intake forms; \"a standalone shareable form URL would be a new unauthenticated write surface needing its own rate limiting and spam story\". It inherits that whole obligation: `guestRateLimit` on the `write` budget runs FIRST, before the slug is even resolved, so this cannot be used to probe which slugs exist either.\n\n**A party at or below the ceiling is refused**, and a venue that has set no ceiling at all refuses everything here. Without that check this endpoint would be an open \"email the business\" box on every hospitality org.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "createGroupEnquiry",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"partySize",
"name",
"email",
"phone"
],
"additionalProperties": false,
"properties": {
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Must be ABOVE the `maxOnlinePartySize` this venue has set; a smaller party is refused, because it can be booked directly and this is not a general contact form."
},
"preferredDate": {
"type": [
"string",
"null"
],
"format": "date",
"description": "A hint only; nothing allocates from it. A malformed value is a 400 rather than a silent drop."
},
"preferredTime": {
"type": [
"string",
"null"
],
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "A hint only, same as preferredDate."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": "string",
"maxLength": 254,
"description": "Lowercased on the way in, same as guest checkout."
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"message": {
"type": [
"string",
"null"
],
"maxLength": 2000
},
"answers": {
"type": "object",
"additionalProperties": true,
"description": "The extra questions this venue asks, keyed by field key and validated against its saved `group_enquiry` form by the same validator the booking-attached intake form uses."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Sent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"enquiryId"
],
"properties": {
"ok": {
"const": true
},
"enquiryId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"400": {
"description": "A validation message, an answer that does not match the saved form, or a party size that does not need an enquiry.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, or the org is not a hospitality business.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The enquiry could not be stored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/book/{slug}/waitlistJoin the waitlist (public)
/api/book/{slug}/waitlistWhat a guest leaves behind when a date came back with no tables at all (migration 0044). Creates a book_waitlist_entries row with status open, which lands on the venue's reservations screen.
Until this existed, a fully-booked Friday was a dead end; the widget said "try another day" and the guest left, taking with them the one signal a booking page most wants. Nothing is held or promised by joining. A table is offered later by a member pressing a button, and the guest re-books through the ordinary flow; whoever gets there first gets it.
Same obligations as every other public write here: guestRateLimit on the write budget runs FIRST, before the slug is resolved and before the body is parsed, so this cannot be used to probe which slugs exist. A party ABOVE the venue's maxOnlinePartySize is refused with enquiryRequired; above that line the venue has said it wants to look at the group, and a waitlist entry is a promise that a table might simply be offered.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| requestedDate* | string (date) | The day being waited on. Must not be in the past (UTC) and must be within 365 days. |
| partySize* | integer | (min 1, max 30) Must be at or below the |
| requestedFrom | string | null | (pattern ^([01]\d|2[0-3]):[0-5]\d$) Earliest acceptable time. Null with |
| requestedTo | string | null | (pattern ^([01]\d|2[0-3]):[0-5]\d$) Latest acceptable time. Must not be earlier than |
| name* | string | (min length 1, max length 120) |
| email* | string | (max length 254) Lowercased on the way in, same as guest checkout. |
| phone* | string | (min length 1, max length 40) Required. A waitlist offer is time-critical, and a number is what makes it actionable inside the window it exists in. |
| notes | string | null | (max length 2000) |
| experienceId | string | null (uuid) | Joins the waitlist for one specific published Experience (migration 0207) rather than a sold-out night in general. A stale or unknown id is silently dropped rather than rejected; a waitlist join is not worth 404ing a disappointed guest over. |
Responses
Joined.
| Field | Type |
|---|---|
| ok* | true |
| waitlistId* | string (uuid) |
A validation message, or a party size above the venue's online ceiling (carries enquiryRequired: true).
No active org with that slug, or the org is not a hospitality business.
The entry could not be stored.
Raw OpenAPI operation
{
"summary": "Join the waitlist (public)",
"description": "What a guest leaves behind when a date came back with **no tables at all** (migration 0044). Creates a `book_waitlist_entries` row with status `open`, which lands on the venue's reservations screen.\n\nUntil this existed, a fully-booked Friday was a dead end; the widget said \"try another day\" and the guest left, taking with them the one signal a booking page most wants. **Nothing is held or promised by joining.** A table is offered later by a member pressing a button, and the guest re-books through the ordinary flow; whoever gets there first gets it.\n\nSame obligations as every other public write here: `guestRateLimit` on the `write` budget runs FIRST, before the slug is resolved and before the body is parsed, so this cannot be used to probe which slugs exist. A party ABOVE the venue's `maxOnlinePartySize` is refused with `enquiryRequired`; above that line the venue has said it wants to look at the group, and a waitlist entry is a promise that a table might simply be offered.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "joinWaitlist",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"requestedDate",
"partySize",
"name",
"email",
"phone"
],
"additionalProperties": false,
"properties": {
"requestedDate": {
"type": "string",
"format": "date",
"description": "The day being waited on. Must not be in the past (UTC) and must be within 365 days."
},
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"description": "Must be at or below the `maxOnlinePartySize` this venue has set; a larger party belongs in the big-group enquiry flow, because above that line the venue wants to look at the group rather than simply offer it a table."
},
"requestedFrom": {
"type": [
"string",
"null"
],
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "Earliest acceptable time. Null with `requestedTo` also null means any time that day, which is the default and the common case."
},
"requestedTo": {
"type": [
"string",
"null"
],
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"description": "Latest acceptable time. Must not be earlier than `requestedFrom`."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": "string",
"maxLength": 254,
"description": "Lowercased on the way in, same as guest checkout."
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Required. A waitlist offer is time-critical, and a number is what makes it actionable inside the window it exists in."
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 2000
},
"experienceId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Joins the waitlist for one specific published Experience (migration 0207) rather than a sold-out night in general. A stale or unknown id is silently dropped rather than rejected; a waitlist join is not worth 404ing a disappointed guest over."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Joined.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"waitlistId"
],
"properties": {
"ok": {
"const": true
},
"waitlistId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"400": {
"description": "A validation message, or a party size above the venue's online ceiling (carries `enquiryRequired: true`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, or the org is not a hospitality business.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The entry could not be stored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/book/{slug}/reservations/availabilityList bookable table slots
/api/book/{slug}/reservations/availabilityThe hospitality availability grid. Expired holds for this org are swept before reading, which makes the system self-healing: an exclusion-constraint predicate cannot call now(), so a dead hold would otherwise keep blocking its table until something deleted it.
Live holds are counted as busy on purpose; a hold is exactly as blocking as a confirmed booking until it expires.
tableId is deliberately NOT returned. It would leak the floor plan, and a client-held allocation goes stale the moment someone else books; the POST re-runs the allocator server-side regardless. GET .../table-options is the one scoped, opt-in exception, for orgs with tablePickerEnabled on.
area_id/level_id (migration 0030) narrow the candidate tables before allocating; passing a level_id INCLUDES every area nested under it, not just tables sitting directly on the level with no room, so a fully-subdivided floor is not a location choice that always resolves to nothing.
Ranged mode (`days`). Same contract as the appointments twin, and the same reason: the wizard's landing walk used to fire one request per day (up to 14), each paying the full fan-out and a hold-sweep DELETE. The single-date shape ({slots, reason}) is unchanged and stays the default; sending days greater than 1 switches the response to {days: {"<date>": {slots, reason}, ...}}.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.date*querystring (date)YYYY-MM-DD, strictly. A non-matching string is a 400, not a coerced date. The first day answered in ranged mode.
party_size*queryintegerCapped at 30: party size is attacker-controlled and feeds a slot loop.
area_idquerystring (uuid)optionalNarrows to one room. Mutually exclusive with level_id; sending both is a 400.
level_idquerystring (uuid)optionalNarrows to one floor, INCLUSIVE of every room nested under it. Mutually exclusive with area_id; sending both is a 400.
experience_idquerystring (uuid)optionalScopes the grid to one published Experience (migration 0207): a dated Experience answers ONLY on its own event date (every other date returns
reason: "closed"), an Experience restricted to specific service periods is narrowed to them, and one with its own party bounds is validated againstparty_size(400 if outside them) and its owncapacity_per_sitting, if set, independently of the venue-widemax_covers/maxOnlinePartySize. A 404 if the id does not resolve to a published, non-archived Experience for this org.daysqueryintegeroptionalHow many consecutive days from
dateto answer in one call. Omitted or1keeps the original single-date{slots, reason}shape; 2 to 14 switches the response to{days: {"<date>": {slots, reason}, ...}}.manage_tokenquerystring (uuid)optionalPresent only when the guest manage page calls this route to pick a new time for an existing booking. See the appointments twin's identical parameter for the full reasoning.
Responses
The free start times for the requested day (or one entry per day in ranged mode), party size and location filter; plus, when a single day has none, WHY.
Missing or malformed date, a party_size outside 1-30, both area_id and level_id sent together, or days outside 1-14.
No active org with that slug, OR the org is an appointments business. Both collapse to the same 404; the reservation endpoints must not half-work for a business with no tables.
The special-hours read (migration 0109) failed. Deliberately loud rather than fail-open, and it is the one read here that is: absence of a row means "follow the ordinary week", so a failed read is indistinguishable from no rows and would silently reopen a date the venue closed. Retrying is worthwhile.
Raw OpenAPI operation
{
"summary": "List bookable table slots",
"description": "The hospitality availability grid. Expired holds for this org are swept before reading, which makes the system self-healing: an exclusion-constraint predicate cannot call `now()`, so a dead hold would otherwise keep blocking its table until something deleted it.\n\nLive holds are counted as busy on purpose; a hold is exactly as blocking as a confirmed booking until it expires.\n\n`tableId` is deliberately NOT returned. It would leak the floor plan, and a client-held allocation goes stale the moment someone else books; the POST re-runs the allocator server-side regardless. `GET .../table-options` is the one scoped, opt-in exception, for orgs with `tablePickerEnabled` on.\n\n`area_id`/`level_id` (migration 0030) narrow the candidate tables before allocating; passing a `level_id` INCLUDES every area nested under it, not just tables sitting directly on the level with no room, so a fully-subdivided floor is not a location choice that always resolves to nothing.\n\n**Ranged mode (`days`).** Same contract as the appointments twin, and the same reason: the wizard's landing walk used to fire one request per day (up to 14), each paying the full fan-out and a hold-sweep DELETE. The single-date shape (`{slots, reason}`) is unchanged and stays the default; sending `days` greater than 1 switches the response to `{days: {\"<date>\": {slots, reason}, ...}}`.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "getReservationAvailability",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "date",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"description": "YYYY-MM-DD, strictly. A non-matching string is a 400, not a coerced date. The first day answered in ranged mode."
},
{
"name": "party_size",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"description": "Capped at 30: party size is attacker-controlled and feeds a slot loop."
},
{
"name": "area_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Narrows to one room. Mutually exclusive with level_id; sending both is a 400."
},
{
"name": "level_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Narrows to one floor, INCLUSIVE of every room nested under it. Mutually exclusive with area_id; sending both is a 400."
},
{
"name": "experience_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Scopes the grid to one published Experience (migration 0207): a dated Experience answers ONLY on its own event date (every other date returns `reason: \"closed\"`), an Experience restricted to specific service periods is narrowed to them, and one with its own party bounds is validated against `party_size` (400 if outside them) and its own `capacity_per_sitting`, if set, independently of the venue-wide `max_covers`/`maxOnlinePartySize`. A 404 if the id does not resolve to a published, non-archived Experience for this org."
},
{
"name": "days",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 14
},
"description": "How many consecutive days from `date` to answer in one call. Omitted or `1` keeps the original single-date `{slots, reason}` shape; 2 to 14 switches the response to `{days: {\"<date>\": {slots, reason}, ...}}`."
},
{
"name": "manage_token",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Present only when the guest manage page calls this route to pick a new time for an existing booking. See the appointments twin's identical parameter for the full reasoning."
}
],
"responses": {
"200": {
"description": "The free start times for the requested day (or one entry per day in ranged mode), party size and location filter; plus, when a single day has none, WHY.",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"slots",
"reason"
],
"properties": {
"slots": {
"type": "array",
"items": {
"type": "object",
"required": [
"startsAt"
],
"properties": {
"startsAt": {
"type": "string",
"format": "date-time"
}
}
}
},
"reason": {
"type": [
"string",
"null"
],
"enum": [
"full",
"closed",
"no_service_period",
"no_tables",
"too_large",
"past",
"locked",
null
],
"description": "Null whenever `slots` is non-empty. Otherwise it names why the day is empty, because **empty is not the same as full**: a venue that is shut must not be presented as fully booked.\n\n`full` sittings exist, the party fits, and every one is taken. The only value that is a real demand signal, and **the only one the booking widget will offer a waitlist for**.\n`closed` a whole-venue closure covers the day (`book_blocked_periods` with a null `provider_id`); what a venue sets when it decides not to take bookings on a date.\n`no_service_period` no active service period covers this weekday; the venue does not trade then.\n`no_tables` the venue has no active tables, or none inside the chosen area/level.\n`too_large` no table and no legal combination could seat this party even if the venue were empty. Easily mistaken for `full`, but no cancellation will ever help.\n`past` every sitting for this date has already started.\n`locked` reserved for per-floor/room/table locks; not yet emitted.\n\nDerived by re-running the allocator a second time against an empty venue, so `too_large` means exactly what the checkout would decide."
}
}
},
{
"type": "object",
"required": [
"days"
],
"properties": {
"days": {
"type": "object",
"description": "Keyed by YYYY-MM-DD, one key per requested date, present only when `days` was sent as 2 or more; each value is the same `{slots, reason}` shape as the single-date response.",
"additionalProperties": {
"type": "object"
}
}
}
}
]
}
}
}
},
"400": {
"description": "Missing or malformed `date`, a `party_size` outside 1-30, both `area_id` and `level_id` sent together, or `days` outside 1-14.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, OR the org is an appointments business. Both collapse to the same 404; the reservation endpoints must not half-work for a business with no tables.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The special-hours read (migration 0109) failed. Deliberately loud rather than fail-open, and it is the one read here that is: absence of a row means \"follow the ordinary week\", so a failed read is indistinguishable from no rows and would silently reopen a date the venue closed. Retrying is worthwhile.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/book/{slug}/reservations/table-optionsList which tables are free for one exact slot
/api/book/{slug}/reservations/table-optionsThe scoped, opt-in reversal of the availability route's "no tableId" stance (migration 0030): given a slot the guest already has from the ordinary grid, lists the tables actually free at that instant, with name and seat count, never coordinates, since none exist in this schema.
Returns 404, not 403, when `tablePickerEnabled` is off. Checked here at request time against the real row, not trusted from a widget's cached config; an admin who just switched it off must not have a stale tab keep listing table names. This is the actual security boundary for the reversal; the widget's own gate on rendering the sub-step is a courtesy on top of it, not the guard itself.
An instant with no matching slot (outside every period, no table fits, or inside a closure) is not an error; it returns an empty list, the same graceful floor a filtered-out area produces on the main grid.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.date*querystring (date)YYYY-MM-DD, strictly.
party_size*queryintegerstarts_at*querystring (date-time)The exact slot start the guest picked from the availability grid.
area_idquerystring (uuid)optionalSame filter as the availability route. Mutually exclusive with level_id.
level_idquerystring (uuid)optionalSame filter as the availability route, inclusive of nested rooms. Mutually exclusive with area_id.
Responses
Tables free at that instant, smallest-first; the first entry is always the same table auto-pick would choose.
| Field | Type |
|---|---|
| tables* | object[] |
| tables[].id | string (uuid) |
| tables[].name | string |
| tables[].seatsMin | integer |
| tables[].seatsMax | integer |
Missing/malformed date, party_size, or starts_at, or both area_id and level_id sent together.
No active org with that slug, the org is not a hospitality business, or the org has not turned on table selection.
The special-hours read (migration 0109) failed. Reported rather than answered as an empty table list, for the same reason the availability route reports it: a silent empty list is how a broken read hides.
Raw OpenAPI operation
{
"summary": "List which tables are free for one exact slot",
"description": "The scoped, opt-in reversal of the availability route's \"no tableId\" stance (migration 0030): given a slot the guest already has from the ordinary grid, lists the tables actually free at that instant, with name and seat count, never coordinates, since none exist in this schema.\n\n**Returns 404, not 403, when `tablePickerEnabled` is off.** Checked here at request time against the real row, not trusted from a widget's cached config; an admin who just switched it off must not have a stale tab keep listing table names. This is the actual security boundary for the reversal; the widget's own gate on rendering the sub-step is a courtesy on top of it, not the guard itself.\n\nAn instant with no matching slot (outside every period, no table fits, or inside a closure) is not an error; it returns an empty list, the same graceful floor a filtered-out area produces on the main grid.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "getReservationTableOptions",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "date",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"description": "YYYY-MM-DD, strictly."
},
{
"name": "party_size",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 30
}
},
{
"name": "starts_at",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "The exact slot start the guest picked from the availability grid."
},
{
"name": "area_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Same filter as the availability route. Mutually exclusive with level_id."
},
{
"name": "level_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Same filter as the availability route, inclusive of nested rooms. Mutually exclusive with area_id."
}
],
"responses": {
"200": {
"description": "Tables free at that instant, smallest-first; the first entry is always the same table auto-pick would choose.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"tables"
],
"properties": {
"tables": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"seatsMin": {
"type": "integer"
},
"seatsMax": {
"type": "integer"
}
}
}
}
}
}
}
}
},
"400": {
"description": "Missing/malformed `date`, `party_size`, or `starts_at`, or both `area_id` and `level_id` sent together.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, the org is not a hospitality business, or the org has not turned on table selection.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The special-hours read (migration 0109) failed. Reported rather than answered as an empty table list, for the same reason the availability route reports it: a silent empty list is how a broken read hides.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/book/{slug}/reservationsHold a table (guest checkout, step 1)
/api/book/{slug}/reservationsTakes a 10-minute hold on a table and returns its id. The guest then confirms it with PATCH once they have typed their details; that gap is exactly what the hold protects.
The slot is re-derived server-side and matched exactly against the requested startsAt. The exclusion constraint, not the availability read, is what actually makes the allocation safe.
400 versus 409. The two are not interchangeable and this route distinguishes them: 400 means the request can never succeed (that time is outside every service period, no table seats a party that size, or the venue is closed), 409 means it was bookable and someone else has it. When the allocator comes back empty it is re-run against an empty venue to decide which. The appointments checkout draws the same line, and the widget relies on it; it re-loads the availability grid on 409 only.
areaId/levelId (migration 0030) narrow the candidate tables the same way the availability route does. tableId requests a specific one of them; honoured only if tablePickerEnabled is on for this org (silently ignored, not rejected, otherwise, so a stale client that cached the toggle a moment before an admin disabled it still completes a normal auto-pick booking) and only if that table is still in the candidate set for this EXACT slot; losing that race is the same 409 family as every other "someone else got there first" outcome here, not a 400; the time itself is still bookable, just not with that one table.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| startsAt* | string (date-time) | Must match a computed slot start exactly. |
| partySize* | integer | (min 1, max 30) |
| areaId | string | null (uuid) | Narrows to one room. Mutually exclusive with levelId; sending both is a 400. |
| levelId | string | null (uuid) | Narrows to one floor, inclusive of every room nested under it. Mutually exclusive with areaId; sending both is a 400. |
| tableId | string | null (uuid) | A guest's specific table preference. Ignored (not rejected) when the org has not turned on table selection. |
| experienceId | string | null (uuid) | Books this hold against a published Experience (migration 0207): re-validates |
Responses
Table held.
| Field | Type | Notes |
|---|---|---|
| reservationId* | string (uuid) | Knowing this id IS the capability to confirm or release the hold. Treat it as a secret. |
| expiresAt* | string (date-time) | |
| holdMinutes* | integer | Currently 10. |
The request can never succeed as sent: party size out of range, an unparseable start time, a time in the past, a whole-venue closure covering that window, both areaId and levelId sent together, a start time that is not a slot for this party size at all (outside every service period, or no table that seats them), or (with experienceId) a party size outside that Experience's own bounds, or a date other than its event_date if it is a one-off.
No active org with that slug, or the org is not a hospitality business.
That start time IS a slot for this party size, but every suitable table is taken; either already, between the availability read and the insert (book_reservations_no_overlap), or (with a tableId request) that specific table was the one that went.
The hold insert failed for some other reason.
Raw OpenAPI operation
{
"summary": "Hold a table (guest checkout, step 1)",
"description": "Takes a 10-minute hold on a table and returns its id. The guest then confirms it with PATCH once they have typed their details; that gap is exactly what the hold protects.\n\nThe slot is re-derived server-side and matched exactly against the requested `startsAt`. The exclusion constraint, not the availability read, is what actually makes the allocation safe.\n\n**400 versus 409.** The two are not interchangeable and this route distinguishes them: 400 means the request can never succeed (that time is outside every service period, no table seats a party that size, or the venue is closed), 409 means it was bookable and someone else has it. When the allocator comes back empty it is re-run against an empty venue to decide which. The appointments checkout draws the same line, and the widget relies on it; it re-loads the availability grid on 409 only.\n\n`areaId`/`levelId` (migration 0030) narrow the candidate tables the same way the availability route does. `tableId` requests a specific one of them; honoured only if `tablePickerEnabled` is on for this org (silently ignored, not rejected, otherwise, so a stale client that cached the toggle a moment before an admin disabled it still completes a normal auto-pick booking) and only if that table is still in the candidate set for this EXACT slot; losing that race is the same 409 family as every other \"someone else got there first\" outcome here, not a 400; the time itself is still bookable, just not with that one table.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "holdTable",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"startsAt",
"partySize"
],
"properties": {
"startsAt": {
"type": "string",
"format": "date-time",
"description": "Must match a computed slot start exactly."
},
"partySize": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"areaId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Narrows to one room. Mutually exclusive with levelId; sending both is a 400."
},
"levelId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Narrows to one floor, inclusive of every room nested under it. Mutually exclusive with areaId; sending both is a 400."
},
"tableId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "A guest's specific table preference. Ignored (not rejected) when the org has not turned on table selection."
},
"experienceId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Books this hold against a published Experience (migration 0207): re-validates `partySize` against its own min/max (400 if outside), re-validates the date against its own `event_date` if it is a one-off, narrows the slot to its own service periods, and applies its own `capacity_per_sitting` independently of the venue-wide `maxOnlinePartySize`, which an Experience with wider bounds deliberately bypasses. Its own `charge_mode` (`per_person` or `flat`) governs the deposit computed for this hold instead of the venue's own `deposit_per_person` setting."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Table held.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"reservationId",
"expiresAt",
"holdMinutes"
],
"properties": {
"reservationId": {
"type": "string",
"format": "uuid",
"description": "Knowing this id IS the capability to confirm or release the hold. Treat it as a secret."
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"holdMinutes": {
"type": "integer",
"description": "Currently 10."
}
}
}
}
}
},
"400": {
"description": "The request can never succeed as sent: party size out of range, an unparseable start time, a time in the past, a whole-venue closure covering that window, both `areaId` and `levelId` sent together, a start time that is not a slot for this party size at all (outside every service period, or no table that seats them), or (with `experienceId`) a party size outside that Experience's own bounds, or a date other than its `event_date` if it is a one-off.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, or the org is not a hospitality business.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "That start time IS a slot for this party size, but every suitable table is taken; either already, between the availability read and the insert (`book_reservations_no_overlap`), or (with a `tableId` request) that specific table was the one that went.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The hold insert failed for some other reason.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/book/{slug}/reservations/{id}Confirm a held table (guest checkout, step 2)
/api/book/{slug}/reservations/{id}Attaches the guest's details and promotes the hold to confirmed. The update is a compare-and-set on status = hold, so two racing confirms cannot both succeed; the loser gets 410 and, importantly, does not send a "table confirmed" email for a booking it did not make.
hold_expires_at is cleared in the same statement because the check constraint is biconditional; setting status alone would fail.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.id*pathstringThe
reservationIdreturned by the hold. Scoped to the slug, so one venue's URL can never confirm another venue's hold.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| customer* | object | |
| customer.name* | string | (min length 1, max length 120) |
| customer.email* | string (email) | (max length 254) Lowercased before use; (company_id, email) is the client identity, so a returning guest lands back on their existing record. |
| customer.phone* | string | (min length 1, max length 40) Required, on both engines. Written to the client record unconditionally; it used to be omitted-when-blank so a later phone-less booking could not erase a number on file, and that request is now refused upstream instead. The column itself stays nullable: enforcement is the parser, not a constraint, so records that predate the rule stay readable. |
| customer.notes | string | null | (max length 1000) The guest's request for THIS booking. Written to the booking, never to the client record; book_customers.notes is the venue's own private note. |
| occasion | string | null | Truncated to 80 characters rather than rejected. |
Responses
Confirmed. A confirmation notification is queued after the response is sent.
| Field | Type | Notes |
|---|---|---|
| reservation* | object | Snake_case; the database row selected straight back. |
| reservation.id | string (uuid) | |
| reservation.starts_at | string (date-time) | |
| reservation.ends_at | string (date-time) | |
| reservation.party_size | integer |
Invalid guest details: a missing name, a malformed email, a MISSING PHONE NUMBER, or over-length phone/notes. Same parser and therefore the same messages as the appointments guest checkout.
No active org with that slug, or the org is not a hospitality business.
The hold is gone: already confirmed, cancelled, swept after expiring, or lost to a racing confirm. 410 rather than 404 so the widget can say "your hold ran out, pick again" instead of "this venue does not exist".
The client record could not be saved, or the promotion query errored.
Raw OpenAPI operation
{
"summary": "Confirm a held table (guest checkout, step 2)",
"description": "Attaches the guest's details and promotes the hold to `confirmed`. The update is a compare-and-set on `status = hold`, so two racing confirms cannot both succeed; the loser gets 410 and, importantly, does not send a \"table confirmed\" email for a booking it did not make.\n\n`hold_expires_at` is cleared in the same statement because the check constraint is biconditional; setting status alone would fail.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "confirmHeldTable",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The `reservationId` returned by the hold. Scoped to the slug, so one venue's URL can never confirm another venue's hold."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"customer"
],
"properties": {
"customer": {
"type": "object",
"required": [
"name",
"email",
"phone"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254,
"description": "Lowercased before use; (company_id, email) is the client identity, so a returning guest lands back on their existing record."
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Required, on both engines. Written to the client record unconditionally; it used to be omitted-when-blank so a later phone-less booking could not erase a number on file, and that request is now refused upstream instead. The column itself stays nullable: enforcement is the parser, not a constraint, so records that predate the rule stay readable."
},
"notes": {
"type": [
"string",
"null"
],
"maxLength": 1000,
"description": "The guest's request for THIS booking. Written to the booking, never to the client record; book_customers.notes is the venue's own private note."
}
}
},
"occasion": {
"type": [
"string",
"null"
],
"description": "Truncated to 80 characters rather than rejected."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Confirmed. A confirmation notification is queued after the response is sent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"reservation"
],
"properties": {
"reservation": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"starts_at": {
"type": "string",
"format": "date-time"
},
"ends_at": {
"type": "string",
"format": "date-time"
},
"party_size": {
"type": "integer"
}
},
"description": "Snake_case; the database row selected straight back."
}
}
}
}
}
},
"400": {
"description": "Invalid guest details: a missing name, a malformed email, a MISSING PHONE NUMBER, or over-length phone/notes. Same parser and therefore the same messages as the appointments guest checkout.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No active org with that slug, or the org is not a hospitality business.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"410": {
"description": "The hold is gone: already confirmed, cancelled, swept after expiring, or lost to a racing confirm. 410 rather than 404 so the widget can say \"your hold ran out, pick again\" instead of \"this venue does not exist\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The client record could not be saved, or the promotion query errored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/book/{slug}/reservations/{id}Release a held table
/api/book/{slug}/reservations/{id}The guest closed the sheet or hit back. Releases the table immediately rather than waiting out the hold window. Only ever deletes a row whose status is still hold; a confirmed booking is cancelled from the dashboard or the manage link, never dropped by an unauthenticated caller holding an id.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.id*pathstringThe reservation id from the hold.
Responses
Always returned for a valid slug, including for an unknown id; this route deliberately never 404s on the id. released is what actually reports the outcome: false means the id pointed at a confirmed booking (which this refuses to touch) or at a hold already swept.
| Field | Type |
|---|---|
| released* | boolean |
No active org with that slug, or the org is not a hospitality business. The only 404 this route can produce.
Raw OpenAPI operation
{
"summary": "Release a held table",
"description": "The guest closed the sheet or hit back. Releases the table immediately rather than waiting out the hold window. Only ever deletes a row whose status is still `hold`; a confirmed booking is cancelled from the dashboard or the manage link, never dropped by an unauthenticated caller holding an id.",
"tags": [
"Public booking (hospitality)"
],
"operationId": "releaseHeldTable",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The reservation id from the hold."
}
],
"responses": {
"200": {
"description": "Always returned for a valid slug, including for an unknown id; this route deliberately never 404s on the id. `released` is what actually reports the outcome: `false` means the id pointed at a confirmed booking (which this refuses to touch) or at a hold already swept.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"released"
],
"properties": {
"released": {
"type": "boolean"
}
}
}
}
}
},
"404": {
"description": "No active org with that slug, or the org is not a hospitality business. The only 404 this route can produce.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}Guest self-service
Unauthenticated except for the booking's manage token, which is the whole credential. One endpoint serves both engines because a manage link points at a booking, not at a product.
post/api/book/{slug}/manage/{token}Message the venue about a booking
/api/book/{slug}/manage/{token}Allowed regardless of the change cutoff and on any status, unlike PATCH and DELETE. "I am running late" and "why was this cancelled" are exactly the moments someone needs to reach a venue; refusing them because the booking is two hours away would be backwards.
A manage link is public to anyone holding it, so there is a flood cap: 20 guest messages per booking, after which the guest is told to contact the venue directly. That is a cheap guard, not real rate limiting; there is none anywhere in this API yet.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.token*pathstringThe booking's manage token.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| message* | string | (min length 1, max length 2000) |
Responses
Sent. It appears in the venue's Inbox.
| Field | Type |
|---|---|
| ok* | true |
An empty message, or one over 2000 characters.
The link does not resolve.
This booking already has 20 guest messages against it. The only 429 in the API, and it is a per-booking cap rather than a rate limit.
The insert failed.
Raw OpenAPI operation
{
"summary": "Message the venue about a booking",
"description": "Allowed regardless of the change cutoff and on any status, unlike PATCH and DELETE. \"I am running late\" and \"why was this cancelled\" are exactly the moments someone needs to reach a venue; refusing them because the booking is two hours away would be backwards.\n\nA manage link is public to anyone holding it, so there is a flood cap: 20 guest messages per booking, after which the guest is told to contact the venue directly. That is a cheap guard, not real rate limiting; there is none anywhere in this API yet.",
"tags": [
"Guest self-service"
],
"operationId": "guestMessageVenue",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's manage token."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string",
"minLength": 1,
"maxLength": 2000
}
}
}
}
}
},
"responses": {
"200": {
"description": "Sent. It appears in the venue's Inbox.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An empty message, or one over 2000 characters.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The link does not resolve.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "This booking already has 20 guest messages against it. The only 429 in the API, and it is a per-booking cap rather than a rate limit.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The insert failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}put/api/book/{slug}/manage/{token}Answer the venue's intake form
/api/book/{slug}/manage/{token}The guest fills in whatever questions the org defined for this booking (migration 0024). Serves both engines for the same reason PATCH does; the token is a link to a booking, and which engine that booking belongs to is resolved from the org's business_type inside loadByToken.
PUT rather than POST because there is exactly one response per booking, enforced by a partial unique index per subject id: a guest reopening their link EDITS their answers rather than stacking a second submission staff would have to reconcile.
The form is re-resolved server-side rather than taken from the body: the page may have been rendered days ago, and the org may have switched forms or deactivated one since. A service-specific form wins over the org-wide one, and only active forms are served. Answers are validated against that form; required questions present, dropdown answers within their options, lengths capped, and fields_snapshot is rewritten from it on every save, so staff always read these answers against the questions that were actually on screen.
Deliberately NOT gated on the org's guest-self-service policy, unlike PATCH and DELETE, and for the same reason POST is not: an org that switched off guest rescheduling still asked these questions, and a notice period is backwards here; the hour before a visit is exactly when someone remembers their allergy.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.token*pathstringThe booking's manage token.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| answers* | object | Keyed by each question's stable |
Responses
Saved. Staff see it on the booking.
| Field | Type |
|---|---|
| ok* | true |
An answer failed validation. The message names the question: a missing required answer, an unticked required checkbox, a dropdown answer outside its choices, a value of the wrong type, an over-length answer (500 characters for a short answer, 2000 for a long one), or a key this form does not define.
The booking is cancelled, or it has already finished; there is nothing left to prepare for. Carries a guest-readable sentence.
The link does not resolve, or this org has no active intake form for this booking. The two are the same status on purpose: from outside, "no such link" and "nothing to fill in" are equally not-a-resource.
A second submit raced the first; two tabs, or a double tap, and the one-response-per-booking index caught the loser.
The insert or update errored.
Raw OpenAPI operation
{
"summary": "Answer the venue's intake form",
"description": "The guest fills in whatever questions the org defined for this booking (migration 0024). Serves both engines for the same reason PATCH does; the token is a link to *a booking*, and which engine that booking belongs to is resolved from the org's `business_type` inside `loadByToken`.\n\nPUT rather than POST because there is exactly one response per booking, enforced by a partial unique index per subject id: a guest reopening their link EDITS their answers rather than stacking a second submission staff would have to reconcile.\n\nThe form is re-resolved server-side rather than taken from the body: the page may have been rendered days ago, and the org may have switched forms or deactivated one since. A service-specific form wins over the org-wide one, and only `active` forms are served. Answers are validated against that form; required questions present, dropdown answers within their `options`, lengths capped, and `fields_snapshot` is rewritten from it on every save, so staff always read these answers against the questions that were actually on screen.\n\nDeliberately NOT gated on the org's guest-self-service policy, unlike PATCH and DELETE, and for the same reason POST is not: an org that switched off guest rescheduling still asked these questions, and a notice period is backwards here; the hour before a visit is exactly when someone remembers their allergy.",
"tags": [
"Guest self-service"
],
"operationId": "guestSubmitIntake",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's manage token."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"answers"
],
"properties": {
"answers": {
"type": "object",
"description": "Keyed by each question's stable `key`. Values are strings for `text`, `textarea` and `select`, booleans for `checkbox`. A key the form does not define is a 400 rather than being dropped; silently discarding something a guest typed is the worst available outcome for a form about allergies. Blank optional answers are omitted from storage rather than stored as `\"\"`, so staff read \"not answered\" instead of a deliberate blank.",
"additionalProperties": {
"type": [
"string",
"boolean"
]
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved. Staff see it on the booking.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "An answer failed validation. The message names the question: a missing required answer, an unticked required checkbox, a dropdown answer outside its choices, a value of the wrong type, an over-length answer (500 characters for a short answer, 2000 for a long one), or a key this form does not define.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "The booking is cancelled, or it has already finished; there is nothing left to prepare for. Carries a guest-readable sentence.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The link does not resolve, **or** this org has no active intake form for this booking. The two are the same status on purpose: from outside, \"no such link\" and \"nothing to fill in\" are equally not-a-resource.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "A second submit raced the first; two tabs, or a double tap, and the one-response-per-booking index caught the loser.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The insert or update errored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}patch/api/book/{slug}/manage/{token}Move a booking to another time
/api/book/{slug}/manage/{token}Guest-initiated reschedule, for both engines. Which engine is in play is resolved from the org's business_type inside loadByToken, so the caller does not choose; this is the one endpoint that serves both and the only one where that is correct, because a manage link is a link to a booking, not to a product.
Only the time changes. Same service, same staff member, same party size; that is what keeps a guest reschedule auto-approvable without staff review.
The new time must satisfy the org's notice period too, or a guest could sidestep the cutoff by moving to a slot an hour from now. The slot is re-derived server-side and the update is a compare-and-set on the current status, so a booking staff cancelled mid-flow is not resurrected.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.token*pathstringThe booking's
manage_token(a uuid) from its confirmation email. This IS the credential; there is no session. A non-uuid string is rejected before it reaches Postgres and reads as "link not valid".
Request body application/json
| Field | Type | Notes |
|---|---|---|
| startsAt* | string (date-time) | Must be at least 60 seconds away, at least |
Responses
Moved. A change notification is queued after the response is sent.
| Field | Type |
|---|---|
| booking* | object |
| booking.id | string (uuid) |
| booking.starts_at | string (date-time) |
| booking.ends_at | string (date-time) |
No time given, an unparseable time, a time already past, or a time inside the org's notice period.
The policy refuses: the org has guest self-service switched off, the booking is in a status a guest may no longer act on (cancelled, seated, completed, no_show), it has already started, or it is now inside the notice period. Available on every tier since 2026-08-07 (src/lib/plan.ts no longer gates guest_self_service at all). The body carries a guest-readable sentence naming the venue.
The slug, the token, or the pairing of the two does not resolve. Deliberately indistinguishable; "That link is no longer valid".
The requested time is no longer free, or was taken between the availability read and the update.
The update errored for a reason other than an overlap.
Raw OpenAPI operation
{
"summary": "Move a booking to another time",
"description": "Guest-initiated reschedule, for both engines. Which engine is in play is resolved from the org's `business_type` inside `loadByToken`, so the caller does not choose; this is the one endpoint that serves both and the only one where that is correct, because a manage link is a link to *a booking*, not to a product.\n\nOnly the time changes. Same service, same staff member, same party size; that is what keeps a guest reschedule auto-approvable without staff review.\n\nThe new time must satisfy the org's notice period too, or a guest could sidestep the cutoff by moving to a slot an hour from now. The slot is re-derived server-side and the update is a compare-and-set on the current status, so a booking staff cancelled mid-flow is not resurrected.",
"tags": [
"Guest self-service"
],
"operationId": "guestRescheduleBooking",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's `manage_token` (a uuid) from its confirmation email. This IS the credential; there is no session. A non-uuid string is rejected before it reaches Postgres and reads as \"link not valid\"."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"startsAt"
],
"properties": {
"startsAt": {
"type": "string",
"format": "date-time",
"description": "Must be at least 60 seconds away, at least `guest_manage_cutoff_hours` away, and match a computed slot exactly."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Moved. A change notification is queued after the response is sent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"booking"
],
"properties": {
"booking": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"starts_at": {
"type": "string",
"format": "date-time"
},
"ends_at": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
}
},
"400": {
"description": "No time given, an unparseable time, a time already past, or a time inside the org's notice period.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "The policy refuses: the org has guest self-service switched off, the booking is in a status a guest may no longer act on (cancelled, seated, completed, no_show), it has already started, or it is now inside the notice period. Available on every tier since 2026-08-07 (src/lib/plan.ts no longer gates `guest_self_service` at all). The body carries a guest-readable sentence naming the venue.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The slug, the token, or the pairing of the two does not resolve. Deliberately indistinguishable; \"That link is no longer valid\".",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The requested time is no longer free, or was taken between the availability read and the update.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The update errored for a reason other than an overlap.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/book/{slug}/manage/{token}Cancel a booking
/api/book/{slug}/manage/{token}Sets status to cancelled rather than deleting, so the venue keeps the history and the slot or table is released by exactly the rules a staff cancellation uses. Compare-and-set on the current status.
Parameters
slug*pathstringThe organization's public booking slug, i.e. the
{slug}in /{slug}. Only orgs with statusactiveresolve; anything else is a 404.token*pathstringThe booking's manage token.
Responses
Cancelled. A cancellation notification is queued after the response is sent.
| Field | Type |
|---|---|
| ok* | true |
The policy refuses; same set of reasons as PATCH, with a guest-readable sentence. Staff can still cancel from the dashboard, and any deposit still refunds or forfeits by the usual rule.
The link does not resolve.
Its status changed underneath the request; in practice, it was already cancelled.
The update errored.
Raw OpenAPI operation
{
"summary": "Cancel a booking",
"description": "Sets status to `cancelled` rather than deleting, so the venue keeps the history and the slot or table is released by exactly the rules a staff cancellation uses. Compare-and-set on the current status.",
"tags": [
"Guest self-service"
],
"operationId": "guestCancelBooking",
"security": [],
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404."
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's manage token."
}
],
"responses": {
"200": {
"description": "Cancelled. A cancellation notification is queued after the response is sent.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"403": {
"description": "The policy refuses; same set of reasons as PATCH, with a guest-readable sentence. Staff can still cancel from the dashboard, and any deposit still refunds or forfeits by the usual rule.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The link does not resolve.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Its status changed underneath the request; in practice, it was already cancelled.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The update errored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/feedback/{token}Submit the private half of a booking's post-visit feedback
/api/feedback/{token}The other branch of the post-visit satisfaction gate a completed booking is emailed (see notifyBookingCompleted/notifyReservationCompleted): a guest who says a visit was NOT great lands here instead of the org's public Google review link, so the negative signal still reaches the business without ever becoming a public review.
No slug, unlike the manage-link endpoint; the manage_token is unique across both engines by construction and is the whole credential, so it resolves a booking on its own. Guest name/email are read from the booking's own customer record rather than trusted from the request body, since the token already proves who this is.
PUT-like replace semantics on a POST: a guest reopening the link edits their existing answer rather than stacking a second row, the same "one response per booking" convenience the intake form gives.
Parameters
token*pathstringThe booking's manage token (a uuid), reused as the feedback link's credential too; see feedbackUrl() in lib/booking/manage.ts.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| message | string | (max length 2000) What went wrong. Optional; an empty submission still records that the guest tapped through. |
Responses
Saved to book_feedback. Visible to staff on the Reviews tab.
| Field | Type |
|---|---|
| ok* | true |
The message is over 2000 characters.
The token does not resolve to a reservation or an appointment.
The insert or update errored.
Raw OpenAPI operation
{
"summary": "Submit the private half of a booking's post-visit feedback",
"description": "The other branch of the post-visit satisfaction gate a completed booking is emailed (see notifyBookingCompleted/notifyReservationCompleted): a guest who says a visit was NOT great lands here instead of the org's public Google review link, so the negative signal still reaches the business without ever becoming a public review.\n\nNo slug, unlike the manage-link endpoint; the `manage_token` is unique across both engines by construction and is the whole credential, so it resolves a booking on its own. Guest name/email are read from the booking's own customer record rather than trusted from the request body, since the token already proves who this is.\n\nPUT-like replace semantics on a POST: a guest reopening the link edits their existing answer rather than stacking a second row, the same \"one response per booking\" convenience the intake form gives.",
"tags": [
"Guest self-service"
],
"operationId": "guestSubmitFeedback",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's manage token (a uuid), reused as the feedback link's credential too; see feedbackUrl() in lib/booking/manage.ts."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"maxLength": 2000,
"description": "What went wrong. Optional; an empty submission still records that the guest tapped through."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Saved to book_feedback. Visible to staff on the Reviews tab.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "The message is over 2000 characters.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The token does not resolve to a reservation or an appointment.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The insert or update errored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}post/api/unsubscribe/{token}Set or clear a customer's marketing consent from the link in a marketing email
/api/unsubscribe/{token}The write half of the unsubscribe surface (migration 0143); the page at /unsubscribe/{token} only renders, this route is the only thing that mutates. POST only, form-encoded, never JSON: read via request.formData(), which parses both multipart/form-data and application/x-www-form-urlencoded transparently. That matters for RFC 8058: a mail client rendering a native "Unsubscribe" button POSTs exactly List-Unsubscribe=One-Click as application/x-www-form-urlencoded, and this route reads it with the same parser the guest-facing form on the page uses, so there is one code path for both a mail client's automated click and a guest's real one.
token is book_customers.unsubscribe_token, a per-customer-per-company credential (not global): a person who books at two venues has two separate tokens and unsubscribing at one never touches the other. No session; the token alone resolves who this is, same posture as /api/feedback/{token}.
An unrecognised or empty body defaults to unsubscribe, never resubscribe: fail-safe toward less contact, not more. A stopSms=1 field alongside an unsubscribe additionally sets sms_opt_out_at; it has no effect on a resubscribe, since SMS consent is its own axis and a resubscribe must never silently reinstate it.
Parameters
token*pathstringThe customer's unsubscribe token (a uuid); see unsubscribeUrl() in lib/marketing/consent.ts.
Request body multipart/form-data
| Field | Type | Notes |
|---|---|---|
| action | "unsubscribe" | "resubscribe" | Defaults to unsubscribe when absent or unrecognised. |
| stopSms | "1" | Also sets sms_opt_out_at. Only honoured alongside action=unsubscribe. |
| List-Unsubscribe | "One-Click" | RFC 8058's own field; when present it wins outright and always means action=unsubscribe with stopSms omitted. |
Responses
marketing_status written on book_customers; the response also carries status, the value that was set.
| Field | Type |
|---|---|
| ok* | true |
The token does not resolve to a customer row.
The update errored.
Raw OpenAPI operation
{
"summary": "Set or clear a customer's marketing consent from the link in a marketing email",
"description": "The write half of the unsubscribe surface (migration 0143); the page at /unsubscribe/{token} only renders, this route is the only thing that mutates. POST only, form-encoded, never JSON: read via `request.formData()`, which parses both multipart/form-data and application/x-www-form-urlencoded transparently. That matters for RFC 8058: a mail client rendering a native \"Unsubscribe\" button POSTs exactly `List-Unsubscribe=One-Click` as application/x-www-form-urlencoded, and this route reads it with the same parser the guest-facing form on the page uses, so there is one code path for both a mail client's automated click and a guest's real one.\n\n`token` is `book_customers.unsubscribe_token`, a per-customer-per-company credential (not global): a person who books at two venues has two separate tokens and unsubscribing at one never touches the other. No session; the token alone resolves who this is, same posture as /api/feedback/{token}.\n\nAn unrecognised or empty body defaults to `unsubscribe`, never `resubscribe`: fail-safe toward less contact, not more. A `stopSms=1` field alongside an unsubscribe additionally sets `sms_opt_out_at`; it has no effect on a resubscribe, since SMS consent is its own axis and a resubscribe must never silently reinstate it.",
"tags": [
"Guest self-service"
],
"operationId": "guestSetMarketingConsent",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The customer's unsubscribe token (a uuid); see unsubscribeUrl() in lib/marketing/consent.ts."
}
],
"requestBody": {
"required": false,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"unsubscribe",
"resubscribe"
],
"description": "Defaults to unsubscribe when absent or unrecognised."
},
"stopSms": {
"type": "string",
"enum": [
"1"
],
"description": "Also sets sms_opt_out_at. Only honoured alongside action=unsubscribe."
},
"List-Unsubscribe": {
"type": "string",
"enum": [
"One-Click"
],
"description": "RFC 8058's own field; when present it wins outright and always means action=unsubscribe with stopSms omitted."
}
}
}
}
}
},
"responses": {
"200": {
"description": "marketing_status written on book_customers; the response also carries `status`, the value that was set.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"404": {
"description": "The token does not resolve to a customer row.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "The update errored.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}get/api/feedback/{token}/positiveLog a guest's thumb-up tap and redirect to the org's Google review link
/api/feedback/{token}/positiveThe other branch of the post-visit satisfaction gate: a GET, not a POST, because this is the link inside the confirmation/reminder email itself (review-email.ts's positiveReviewUrl); a guest clicks it exactly like any other mailto/href, never sees this route, and is never shown a page belonging to it. Logs a bare click (rating: 'positive', no body, no ratings; migration 0073's own constraint enforces that shape) then redirects straight to Google. Same credential model as the sibling POST route: the manage token alone resolves the booking, no session.
Logging is best-effort and never blocks the redirect. A DB error or a rate-limit hit is swallowed; the guest always lands on Google. The click log itself exists for a later Google Business Profile connector to match a new public review back to a booking by guest name or time-proximity, not something worth degrading a happy guest's one tap over.
Re-opening the link edits the existing row's created_at rather than stacking a second one (0073's partial unique index), which also keeps a later time-proximity match pointed at the most recent tap.
Parameters
token*pathstringThe booking's manage token, reused as the feedback link's credential; see feedbackUrl() in lib/booking/manage.ts.
Responses
Not actually returned; the real response is the 307 below. Documented here only because this repo's build-contract checker (check-openapi.ts) requires every operation to declare a 2xx, and a redirect-only route has none.
The real response, always. Redirects to the org's Google review link when one is configured, otherwise to the manage page the token resolves to. An unresolvable token redirects to / rather than 404ing; this is a link already sitting in a sent email, and a broken-looking page reads worse than a soft landing.
Raw OpenAPI operation
{
"summary": "Log a guest's thumb-up tap and redirect to the org's Google review link",
"description": "The other branch of the post-visit satisfaction gate: a GET, not a POST, because this is the link inside the confirmation/reminder email itself (`review-email.ts`'s `positiveReviewUrl`); a guest clicks it exactly like any other mailto/href, never sees this route, and is never shown a page belonging to it. Logs a bare click (`rating: 'positive'`, no body, no ratings; migration 0073's own constraint enforces that shape) then redirects straight to Google. Same credential model as the sibling POST route: the manage token alone resolves the booking, no session.\n\n**Logging is best-effort and never blocks the redirect.** A DB error or a rate-limit hit is swallowed; the guest always lands on Google. The click log itself exists for a later Google Business Profile connector to match a new public review back to a booking by guest name or time-proximity, not something worth degrading a happy guest's one tap over.\n\nRe-opening the link edits the existing row's `created_at` rather than stacking a second one (0073's partial unique index), which also keeps a later time-proximity match pointed at the most recent tap.",
"tags": [
"Guest self-service"
],
"operationId": "guestLogPositiveFeedback",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The booking's manage token, reused as the feedback link's credential; see feedbackUrl() in lib/booking/manage.ts."
}
],
"responses": {
"200": {
"description": "Not actually returned; the real response is the 307 below. Documented here only because this repo's build-contract checker (check-openapi.ts) requires every operation to declare a 2xx, and a redirect-only route has none."
},
"307": {
"description": "The real response, always. Redirects to the org's Google review link when one is configured, otherwise to the manage page the token resolves to. An unresolvable token redirects to `/` rather than 404ing; this is a link already sitting in a sent email, and a broken-looking page reads worse than a soft landing."
}
}
}API keys
Issue and revoke the credentials programmatic callers use. Session-authenticated and admin-only, and deliberately unreachable with an API key: there is no scope for key management, so a leaked key cannot mint another.
post/api/keysIssue an API key (admin)
/api/keysMints a key for a programmatic caller. The raw secret is returned in this response and nowhere else, ever: only its SHA-256 digest is stored. There is no way to recover it afterwards; the only remedy for a lost key is to revoke it and issue another.
Requested scopes are validated against this org's own vertical, which is where "a key can never exceed what the org itself can do" is enforced: a hospitality org asking for services:write is a 400, not a key quietly holding a scope no route would ever honour for it. The route itself takes no vertical argument, because key management is shared by both engines; it is the scope list that is filtered, not the endpoint that is gated.
There is no GET /api/keys. The /developer dashboard page lists keys through a server component reading the table with service-role after requireMember() has established tenancy.
Request body application/json
| Field | Type | Notes |
|---|---|---|
| name* | string | (min length 1, max length 80) What this key is for. Shown in the dashboard; not a credential. |
| scopes* | string[] | (min items 1)
|
| expiresInDays | integer | null | (min 1, max 3650) Omit for a key that never expires, which is what most server-to-server integrations actually want. |
Responses
Issued. Capture secret now; it is unrecoverable.
| Field | Type | Notes |
|---|---|---|
| ok* | true | |
| key* | object | The stored row. Snake_case, selected straight back. |
| key.id | string | The non-secret 16-hex key id. Safe in logs and in the dashboard. |
| key.name | string | |
| key.scopes | string[] | |
| key.last_four | string | Last four characters of the raw token, for display only. |
| key.created_at | string (date-time) | |
| key.expires_at | string | null (date-time) | |
| key.revoked_at | string | null (date-time) | |
| key.rate_limit_per_minute | integer | Defaults to 120. |
| secret* | string | The full 68-character token, |
A missing or over-length name, an empty scope list, a scope this organization cannot grant (the message names up to three), an expiry outside 1-3650 days, or a Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Not an admin, no linked organization, not on the roster, or the organization row could not be read ("Organization not found").
Raw OpenAPI operation
{
"summary": "Issue an API key (admin)",
"description": "Mints a key for a programmatic caller. The raw secret is returned **in this response and nowhere else, ever**: only its SHA-256 digest is stored. There is no way to recover it afterwards; the only remedy for a lost key is to revoke it and issue another.\n\nRequested scopes are validated against **this org's own vertical**, which is where \"a key can never exceed what the org itself can do\" is enforced: a hospitality org asking for `services:write` is a 400, not a key quietly holding a scope no route would ever honour for it. The route itself takes no `vertical` argument, because key management is shared by both engines; it is the scope list that is filtered, not the endpoint that is gated.\n\nThere is no `GET /api/keys`. The `/developer` dashboard page lists keys through a server component reading the table with service-role after `requireMember()` has established tenancy.",
"tags": [
"API keys"
],
"operationId": "issueApiKey",
"security": [
{
"sessionCookie": []
}
],
"x-required-role": "admin",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"scopes"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "What this key is for. Shown in the dashboard; not a credential."
},
"scopes": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-z-]+:(read|write)$"
},
"description": "`resource:action`, where resource is exactly the `/api/<resource>` folder name. Deduplicated server-side. `write` does NOT imply `read`; scopes are compared by exact string, so a key needing both carries both. See the API-key section of these docs for the full vocabulary."
},
"expiresInDays": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 3650,
"description": "Omit for a key that never expires, which is what most server-to-server integrations actually want."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Issued. Capture `secret` now; it is unrecoverable.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"key",
"secret"
],
"properties": {
"ok": {
"const": true
},
"key": {
"type": "object",
"description": "The stored row. Snake_case, selected straight back.",
"properties": {
"id": {
"type": "string",
"description": "The non-secret 16-hex key id. Safe in logs and in the dashboard."
},
"name": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"last_four": {
"type": "string",
"description": "Last four characters of the raw token, for display only."
},
"created_at": {
"type": "string",
"format": "date-time"
},
"expires_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"revoked_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"rate_limit_per_minute": {
"type": "integer",
"description": "Defaults to 120."
}
}
},
"secret": {
"type": "string",
"description": "The full 68-character token, `sk_live_<16 hex>_<43 base64url>`. Returned exactly once."
}
}
}
}
}
},
"400": {
"description": "A missing or over-length name, an empty scope list, a scope this organization cannot grant (the message names up to three), an expiry outside 1-3650 days, or a Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin, no linked organization, not on the roster, or the organization row could not be read (\"Organization not found\").",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}delete/api/keys/{id}Revoke an API key (admin)
/api/keys/{id}A soft delete: revoked_at is stamped and the row stays, so the record of what existed, who issued it and what it could reach survives revoking it. That matters precisely when a key is being revoked in a hurry because it leaked, which is the worst possible moment to discard the evidence.
Revocation is immediate, with no cache to wait out; the gate reads revoked_at on every request.
Parameters
id*pathstringThe key id; the non-secret 16-hex segment of the token, not the token itself.
Responses
Revoked.
| Field | Type |
|---|---|
| ok* | true |
A Postgres error.
No valid session cookie. {"error":"Not signed in"}.
Signed in, but not permitted: the account has no organization linked (No organization linked to this account), or is not on that org's roster (Not a member of this organization), or is a staff member where this action requires admin (Admin access required).
No live key with that id in this org. An id belonging to another organization and an already-revoked key are deliberately the same 404: distinguishing them would confirm that a key id exists in a tenant you cannot see.
Raw OpenAPI operation
{
"summary": "Revoke an API key (admin)",
"description": "A soft delete: `revoked_at` is stamped and the row stays, so the record of what existed, who issued it and what it could reach survives revoking it. That matters precisely when a key is being revoked in a hurry because it leaked, which is the worst possible moment to discard the evidence.\n\nRevocation is immediate, with no cache to wait out; the gate reads `revoked_at` on every request.",
"tags": [
"API keys"
],
"operationId": "revokeApiKey",
"security": [
{
"sessionCookie": []
}
],
"x-required-role": "admin",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The key id; the non-secret 16-hex segment of the token, not the token itself."
}
],
"responses": {
"200": {
"description": "Revoked.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"const": true
}
}
}
}
}
},
"400": {
"description": "A Postgres error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Signed in, but not permitted: the account has no organization linked (`No organization linked to this account`), or is not on that org's roster (`Not a member of this organization`), or is a `staff` member where this action requires `admin` (`Admin access required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No live key with that id in this org. An id belonging to another organization and an already-revoked key are deliberately the same 404: distinguishing them would confirm that a key id exists in a tenant you cannot see.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}Want to see the product behind the API?
Five real example businesses, both engines, the full owner dashboard and a booking page you can actually complete. No account, nothing saved.
Open the live demo