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_overlap on 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_overlap on 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-token

The 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 · bearer

The CRON_SECRET value, sent as Authorization: Bearer <secret>. Used by exactly one route. Fails closed when the secret is unset.

applePassAuthhttp · ApplePass

Apple 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 · bearer

Authorization: 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_id claim 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 explicit company_id filter 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/companies and 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_at is always computed from the start plus a duration. An appointment's price and duration_minutes are snapshotted from the service chosen; a reservation's turn_minutes is 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/v1 list 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-After and X-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 22P02 for 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/services

Create a service

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
durationMinutes*integer(min 5, max 600)
pricenumber | 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.

groupIdstring | 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".

activeboolean(default true)

Defaults to true. Only the literal false turns it off (active !== false).

Responses

200

Created.

FieldType
ok*true
service*object
service.idstring (uuid)
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/services

Reorder and/or regroup services

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
groupIdstring (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

200

Reordered.

FieldType
ok*true
400

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

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Service id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
durationMinutes*integer(min 5, max 600)
pricenumber | 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.

groupIdstring | 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".

activeboolean(default true)

Defaults to true. Only the literal false turns it off (active !== false).

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseServiceInput, or "Group not found" for a groupId that is not this org's (FK 23503).

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Service id.

Responses

200

Deleted.

FieldType
ok*true
400

A Postgres error other than the two handled below.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No service with that id in this org, or a malformed uuid.

409

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}/image

Upload a service image

Session cookieengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Service id.

Request body multipart/form-data

The service image. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldTypeNotes
ok*true
imageUrl*string (uri)

Public URL with a ?v=<timestamp> cache-buster; the storage path itself never changes.

400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Remove a service image

Session cookieengine-guarded · appointments · 403 otherwise

Deletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.

Parameters

  • id*pathstring

    Service id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-groups

Create a service group

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
colorstring | null

One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400.

activeboolean(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

200

Created.

FieldType
ok*true
group*object
group.idstring (uuid)
400

A validation message from parseServiceGroupInput, or the raw Postgres message if the insert itself failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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-groups

Reorder service groups

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
ids*string (uuid)[](min items 1, max items 100)

Every group id, in the order they should appear.

Responses

200

Reordered.

FieldType
ok*true
400

ids missing, empty, over 100 entries, or containing a non-uuid.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Group id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
colorstring | null

One of SERVICE_COLORS, or null/omitted for no colour. Anything else is a 400.

activeboolean(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

200

Updated.

FieldType
ok*true
400

A validation message from parseServiceGroupInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Group id.

Responses

200

Deleted. Its services are now ungrouped.

FieldType
ok*true
400

A Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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/providers

Create a staff member

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
emailstring | null (email)(max length 254)
biostring | null(max length 500)
titlestring | 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).

activeboolean(default true)
serviceIdsstring (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.

hoursobject[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

userIdstring | 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 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"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 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.

conflictAcknowledgedboolean(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 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.

cycleWeeksinteger(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 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.

rotationAnchorstring | null (date)

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.

rotationWeeksobject[][](max items 7)

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.

Responses

200

Created.

FieldType
ok*true
provider*object
provider.idstring (uuid)
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

402

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.

403

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").

409

That login is already linked to another staff member in this company.

500

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Provider id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
emailstring | null (email)(max length 254)
biostring | null(max length 500)
titlestring | 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).

activeboolean(default true)
serviceIdsstring (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.

hoursobject[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

userIdstring | 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 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"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 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.

conflictAcknowledgedboolean(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 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.

cycleWeeksinteger(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 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.

rotationAnchorstring | null (date)

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.

rotationWeeksobject[][](max items 7)

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.

Responses

200

Updated.

FieldType
ok*true
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No staff member with that id in this org, or a malformed uuid.

409

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)

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Provider id.

Responses

200

Deleted.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No staff member with that id in this org.

409

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}/archive

Archive a staff member (admin, or manage_staff)

Session cookieengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Provider id.

Responses

200

Archived.

FieldType
ok*true
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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}/reactivate

Reactivate an archived staff member (admin, or manage_staff)

Session cookieengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Provider id.

Responses

200

Reactivated.

FieldType
ok*true
400

That staff member is not archived, or an unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

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.

403

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

404

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}/avatar

Upload a staff photo

Session cookieengine-guarded · appointments · 403 otherwise

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

Parameters

  • id*pathstring

    Provider id.

Request body multipart/form-data

The staff photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
avatarUrl*string (uri)
400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/avatar

Remove a staff photo

Session cookieengine-guarded · appointments · 403 otherwise

Deletes the stored object and nulls avatar_url.

Parameters

  • id*pathstring

    Provider id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-overrides

Request a date-specific schedule override

Session cookieengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Provider id.

Request body application/json

FieldTypeNotes
overrideDate*string (date)

Must not be in the past.

windowsobject[](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.

reasonstring(max length 300)
conflictAcknowledgedboolean(default false)

Admin only; ignored for any other caller, both here and inside the RPC.

Responses

200

Submitted; either pending or already applied, see status.

FieldType
ok*true
id*string (uuid)
status*"pending" | "approved"
400

An invalid date, malformed windows, a reason over 300 characters, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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").

404

No provider with that id in this org.

409

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-periods

Mark a staff member away (admin, or manage_staff)

Session cookieengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Provider id.

Request body application/json

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

reasonstring(max length 300)
conflictAcknowledgedboolean(default false)

Responses

200

Saved.

FieldType
ok*true
blockedPeriod*object
blockedPeriod.idstring (uuid)
blockedPeriod.starts_atstring (date-time)
blockedPeriod.ends_atstring (date-time)
blockedPeriod.reasonstring | null
400

An invalid or backwards date range, a reason over 300 characters, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No provider with that id in this org.

409

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-periods

Remove a time-away entry (admin, or manage_staff)

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Provider id.

  • id*querystring (uuid)

    The blocked-period row id.

Responses

200

Removed, or already absent.

FieldType
ok*true
400

The query id is missing, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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-overrides

List availability-override requests

Session cookieengine-guarded · appointments · 403 otherwise

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.

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

200

The list, newest date first.

FieldTypeNotes
overrides*object[]
overrides[].idstring (uuid)
overrides[].providerIdstring (uuid)
overrides[].providerNamestring | null
overrides[].overrideDatestring (date)
overrides[].windowsobject[](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[].reasonstring | null(max length 300)
overrides[].status"pending" | "approved" | "rejected" | "cancelled" | "superseded"
overrides[].requiresApprovalboolean
overrides[].autoApplyReason"trusted_tier" | "beyond_horizon" | "admin_direct" | null
overrides[].createdByRole"admin" | "staff"
overrides[].createdAtstring (date-time)
overrides[].reviewedAtstring | null (date-time)
overrides[].decisionNotestring | null(max length 300)
overrides[].conflictAcknowledgedboolean
overrides[].conflictCountinteger

Admin only, live-recomputed on every GET; always 0 in a staff response. Never persisted; see the route file for why.

400

A Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Availability-override request id.

Request body application/json

FieldTypeNotes
status*"cancelled" | "rejected"
decisionNotestring(max length 300)

Only meaningful with rejected.

Responses

200

Updated.

FieldType
ok*true
400

An unknown status, one this route refuses (approved, superseded), a note over 300 characters, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

Wrong engine for this org, a non-admin sent rejected, or the function itself refused (staff trying to cancel someone else's row).

404

No request with that id in this org.

409

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}/approve

Approve a pending availability-override request

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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.

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*pathstring

    Availability-override request id.

Request body application/json

FieldTypeNotes
conflictAcknowledgedboolean(default false)
decisionNotestring(max length 300)

Responses

200

Approved.

FieldType
ok*true
400

A note over 300 characters, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No request with that id in this org.

409

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/appointments

Create an appointment, or a repeat series (staff side)

Session cookieengine-guarded · appointments · 403 otherwise

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.

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.

FieldTypeNotes
serviceId*string (uuid)
providerId*string (uuid)
startsAt*string (date-time)

Anything Date.parse accepts. Stored as ISO 8601 UTC. Ignored when repeats is present.

notesstring | null(max length 1000)

The guest's request for this visit.

repeatsobject | null

Present to create a series instead of one booking. Exactly one of endsOn/occurrenceCount must be set, never both or neither.

repeats.intervalWeeksinteger(min 1, max 4)

1 = weekly, 2 = fortnightly, up to every 4 weeks.

repeats.weekdaysinteger[](min items 1)

0 = Sunday. A Monday-and-Friday plan is one series with two entries.

repeats.timeOfDaystring

"HH:MM", every visit's start time.

repeats.startsOnstring (date)

The first visit's date. Must not be in the past.

repeats.endsOnstring | null (date)
repeats.occurrenceCountinteger | null(min 1, max 52)
customerIdstring (uuid)

An existing client in this org. A foreign or unknown id is a 400 ("Client not found").

customerNamestring(min length 1, max length 120)

Required when customerId is absent.

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

customerPhonestring(min length 1, max length 40)

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

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

FieldType
idstring (uuid)
seriesIdstring (uuid)
createdCountinteger
requestedCountinteger
skippedDatesstring (date)[]
400

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

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

409

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}/series

Whether this booking is part of a repeat series

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Appointment id.

Responses

200

Series membership.

FieldType
seriesId*string | null (uuid)
401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    book_booking_series id.

Responses

200

The series and its visits.

FieldType
series*object
series.idstring (uuid)
series.status"active" | "ended" | "cancelled"
visits*object[]
visits[].idstring (uuid)
visits[].starts_atstring (date-time)
visits[].status"pending" | "confirmed" | "cancelled" | "completed" | "no_show"
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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

Session cookieengine-guarded · appointments · 403 otherwise

"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*pathstring

    book_booking_series id.

Request body application/json

FieldType
status*"cancelled"

Responses

200

Ended.

FieldTypeNotes
ok*true
cancelledCount*integer

How many visits this call actually cancelled.

400

status was anything other than "cancelled".

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No series with this id for this company.

409

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Appointment id.

Responses

200

The booking, with an unread-message count and its service, staff and customer fields resolved.

FieldTypeNotes
row*object
row.idstring (uuid)
row.startsAtstring (date-time)
row.endsAtstring (date-time)
row.status"pending" | "confirmed" | "cancelled" | "completed" | "no_show"
row.pricenumber | null
row.notesstring | null
row.serviceIdstring (uuid)
row.providerIdstring | null (uuid)

Nullable as of migration 0051: a permanently deleted staff member leaves this null and keeps providerName instead.

row.durationMinutesinteger
row.depositStatus"none" | "pending" | "paid" | "refunded" | "forfeited"
row.unreadCountinteger

Unread guest messages on this booking's thread, the same definition the Inbox and the bookings list use.

row.serviceNamestring

"(deleted service)" when the service no longer exists.

row.itemNamesstring[]

A multi-service visit's (migration 0054) line items, in position order. Empty for a single-service booking.

row.serviceColorstring | null
row.providerNamestring

"(no longer on staff)" when the provider row has been deleted.

row.providerConfirmedboolean

False on an "Available specialist" booking the system auto-assigned but nobody has confirmed yet (migration 0080).

row.customerNamestring

Redacted for a scoped staff member per the org's staffClientVisibility, same as the bookings list. "(unknown)" when no customer is linked.

row.customerEmailstring | null
row.customerPhonestring | null
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Appointment id.

Request body application/json

FieldTypeNotes
status"pending" | "confirmed" | "cancelled" | "completed" | "no_show"

Transitioning INTO cancelled sends the cancellation notification once; re-cancelling an already-cancelled booking does not.

serviceIdstring (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.

providerIdstring (uuid)

Cannot be empty; an appointment with no provider is not a legal state.

startsAtstring (date-time)
notesstring | null(max length 1000)
phonestring | 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 PATCH /api/reservations/{id} and PATCH /api/clients/{id} apply to the same column.

Responses

200

Updated.

FieldType
ok*true
400

Invalid status, invalid date, over-length notes, an unknown service, or "Nothing to change" when the body contained no recognised key.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No appointment with that id in this org, or a malformed uuid.

409

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/appointments

List appointments (API key)

API keyappointments:readengine-guarded · appointments · 403 otherwise

The 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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

  • fromquerystring (date-time)optional

    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.

    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.

  • toquerystring (date-time)optional

    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

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The appointments, newest first.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].startsAtstring (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 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.

data[].endsAtstring (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 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.

data[].status"pending" | "confirmed" | "cancelled" | "completed" | "no_show"
data[].durationMinutesinteger
data[].pricenumber | null
data[].notesstring | null

The customer's request for this booking, as they typed it.

data[].serviceIdstring (uuid)
data[].providerIdstring | null (uuid)
data[].customerIdstring (uuid)
data[].depositStatus"none" | "pending" | "paid" | "refunded" | "forfeited"
data[].depositAmountCentsinteger | null
data[].createdAtstring (date-time)

A true UTC instant (the row's now() default), unlike startsAt/endsAt above. Parse and render this one normally.

nextCursor*string | null

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.

400

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.

FieldType
error*string
code"invalid_cursor"
401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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/services

List services (API key)

API keyservices:readengine-guarded · appointments · 403 otherwise

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.

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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The services.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].namestring
data[].descriptionstring | null
data[].durationMinutesinteger
data[].pricenumber | null

Null means "no price shown".

data[].colorstring | null

One of SERVICE_COLORS: the swatch the dashboard and the widget draw this service in.

data[].activeboolean
data[].imageUrlstring | null
nextCursor*string | null

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.

400

invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.

FieldTypeNotes
error*string
code*"invalid_cursor"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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/providers

List staff (API key)

API keyproviders:readengine-guarded · appointments · 403 otherwise

The 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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The staff members.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].namestring
data[].emailstring | null (email)
data[].biostring | null
data[].titlestring | null

The public team page's role line, e.g. "Owner & Colorist".

data[].activeboolean
data[].avatarUrlstring | null
data[].serviceIdsstring (uuid)[]

The services this staff member can perform. Empty array, never absent.

nextCursor*string | null

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.

400

invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.

FieldTypeNotes
error*string
code*"invalid_cursor"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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/products

Create a product

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
skustring | 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).

categoryIdstring | 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".

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

stockOnHandinteger(min 0, default 0)

The opening count. Defaults to 0 if omitted.

Responses

200

Created.

FieldType
product*object
product.idstring (uuid)
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Product id.

Request body application/json

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseProductInput, or "Category not found" for a categoryId that is not this org's (FK 23503).

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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}/image

Upload a product image

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Product id.

Request body multipart/form-data

The product image. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldTypeNotes
ok*true
imageUrl*string (uri)

Public URL with a ?v=<timestamp> cache-buster; the storage path itself never changes.

400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Remove a product image

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Product id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/stock

A product's recent stock movements

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Product id.

Responses

200

Up to 20 movements.

FieldTypeNotes
movements*object[](max items 20)
movements[].idstring (uuid)
movements[].reason"received" | "stocktake" | "damaged" | "adjustment" | "sale" | "sale_undo"
movements[].deltainteger

Signed. Negative for a removal.

movements[].resulting_stockinteger
movements[].notestring | null
movements[].created_atstring (date-time)
400

A Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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}/stock

Adjust a product's stock

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • id*pathstring

    Product id.

Request body application/json

FieldTypeNotes
reason*"received" | "stocktake" | "damaged"
deltainteger

Required (and must be non-zero) when reason is received or damaged. Ignored for stocktake.

countinteger(min 0)

Required (0 or more) when reason is stocktake: the counted total on the shelf, not a delta.

notestring | null(max length 300)

Responses

200

Adjusted.

FieldTypeNotes
stockOnHand*integer

The new count after this adjustment.

400

Reason missing/unrecognised, delta/count missing or invalid for the chosen reason, or the RPC's own error message.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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/categories

Create a product category

Session cookieengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 60)

Responses

200

Created.

FieldType
category*object
category.idstring (uuid)
category.namestring
400

Name missing or over 60 characters, or the raw Postgres message if the insert itself failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · appointments · 403 otherwise

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*pathstring

    Category id.

Responses

200

Deleted.

FieldType
ok*true
400

A Postgres error other than the not-found case below.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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/hours

Read the venue opening hours (admin, or manage_org_settings)

Session cookieengine-guarded · appointments · 403 otherwise

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

Responses

200

The configured week. An empty array means no venue restriction.

FieldTypeNotes
hours*object[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

400

A Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/hours

Replace the venue opening hours (admin, or manage_org_settings)

Session cookieengine-guarded · appointments · 403 otherwise

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

Request body application/json

FieldTypeNotes
hours*object[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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

200

Saved. Echoes the stored week back.

FieldTypeNotes
ok*true
hours*object[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

400

A validation message from parseHoursWindows, or a Postgres error clearing the old rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

500

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)

Session cookieengine-guarded · appointments · 403 otherwise

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

FieldTypeNotes
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 visible.

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 <title> tag and OpenGraph/Twitter title.

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

200

Saved.

FieldType
ok*true
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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}/publish

Publish a page's draft content live (admin)

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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.

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

200

Published (or a no-op success if nothing was ever drafted for this page).

FieldType
ok*true
400

A Postgres error on the read or the write.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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/domain

Read this company's connected custom domain

Session cookieengine-guarded · appointments · 403 otherwise

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.

Responses

200

The connected domain, or null.

FieldTypeNotes
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.typestring
domain.verificationChallenge.domainstring
domain.verificationChallenge.valuestring
domain.lastCheckedAt*string (date-time)
domain.createdAt*string (date-time)
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

503

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/domain

Connect a custom domain (admin)

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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.

Request body application/json

FieldTypeNotes
hostname*string

A bare hostname, e.g. "book.yourbusiness.com.au". Lowercased and trimmed before validation.

Responses

200

Connected. Echoes the stored row, including Vercel's current verification state.

FieldTypeNotes
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.typestring
domain.verificationChallenge.domainstring
domain.verificationChallenge.valuestring
domain.lastCheckedAt*string (date-time)
domain.createdAt*string (date-time)
400

hostname failed format validation (HOSTNAME_RE, lib/booking/custom-domain.ts).

401

No valid session cookie. {"error":"Not signed in"}.

402

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

403

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.

409

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

502

Vercel's API rejected or failed the add call for a reason other than a conflict.

503

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/domain

Re-check this company's connected domain against Vercel (admin)

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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.

Responses

200

The domain's refreshed state.

FieldTypeNotes
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.typestring
domain.verificationChallenge.domainstring
domain.verificationChallenge.valuestring
domain.lastCheckedAt*string (date-time)
domain.createdAt*string (date-time)
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No domain is connected for this company.

502

Vercel's verify or config call failed for a reason other than "already verified".

503

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/domain

Disconnect the connected custom domain (admin)

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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.

Responses

200

Disconnected, or already had nothing connected.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

502

Vercel's remove call failed for a reason other than the domain already being gone (404 from Vercel is treated as success).

503

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/analytics

This website's traffic (self-hosted, cookieless)

Session cookieengine-guarded · appointments · 403 otherwise

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.

Parameters

  • daysquery7 | 30 | 90optional

    The trailing window. Anything else silently falls back to 30, the same permissive-default posture holidays' own year param takes.

Responses

200

Summary, a daily trend, and four ranked breakdowns, each capped at 8 rows.

FieldType
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
401

No valid session cookie. {"error":"Not signed in"}.

402

The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, analytics).

403

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.

502

The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.

503

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/offline

Take the public site offline, or bring it back (admin)

Session cookieadmin onlyengine-guarded · appointments · 403 otherwise

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

Request body application/json

FieldTypeNotes
offline*boolean

true takes the site offline; false brings it back.

Responses

200

The flag was updated.

FieldType
ok*true
400

offline was missing or not a boolean, or the update itself failed (e.g. the company row could not be found).

401

No valid session cookie. {"error":"Not signed in"}.

403

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/reservations

List reservations (API key)

API keyreservations:readengine-guarded · hospitality · 403 otherwise

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.

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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

  • fromquerystring (date-time)optional

    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.

    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.

  • toquerystring (date-time)optional

    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

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The reservations, newest first.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].startsAtstring (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 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.

data[].endsAtstring (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 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.

data[].status"hold" | "pending" | "confirmed" | "seated" | "completed" | "cancelled" | "no_show"
data[].partySizeinteger
data[].turnMinutesinteger

How long the table is held for. endsAt is startsAt plus this.

data[].occasionstring | null
data[].notesstring | null

The guest's request for this booking.

data[].tableIdstring | null (uuid)
data[].customerIdstring | null (uuid)
data[].holdExpiresAtstring | null (date-time)

Set only while status is hold. Past means the hold lapsed and the slot is free again.

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.

data[].depositStatus"none" | "pending" | "paid" | "refunded" | "forfeited"
data[].depositAmountCentsinteger | null
data[].createdAtstring (date-time)

A true UTC instant (the row's now() default), like holdExpiresAt and unlike startsAt/endsAt. Parse and render this one normally.

nextCursor*string | null

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.

400

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.

FieldType
error*string
code"invalid_cursor"
401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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/tables

List tables (API key)

API keytables:readengine-guarded · hospitality · 403 otherwise

The 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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The tables.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].namestring
data[].areaIdstring | null (uuid)
data[].seatsMininteger
data[].seatsMaxinteger

A party larger than this cannot be seated here: the constraint reservation availability is computed against.

data[].activeboolean
nextCursor*string | null

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.

400

invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.

FieldTypeNotes
error*string
code*"invalid_cursor"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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-periods

List service periods (API key)

API keyservice-periods:readengine-guarded · hospitality · 403 otherwise

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.

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

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The service periods.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].namestring
data[].dayOfWeekinteger(min 0, max 6)

0 = Sunday, matching both Postgres and JavaScript.

data[].startTimestring

Wall clock, HH:MM:SS.

data[].endTimestring

Wall clock, HH:MM:SS.

data[].lastSeatingstring | null

The latest a party may be seated. Null means up to endTime.

data[].turnMinutesinteger
data[].slotMinutesinteger

The granularity offered: 15 means quarter-past bookings.

data[].maxCoversinteger | null

A cap on concurrent covers across the whole venue, independent of table capacity. Null means uncapped.

data[].activeboolean
nextCursor*string | null

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.

400

invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.

FieldTypeNotes
error*string
code*"invalid_cursor"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"insufficient_scope" | "wrong_vertical"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

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/tables

Create a table

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
seatsMin*integer(min 1, max 40)
seatsMax*integer(min 1, max 40)

Must be >= seatsMin.

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

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

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

activeboolean(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

200

Created.

FieldType
ok*true
table*object
table.idstring (uuid)
400

A validation message from parseTableInput, or a Postgres error (including a foreign areaId refused by the composite foreign key).

401

No valid session cookie. {"error":"Not signed in"}.

403

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/bulk

Create a range of tables in one request

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
prefixstring(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. to - from + 1 is capped at 50.

separatorstring(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.

areaIdstring | null (uuid)

Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with levelId.

levelIdstring | null (uuid)

Same rule as POST /api/tables, applied to every table in the run. Mutually exclusive with areaId.

linkGroupIdstring | null (uuid)

Same rule as POST /api/tables, applied to every table in the run.

activeboolean(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

200

Created.

FieldType
ok*true
tables*object[]
tables[].idstring (uuid)
400

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

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

409

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-plan

Save one floor's decor elements

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
levelIdstring | null (uuid)
backgroundXinteger | null(min 0, max 2000)
backgroundYinteger | null(min 0, max 2000)
backgroundWidthinteger | null(min 1, max 400)
backgroundHeightinteger | null(min 1, max 400)
backgroundOpacityinteger(min 10, max 100)
backgroundInvertDarkboolean

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[].xinteger(min 0, max 2000)
elements[].yinteger(min 0, max 2000)
elements[].widthinteger(min 1, max 200)
elements[].heightinteger(min 1, max 200)
elements[].rotationinteger(min 0, max 359)
elements[].textstring(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

200

Saved.

FieldType
ok*true
400

A validation message from parseFloorPlanInput (a malformed element, the 300-element cap), or a foreign levelId refused by the composite foreign key.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

409

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/background

Upload the backdrop image for one floor

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body multipart/form-data

FieldTypeNotes
file*string (binary)

PNG, JPG or WebP, up to 5 MB.

levelIdstring (uuid)

Omit for the single implicit floor.

Responses

200

Uploaded.

FieldType
ok*true
backgroundUrl*string
400

No file, an unsupported type, over 5 MB, a malformed levelId, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/background

Remove a floor's backdrop image

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldType
levelIdstring | null (uuid)

Responses

200

Removed (or was already absent).

FieldType
ok*true
400

A malformed levelId, or a database error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/layout

Save floor-plan geometry for a batch of tables and rooms

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
tablesobject[](max items 200)
tables[].id*string (uuid)
tables[].posXinteger | null(min 0, max 2000)
tables[].posYinteger | null(min 0, max 2000)
tables[].widthinteger | null(min 1, max 200)
tables[].heightinteger | null(min 1, max 200)
tables[].rotationinteger(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")
roomsobject[](max items 200)

book_areas zones (0093). Axis-aligned: no rotation, no shape.

rooms[].id*string (uuid)
rooms[].posXinteger | null(min 0, max 2000)
rooms[].posYinteger | null(min 0, max 2000)
rooms[].widthinteger | null(min 1, max 200)
rooms[].heightinteger | null(min 1, max 200)

Responses

200

Saved. updated counts the rows actually written; an id that no longer exists is skipped.

FieldType
ok*true
updated*integer
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

Full replacement of the table's fields, same as its services twin.

Parameters

  • id*pathstring

    Table id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
seatsMin*integer(min 1, max 40)
seatsMax*integer(min 1, max 40)

Must be >= seatsMin.

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

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

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

activeboolean(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

200

Updated.

FieldType
ok*true
400

A validation message from parseTableInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieadmin onlyengine-guarded · hospitality · 403 otherwise

book_reservations.table_id is ON DELETE RESTRICT, so a table with sittings against it keeps its history and cannot be deleted.

Parameters

  • id*pathstring

    Table id.

Responses

200

Deleted.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No table with that id in this org.

409

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/levels

Create a level (floor)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
bookableboolean(default true)

false lets staff seat/assign here without ever offering it to a guest.

descriptionstring | null(max length 500)

Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description.

Responses

200

Created.

FieldType
ok*true
level*object
level.idstring (uuid)
400

A validation message from parseLevelInput, or an unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

Floors and multiple spaces are Custom-plan only (src/lib/plan.ts, floors); a Basic Table or Venue Pro org stays single-space.

403

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/levels

Reorder levels (floors)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
ids*string (uuid)[](min items 1, max items 100)

Every area (or level) id in this org, in the order they should appear.

Responses

200

Reordered.

FieldType
ok*true
400

ids missing, empty, over 100 entries, or containing a non-uuid.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

Full replacement of the level's fields, same as its table twin.

Parameters

  • id*pathstring

    Level id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
bookableboolean(default true)

false lets staff seat/assign here without ever offering it to a guest.

descriptionstring | null(max length 500)

Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description.

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseLevelInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieadmin onlyengine-guarded · hospitality · 403 otherwise

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*pathstring

    Level id.

Responses

200

Deleted.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Upload a level photo

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Parameters

  • id*pathstring

    Level id.

Request body multipart/form-data

The level photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldTypeNotes
ok*true
imageUrl*string (uri)

Public URL with a ?v=<timestamp> cache-buster; the storage path itself never changes.

400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Remove a level photo

Session cookieengine-guarded · hospitality · 403 otherwise

Deletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.

Parameters

  • id*pathstring

    Level id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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/waitlist

List guests waiting for a table

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

200

The list.

FieldTypeNotes
entries*object[]
entries[].idstring (uuid)
entries[].requestedDatestring (date)
entries[].requestedFromstring | null
entries[].requestedTostring | null
entries[].partySizeinteger | null

Null only for an appointments entry, which nothing writes yet; see 0044's one-subject constraint.

entries[].namestring
entries[].emailstring | null

Nullable since 0232; a staff-sourced entry commonly has none.

entries[].phonestring | null
entries[].notesstring | null
entries[].status"open" | "notified"
entries[].notifiedAtstring | null (date-time)
entries[].source"online" | "staff"
entries[].quotedMinutesinteger | null

What a host TOLD a staff entry. Always null for online.

entries[].createdAtstring (date-time)
400

The read failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/waitlist

Add a party waiting now at the door

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
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_minutesinteger(min 0, max 480)

What the host TOLD them. Null is a real answer: a made-up number is worse than none.

notesstring(max length 2000)

Responses

200

Added.

FieldType
ok*true
id*string (uuid)
400

A validation message written for the host to read, e.g. a missing contact number or a party size outside 1-30.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

500

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}/offer

Offer a waitlisted guest a table

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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*pathstring

    Waitlist entry id.

Responses

200

Offered.

FieldTypeNotes
ok*true
email*"sent" | "failed" | "skipped"
sms*"sent" | "failed" | "skipped"

skipped when the guest left no phone number or the org has SMS off.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No waitlist entry with that id in this org.

409

The entry is converted, expired or cancelled; offering one would mail a guest who has already booked or already said no.

502

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-ready

Text a "waiting now" guest that their table is ready

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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*pathstring

    Waitlist entry id.

Responses

200

Texted.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No waitlist entry with that id in this org.

409

The entry is source: 'online' (no route into this action), or is converted/expired/cancelled already.

502

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Waitlist entry id.

Request body application/json

FieldType
status*"converted" | "expired" | "cancelled"

Responses

200

Updated.

FieldType
ok*true
400

An unknown status, or one this route refuses (open, notified).

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No waitlist entry with that id in this org, or a malformed uuid.

409

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}/convert

Pencil in or book a big-group enquiry

Session cookieengine-guarded · hospitality · 403 otherwise

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

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

`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*pathstring

    Enquiry id.

Request body application/json

FieldTypeNotes
startsAt*string (date-time)
turnMinutes*integer(min 5, max 600)
partySizeinteger(min 1, max 500)

Defaults to what the guest asked for.

status"pending" | "confirmed"

Defaults to confirmed. pending pencils the tables in without telling the guest anything.

tableIdsstring (uuid)[](max items 20)

Ordered; the first is the primary table. Empty is legal, a capacity-only booking that holds no tables.

tableIdstring | null (uuid)

The single-table form, still accepted. Ignored when tableIds is present.

notesstring | null(max length 1000)

Defaults to the message the guest sent with the enquiry.

Responses

200

Pencilled in, or booked.

FieldType
ok*true
reservationId*string (uuid)
status*"pending" | "confirmed"
400

A validation message, or the tables cannot seat the party while the venue holds staff to seat limits.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No enquiry with that id in this org.

409

It already has a booking, one of those tables is taken at that time, or another member converted it first.

500

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}/convert

Release a pencilled-in hold

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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*pathstring

    Enquiry id.

Responses

200

Released. Also the answer when the reservation had already gone.

FieldType
ok*true
400

The delete failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No enquiry with that id in this org.

409

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}/confirm

Confirm a pencilled-in big-group booking

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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*pathstring

    Enquiry id.

Responses

200

Booked.

FieldType
ok*true
reservationId*string (uuid)
400

The update failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No enquiry with that id in this org, or its booking has gone.

409

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Enquiry id.

Request body application/json

FieldType
status*"new" | "open" | "won" | "lost"

Responses

200

Updated.

FieldType
ok*true
400

An unknown status.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-groups

Create a link group

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
name*string(min length 1, max length 40)

Responses

200

Created.

FieldType
ok*true
linkGroup*object
linkGroup.idstring (uuid)
400

A validation message from parseLinkGroupInput, or an unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

Table combining is Venue Pro+ only (src/lib/plan.ts, table_combining).

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

A group carries nothing but a name, so this is the whole of it. Membership is changed by editing the tables.

Parameters

  • id*pathstring

    Link group id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseLinkGroupInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieadmin onlyengine-guarded · hospitality · 403 otherwise

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*pathstring

    Link group id.

Responses

200

Deleted.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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/locks

Area locks touching a date

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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*querystring

    UTC calendar date, YYYY-MM-DD.

Responses

200

The locks. Exactly one of levelId/areaId/tableId is non-null on each; the database enforces it.

FieldType
locks*object[]
locks[].idstring (uuid)
locks[].levelIdstring | null (uuid)
locks[].areaIdstring | null (uuid)
locks[].tableIdstring | null (uuid)
locks[].startsAtstring (date-time)
locks[].endsAtstring (date-time)
locks[].reasonstring | null
400

Missing or malformed date.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/locks

Lock a floor, room or table

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
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 names.

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.

fullDayboolean(default true)

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.

fromstring(pattern ^([01]\d|2[0-3]):[0-5]\d$)

Start of a part-day lock. Send with to.

tostring(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 from is rejected: the lock would cover no time.

reasonstring | null(max length 200)

What the floor screen shows on the badge; "Smith wedding", "Deep clean".

Responses

200

Locked.

FieldType
ok*true
lock*object
lock.idstring (uuid)
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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)

Session cookieadmin onlyengine-guarded · hospitality · 403 otherwise

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*pathstring

    Area lock id.

Responses

200

Removed.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-notes

Read the floor's shared note for a date

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Returns an empty note, never a 404, when nothing has been written yet; the absence of a note is not an error.

Parameters

  • date*querystring

    YYYY-MM-DD.

  • servicePeriodIdquerystring (uuid)optional

    Omit for the day note. A specific service period id for that service's own note, offered alongside the day note.

Responses

200

The note, or an empty one if nothing has been written for this date/service yet.

FieldType
note*string
updatedByEmail*string | null
updatedAt*string | null (date-time)
400

Missing or malformed date, or a malformed servicePeriodId.

401

No valid session cookie. {"error":"Not signed in"}.

403

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-notes

Save the floor's shared note for a date

Session cookieengine-guarded · hospitality · 403 otherwise

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.

An empty string is how a note is cleared; there is no DELETE.

Request body application/json

FieldTypeNotes
date*string(pattern ^\d{4}-\d{2}-\d{2}$)
servicePeriodIdstring | null (uuid)

Omit or null for the day note.

notestring(max length 2000)

Responses

200

Saved.

FieldType
note*string
updatedByEmail*string | null
updatedAt*string | null (date-time)
400

Missing or malformed date, a malformed servicePeriodId, or a Postgres error from the RPC.

401

No valid session cookie. {"error":"Not signed in"}.

403

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/areas

Create an area (room)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
levelIdstring | 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.

bookableboolean(default true)

false lets staff seat/assign here without ever offering it to a guest.

descriptionstring | null(max length 500)

Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description.

Responses

200

Created.

FieldType
ok*true
area*object
area.idstring (uuid)
400

A validation message from parseAreaInput, or a Postgres error (including a foreign levelId refused by the composite foreign key).

401

No valid session cookie. {"error":"Not signed in"}.

403

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/areas

Reorder areas (rooms)

Session cookieengine-guarded · hospitality · 403 otherwise

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

Request body application/json

FieldTypeNotes
ids*string (uuid)[](min items 1, max items 100)

Every area (or level) id in this org, in the order they should appear.

Responses

200

Reordered.

FieldType
ok*true
400

ids missing, empty, over 100 entries, or containing a non-uuid.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

Full replacement of the area's fields, same as its table twin.

Parameters

  • id*pathstring

    Area id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)
levelIdstring | 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.

bookableboolean(default true)

false lets staff seat/assign here without ever offering it to a guest.

descriptionstring | null(max length 500)

Migration 0111. Renders on the public booking widget's location picker, same treatment as a service's description.

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseAreaInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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)

Session cookieadmin onlyengine-guarded · hospitality · 403 otherwise

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*pathstring

    Area id.

Responses

200

Deleted.

FieldType
ok*true
400

An unhandled Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Upload an area (room) photo

Session cookieengine-guarded · hospitality · 403 otherwise

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

Parameters

  • id*pathstring

    Area id.

Request body multipart/form-data

The area photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldTypeNotes
ok*true
imageUrl*string (uri)

Public URL with a ?v=<timestamp> cache-buster; the storage path itself never changes.

400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Remove an area photo

Session cookieengine-guarded · hospitality · 403 otherwise

Deletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.

Parameters

  • id*pathstring

    Area id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-periods

Replace a named service period (admin, or manage_services)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
name*string(min length 1, max length 40)

The name the group will have after the write.

oldNamestring(min length 1, max length 40)

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.

daysobject[](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 dayOfWeek is not rejected: overlapping windows are something computeReservationSlots handles on purpose.

days[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay(). The dashboard lists the week Monday-first; that is display order only.

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[].lastSeatingstring | null

HH:MM, must fall between startTime and endTime. Null means "up to endTime".

turnMinutesinteger(min 15, max 480)

How long a sitting occupies its table. Required whenever days is non-empty.

slotMinutesinteger(min 5, max 120)

Booking interval within the period. Required whenever days is non-empty.

maxCoversinteger | null(min 1)

Total covers seated at once in this period, on top of table capacity. Null = uncapped.

activeboolean(default true)

Responses

200

Written. days echoes how many rows the period now has, 0 for a delete.

FieldTypeNotes
ok*true
name*string
days*integer(min 0, max 7)
400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

500

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/reservations

Create a reservation (staff side)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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.

FieldTypeNotes
partySize*integer(min 1, max 30)
turnMinutes*integer(min 5, max 600)
startsAt*string (date-time)
tableIdstring | null (uuid)

Null is legal and common: a venue that caps covers without assigning tables works as-is.

occasionstring | null(max length 60)
notesstring | null(max length 1000)
walkInboolean

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

seatedboolean

True inserts the sitting already seated rather than confirmed: a walk-in being shown to their table is already sitting down.

customerIdstring (uuid)

An existing client in this org. A foreign or unknown id is a 400 ("Client not found").

customerNamestring(min length 1, max length 120)

Required when customerId is absent.

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

customerPhonestring(min length 1, max length 40)

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

Created. Same bare {id} shape as POST /api/appointments.

FieldType
id*string (uuid)
400

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

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

409

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/servers

Add a floor server

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Front-of-house work, so no admin gate: the same trust level as moving a party between tables.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)

Responses

200

Created.

FieldTypeNotes
id*string (uuid)
name*string
color*string

#rrggbb, assigned by the server.

400

Missing or over-long name.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Server id.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 40)

Responses

200

The updated server.

FieldTypeNotes
id*string (uuid)
name*string
color*string

#rrggbb, unchanged by this call.

400

Missing or over-long name.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Server id.

Responses

200

Deleted, assignments included.

FieldType
ok*true
400

An unexpected database error, surfaced verbatim.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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-assignments

Assign a table to a server for a date

Session cookieengine-guarded · hospitality · 403 otherwise

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.

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

FieldTypeNotes
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

200

Assigned (or cleared).

FieldType
ok*true
400

Missing fields, or a composite FK refusing a table/server id that does not exist in this org.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Reservation id.

Request body application/json

FieldTypeNotes
status"pending" | "confirmed" | "seated" | "completed" | "cancelled" | "no_show"

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.

partySizeinteger(min 1, max 30)
tableIdstring | null (uuid)

Explicit null unassigns the table.

startsAtstring (date-time)
turnMinutesinteger(min 5, max 600)
occasionstring | null(max length 60)
notesstring | null(max length 1000)
phonestring | 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 PATCH /api/clients/{id} applies to the same column from the roster side.

Responses

200

Updated.

FieldType
ok*true
400

A validation message, an attempt to set hold by hand, or "Nothing to change".

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

No reservation with that id in this org, or a malformed uuid.

409

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/experiences

Create an experience

Session cookieengine-guarded · hospitality · 403 otherwise

Adds 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

FieldTypeNotes
name*string(min length 1, max length 120)
descriptionstring | null(max length 500)
categorystring | null(max length 60)

A free-form label ("Special night", "Set menu"). No categories table for v1, unlike Products.

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

eventDatestring | 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

200

Created.

FieldType
experience*object
experience.idstring (uuid)
400

A validation message from parseExperienceInput, or the raw Postgres message if the insert itself failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Session cookieengine-guarded · hospitality · 403 otherwise

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*pathstring

    Experience id.

Request body application/json

Responses

200

Updated.

FieldType
ok*true
400

A validation message from parseExperienceInput.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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}/image

Upload an experience image

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Parameters

  • id*pathstring

    Experience id.

Request body multipart/form-data

The experience image. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldTypeNotes
ok*true
imageUrl*string (uri)

Public URL with a ?v=<timestamp> cache-buster; the storage path itself never changes.

400

No file, wrong MIME type, over 2 MB, or a storage error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/image

Remove an experience image

Session cookieengine-guarded · hospitality · 403 otherwise

Deletes the stored object and nulls image_url. Storage removal is best-effort and its failure does not fail the request.

Parameters

  • id*pathstring

    Experience id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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}/tickets

The door-staff guestlist for one experience

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Parameters

  • id*pathstring

    Experience id.

Responses

200

The guestlist.

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

401

No valid session cookie. {"error":"Not signed in"}.

403

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/checkin

Scan or manually check in a ticket

Session cookieengine-guarded · hospitality · 403 otherwise

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

Request body application/json

FieldTypeNotes
tokenstring

The ticket QR payload, or its bare token.

ticketIdstring (uuid)
undoboolean

Default false. True clears an existing check-in.

Responses

200

The ticket, and whether it was already checked in before this call.

FieldType
ticket*object
ticket.ticketIdstring (uuid)
ticket.tokenstring (uuid)
ticket.quantityinteger
ticket.seatLabelstring | null
ticket.checkedInAtstring | null (date-time)
ticket.reservationIdstring (uuid)
ticket.reservationStatusstring
ticket.partySizeinteger
ticket.customerNamestring
ticket.customerPhonestring | null
ticket.experienceNamestring
alreadyCheckedIn*boolean
400

Neither token nor ticketId was given.

401

No valid session cookie. {"error":"Not signed in"}.

403

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.

404

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/funnel

Hospitality's "Your booking funnel" (self-hosted, cookieless)

Session cookieengine-guarded · hospitality · 403 otherwise

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.

Parameters

  • daysquery7 | 30 | 90optional

    The trailing window. Anything else silently falls back to 30, same as getWebsiteAnalytics' identical parameter.

Responses

200

The funnel, step by step, ending on the real number of completed reservations.

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

401

No valid session cookie. {"error":"Not signed in"}.

402

The analytics dashboard is included from Pro upward, or purchasable standalone at A$4.99/mo on Solo/Basic (src/lib/plan.ts, analytics).

403

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.

502

The Umami API call itself failed (network error, or a non-2xx from analytics.solvintia.com) for a reason other than being unconfigured.

503

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}/availability

List bookable appointment slots

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • service_id*querystring

    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.

  • 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_idquerystringoptional

    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.

  • daysqueryintegeroptional

    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}, ...}}.

  • manage_tokenquerystring (uuid)optional

    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

The free slots for the requested day, or one entry per day in ranged mode. May legitimately be empty.

400

service_id or date missing, more than six service ids, or days outside 1-14.

404

No active org with that slug, or ANY of the requested ids is not an active service in it.

500

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}/appointments

Book an appointment (guest checkout)

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request body application/json

FieldTypeNotes
serviceIdstring (uuid)

The single-service spelling. Exactly one of serviceId / serviceIds is required; if both are sent, serviceIds wins.

serviceIdsstring (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, any is not accepted; pick one from the slot list.

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

promoCodestring

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

Booked. A confirmation notification is queued after the response is sent.

FieldTypeNotes
appointment*object

Snake_case, because it is the database row selected straight back. The only response in the API that is not camelCase.

appointment.idstring (uuid)
appointment.starts_atstring (date-time)
appointment.ends_atstring (date-time)
calendarobject

"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.googleCalendarUrlstring | null (uri)

A pre-filled "create event" link on calendar.google.com. The guest saves it themselves, no OAuth, no token stored.

calendar.icsUrlstring | 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.

promotionobject | null

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.

promotion.kind"sale" | "code"
promotion.discountCentsinteger
400

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.

404

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.

409

book_appointments_no_overlap fired; someone booked that exact slot first.

500

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/validate

Preview a promo code before checkout (public, unauthenticated)

Public · no auth

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.

Parameters

  • slug*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request body application/json

FieldTypeNotes
code*string
serviceIds*string (uuid)[](min items 1)

Responses

200

The code applies.

FieldType
discountCents*integer
400

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

404

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}/enquiries

Send a big-group enquiry (public)

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request body application/json

FieldTypeNotes
partySize*integer(min 1, max 500)

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.

preferredDatestring | null (date)

A hint only; nothing allocates from it. A malformed value is a 400 rather than a silent drop.

preferredTimestring | 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)
messagestring | null(max length 2000)
answersobject

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

Sent.

FieldType
ok*true
enquiryId*string (uuid)
400

A validation message, an answer that does not match the saved form, or a party size that does not need an enquiry.

404

No active org with that slug, or the org is not a hospitality business.

500

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}/waitlist

Join the waitlist (public)

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request body application/json

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

requestedFromstring | null(pattern ^([01]\d|2[0-3]):[0-5]\d$)

Earliest acceptable time. Null with requestedTo also null means any time that day, which is the default and the common case.

requestedTostring | null(pattern ^([01]\d|2[0-3]):[0-5]\d$)

Latest acceptable time. Must not be earlier than requestedFrom.

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.

notesstring | null(max length 2000)
experienceIdstring | 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

200

Joined.

FieldType
ok*true
waitlistId*string (uuid)
400

A validation message, or a party size above the venue's online ceiling (carries enquiryRequired: true).

404

No active org with that slug, or the org is not a hospitality business.

500

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/availability

List bookable table slots

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; 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*queryinteger

    Capped at 30: party size is attacker-controlled and feeds a slot loop.

  • area_idquerystring (uuid)optional

    Narrows to one room. Mutually exclusive with level_id; sending both is a 400.

  • level_idquerystring (uuid)optional

    Narrows to one floor, INCLUSIVE of every room nested under it. Mutually exclusive with area_id; sending both is a 400.

  • experience_idquerystring (uuid)optional

    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.

  • daysqueryintegeroptional

    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}, ...}}.

  • manage_tokenquerystring (uuid)optional

    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

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.

400

Missing or malformed date, a party_size outside 1-30, both area_id and level_id sent together, or days outside 1-14.

404

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.

500

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-options

List which tables are free for one exact slot

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • date*querystring (date)

    YYYY-MM-DD, strictly.

  • party_size*queryinteger
  • starts_at*querystring (date-time)

    The exact slot start the guest picked from the availability grid.

  • area_idquerystring (uuid)optional

    Same filter as the availability route. Mutually exclusive with level_id.

  • level_idquerystring (uuid)optional

    Same filter as the availability route, inclusive of nested rooms. Mutually exclusive with area_id.

Responses

200

Tables free at that instant, smallest-first; the first entry is always the same table auto-pick would choose.

FieldType
tables*object[]
tables[].idstring (uuid)
tables[].namestring
tables[].seatsMininteger
tables[].seatsMaxinteger
400

Missing/malformed date, party_size, or starts_at, or both area_id and level_id sent together.

404

No active org with that slug, the org is not a hospitality business, or the org has not turned on table selection.

500

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}/reservations

Hold a table (guest checkout, step 1)

Public · no auth

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.

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request body application/json

FieldTypeNotes
startsAt*string (date-time)

Must match a computed slot start exactly.

partySize*integer(min 1, max 30)
areaIdstring | null (uuid)

Narrows to one room. Mutually exclusive with levelId; sending both is a 400.

levelIdstring | null (uuid)

Narrows to one floor, inclusive of every room nested under it. Mutually exclusive with areaId; sending both is a 400.

tableIdstring | null (uuid)

A guest's specific table preference. Ignored (not rejected) when the org has not turned on table selection.

experienceIdstring | null (uuid)

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

Table held.

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

400

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.

404

No active org with that slug, or the org is not a hospitality business.

409

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.

500

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)

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • id*pathstring

    The reservationId returned by the hold. Scoped to the slug, so one venue's URL can never confirm another venue's hold.

Request body application/json

FieldTypeNotes
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.notesstring | 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.

occasionstring | null

Truncated to 80 characters rather than rejected.

Responses

200

Confirmed. A confirmation notification is queued after the response is sent.

FieldTypeNotes
reservation*object

Snake_case; the database row selected straight back.

reservation.idstring (uuid)
reservation.starts_atstring (date-time)
reservation.ends_atstring (date-time)
reservation.party_sizeinteger
400

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.

404

No active org with that slug, or the org is not a hospitality business.

410

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

500

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

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • id*pathstring

    The reservation id from the hold.

Responses

200

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.

FieldType
released*boolean
404

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

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • token*pathstring

    The booking's manage token.

Request body application/json

FieldTypeNotes
message*string(min length 1, max length 2000)

Responses

200

Sent. It appears in the venue's Inbox.

FieldType
ok*true
400

An empty message, or one over 2000 characters.

404

The link does not resolve.

429

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.

500

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

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • token*pathstring

    The booking's manage token.

Request body application/json

FieldTypeNotes
answers*object

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.

Responses

200

Saved. Staff see it on the booking.

FieldType
ok*true
400

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.

403

The booking is cancelled, or it has already finished; there is nothing left to prepare for. Carries a guest-readable sentence.

404

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.

409

A second submit raced the first; two tabs, or a double tap, and the one-response-per-booking index caught the loser.

500

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

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • token*pathstring

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

Request body application/json

FieldTypeNotes
startsAt*string (date-time)

Must be at least 60 seconds away, at least guest_manage_cutoff_hours away, and match a computed slot exactly.

Responses

200

Moved. A change notification is queued after the response is sent.

FieldType
booking*object
booking.idstring (uuid)
booking.starts_atstring (date-time)
booking.ends_atstring (date-time)
400

No time given, an unparseable time, a time already past, or a time inside the org's notice period.

403

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.

404

The slug, the token, or the pairing of the two does not resolve. Deliberately indistinguishable; "That link is no longer valid".

409

The requested time is no longer free, or was taken between the availability read and the update.

500

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

Public · no auth

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*pathstring

    The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

  • token*pathstring

    The booking's manage token.

Responses

200

Cancelled. A cancellation notification is queued after the response is sent.

FieldType
ok*true
403

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.

404

The link does not resolve.

409

Its status changed underneath the request; in practice, it was already cancelled.

500

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

Public · no auth

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*pathstring

    The 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

FieldTypeNotes
messagestring(max length 2000)

What went wrong. Optional; an empty submission still records that the guest tapped through.

Responses

200

Saved to book_feedback. Visible to staff on the Reviews tab.

FieldType
ok*true
400

The message is over 2000 characters.

404

The token does not resolve to a reservation or an appointment.

500

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

Public · no auth

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*pathstring

    The customer's unsubscribe token (a uuid); see unsubscribeUrl() in lib/marketing/consent.ts.

Request body multipart/form-data

FieldTypeNotes
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

200

marketing_status written on book_customers; the response also carries status, the value that was set.

FieldType
ok*true
404

The token does not resolve to a customer row.

500

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}/positive

Log a guest's thumb-up tap and redirect to the org's Google review link

Public · no auth

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.

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*pathstring

    The booking's manage token, reused as the feedback link's credential; see feedbackUrl() in lib/booking/manage.ts.

Responses

200

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

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

Shared

Clients, teammates, messages, notes, templates, branding, profile, account, organization settings. Deliberately NOT engine-guarded: a client record and a teammate mean the same thing to both products, and guarding these would lock half the product out of its own settings.

get/api/v1/clients

List clients (API key)

API keyclients:read

The client/guest directory, for a program. Shared by both engines and gated with no vertical for that reason: a client record means the same thing to a salon and a restaurant, which is why the dashboard screen behind it is one shared route too. There is consequently no wrong_vertical failure here.

Read-only, like every /api/v1 operation. As above, the handler's explicit company_id filter is the entire tenant boundary: removing it would leak every organization's client list.

Ordered newest first. No date filters: a client directory is not time-shaped, and there is no search parameter either, deliberately: a ?q= would be a third .or() call site to sanitize and pagination already lets a caller walk the whole list.

Parameters

  • limitqueryintegeroptional

    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.

  • cursorquerystringoptional

    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.

    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 and limit may change between pages; the cursor only says where you got to.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The clients, newest first.

FieldTypeNotes
data*object[]
data[].idstring (uuid)
data[].namestring
data[].emailstring | null (email)
data[].phonestring | null
data[].createdAtstring (date-time)
nextCursor*string | null

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.

400

invalid_cursor: the cursor was not one this API issued. Pass back nextCursor unchanged; do not construct one.

FieldTypeNotes
error*string
code*"invalid_cursor"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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. There is no wrong_vertical here: this operation is shared by both engines.

FieldTypeNotes
error*string
code*"insufficient_scope"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The query failed. The body is the raw Postgres message and carries no code, unlike the gate's own errors above.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

Raw OpenAPI operation
{
  "summary": "List clients (API key)",
  "description": "The client/guest directory, for a program. **Shared by both engines** and gated with no vertical for that reason: a client record means the same thing to a salon and a restaurant, which is why the dashboard screen behind it is one shared route too. There is consequently no `wrong_vertical` failure here.\n\nRead-only, like every `/api/v1` operation. As above, the handler's explicit `company_id` filter is the entire tenant boundary: removing it would leak every organization's client list.\n\nOrdered newest first. No date filters: a client directory is not time-shaped, and there is no search parameter either, deliberately: a `?q=` would be a third `.or()` call site to sanitize and pagination already lets a caller walk the whole list.",
  "tags": [
    "Shared"
  ],
  "operationId": "listClientsV1",
  "security": [
    {
      "bearerApiKey": [
        "clients:read"
      ]
    }
  ],
  "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 clients, newest first.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "data",
              "nextCursor"
            ],
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "type": "object",
                  "description": "camelCase. `notes` is deliberately absent: it is the venue's private note ABOUT a client rather than the client's own data, and it is not exposed to a program-held credential.",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "email": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "email"
                    },
                    "phone": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              },
              "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`. There is no `wrong_vertical` here: this operation is shared by both engines.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "error",
              "code"
            ],
            "properties": {
              "error": {
                "type": "string"
              },
              "code": {
                "type": "string",
                "enum": [
                  "insufficient_scope"
                ]
              },
              "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/organization

Read the organization (API key)

API keyorganization:read

The calling organization's own profile and settings. Shared, and this is the operation that makes the rest of the surface navigable: businessType here is how a program learns which engine it is talking to, and therefore which of the two sets of endpoints below apply to it. Guarding it to one vertical would make that undiscoverable.

A singleton, so the envelope is { data: { … } } with an object and there is no nextCursor, no limit and no cursor. One envelope, always data, so a caller unwraps every v1 response the same way.

timezone is the org's own IANA zone, and it is what the wall-clock times on GET /api/v1/service-periods are expressed in and what `startsAt`/`endsAt` on the two booking lists are expressed in too. A program reading bookings needs this endpoint to interpret them correctly, so a key granted only appointments:read or reservations:read cannot resolve them; grant organization:read alongside. See the timestamps section of docs/api-keys.md. currency and the deposit* fields are what a program needs to quote a deposit without guessing.

There is deliberately no `organization:write` scope to pair with this. Migration 0022 exists because a tenant could edit its own slug, status and business_type outside the app; handing that back to a long-lived program-held credential would undo it. solvintia_client_id is withheld: it links this org to a record in a different Solvintia product and means nothing here.

Responses

200X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

The organization.

FieldTypeNotes
data*object

camelCase.

data.idstring (uuid)
data.namestring
data.slugstring

The {slug} in /{slug}.

data.industrystring | null
data.statusstring

Always active here: the gate refuses a key whose org is not, so no other value is reachable.

data.timezonestring

IANA zone, e.g. Europe/Rome. The key to every booking time this API returns: startsAt/endsAt on appointments and reservations, and the wall-clock times on GET /api/v1/service-periods, are all expressed in this zone rather than in UTC. A program that reads bookings should read this once and cache it.

data.businessType"appointments" | "hospitality"

Which engine this org is. Fixed at creation; changing it would strand whatever the org already has.

data.taglinestring | null
data.currencystring
data.logoUrlstring | null
data.backgroundImageUrlstring | null
data.accentColorstring | null
data.bookingThemestring
data.bookingThemeToggleEnabledboolean
data.notifyEmailEnabledboolean
data.notifySmsEnabledboolean
data.depositEnabledboolean
data.depositType"fixed" | "percentage"

Which of the next two fields to use. fixed reads depositAmountCents; percentage reads depositPercentage against the booking's own total. A program quoting a deposit for a specific booking must branch on this rather than assuming depositAmountCents is always the answer.

data.depositAmountCentsinteger | null

Only meaningful when depositType is fixed.

data.depositPercentageinteger | null

1-100. Only meaningful when depositType is percentage. 100 is a full-payment-at-booking policy, not a distinct third type.

data.depositPerPersonboolean
data.depositMinPartySizeinteger | null
data.depositRefundWindowHoursinteger
data.guestManageEnabledboolean
data.guestManageCutoffHoursinteger
data.createdAtstring (date-time)
401

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.

FieldTypeNotes
error*string
code*"missing_authorization" | "invalid_key" | "key_revoked" | "key_expired" | "organization_inactive"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

403X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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. There is no wrong_vertical here: this operation is shared by both engines.

FieldTypeNotes
error*string
code*"insufficient_scope"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

429Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset

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.

FieldTypeNotes
error*string
code*"rate_limited"
required_scopestring

Present only on insufficient_scope: the scope the key would need.

500

The row could not be read. 500 rather than 404 on purpose: the gate already read this row to check status and business_type, so a miss here means it vanished mid-request. Nothing the caller sent is wrong, and a 404 would send them looking for a bad key.

FieldTypeNotes
error*string
code*
required_scopestring

Present only on insufficient_scope: the scope the key would need.

Raw OpenAPI operation
{
  "summary": "Read the organization (API key)",
  "description": "The calling organization's own profile and settings. **Shared**, and this is the operation that makes the rest of the surface navigable: `businessType` here is how a program learns which engine it is talking to, and therefore which of the two sets of endpoints below apply to it. Guarding it to one vertical would make that undiscoverable.\n\nA **singleton**, so the envelope is `{ data: { … } }` with an object and there is no `nextCursor`, no `limit` and no `cursor`. One envelope, always `data`, so a caller unwraps every v1 response the same way.\n\n`timezone` is the org's own IANA zone, and it is what the wall-clock times on `GET /api/v1/service-periods` are expressed in **and what `startsAt`/`endsAt` on the two booking lists are expressed in too**. A program reading bookings needs this endpoint to interpret them correctly, so a key granted only `appointments:read` or `reservations:read` cannot resolve them; grant `organization:read` alongside. See the timestamps section of `docs/api-keys.md`. `currency` and the `deposit*` fields are what a program needs to quote a deposit without guessing.\n\nThere is deliberately **no `organization:write` scope** to pair with this. Migration 0022 exists because a tenant could edit its own `slug`, `status` and `business_type` outside the app; handing that back to a long-lived program-held credential would undo it. `solvintia_client_id` is withheld: it links this org to a record in a different Solvintia product and means nothing here.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationV1",
  "security": [
    {
      "bearerApiKey": [
        "organization:read"
      ]
    }
  ],
  "responses": {
    "200": {
      "description": "The organization.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "data"
            ],
            "properties": {
              "data": {
                "type": "object",
                "description": "camelCase.",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "name": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string",
                    "description": "The `{slug}` in /{slug}."
                  },
                  "industry": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "status": {
                    "type": "string",
                    "description": "Always `active` here: the gate refuses a key whose org is not, so no other value is reachable."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA zone, e.g. `Europe/Rome`. **The key to every booking time this API returns**: `startsAt`/`endsAt` on appointments and reservations, and the wall-clock times on `GET /api/v1/service-periods`, are all expressed in this zone rather than in UTC. A program that reads bookings should read this once and cache it."
                  },
                  "businessType": {
                    "type": "string",
                    "enum": [
                      "appointments",
                      "hospitality"
                    ],
                    "description": "Which engine this org is. Fixed at creation; changing it would strand whatever the org already has."
                  },
                  "tagline": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "currency": {
                    "type": "string"
                  },
                  "logoUrl": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "backgroundImageUrl": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "accentColor": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "bookingTheme": {
                    "type": "string"
                  },
                  "bookingThemeToggleEnabled": {
                    "type": "boolean"
                  },
                  "notifyEmailEnabled": {
                    "type": "boolean"
                  },
                  "notifySmsEnabled": {
                    "type": "boolean"
                  },
                  "depositEnabled": {
                    "type": "boolean"
                  },
                  "depositType": {
                    "type": "string",
                    "enum": [
                      "fixed",
                      "percentage"
                    ],
                    "description": "Which of the next two fields to use. `fixed` reads `depositAmountCents`; `percentage` reads `depositPercentage` against the booking's own total. A program quoting a deposit for a specific booking must branch on this rather than assuming `depositAmountCents` is always the answer."
                  },
                  "depositAmountCents": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Only meaningful when `depositType` is `fixed`."
                  },
                  "depositPercentage": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "1-100. Only meaningful when `depositType` is `percentage`. 100 is a full-payment-at-booking policy, not a distinct third type."
                  },
                  "depositPerPerson": {
                    "type": "boolean"
                  },
                  "depositMinPartySize": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "depositRefundWindowHours": {
                    "type": "integer"
                  },
                  "guestManageEnabled": {
                    "type": "boolean"
                  },
                  "guestManageCutoffHours": {
                    "type": "integer"
                  },
                  "createdAt": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        }
      },
      "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."
        }
      }
    },
    "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`. There is no `wrong_vertical` here: this operation is shared by both engines.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "error",
              "code"
            ],
            "properties": {
              "error": {
                "type": "string"
              },
              "code": {
                "type": "string",
                "enum": [
                  "insufficient_scope"
                ]
              },
              "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 row could not be read. 500 rather than 404 on purpose: the gate already read this row to check `status` and `business_type`, so a miss here means it vanished mid-request. Nothing the caller sent is wrong, and a 404 would send them looking for a bad key.",
      "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/feedback-submissions

Send product feedback (bug report, idea, or general) to Solvintia

Session cookie

Reached from NavUser's account menu, present for every signed-in dashboard login regardless of role or vertical: deliberately NOT the guest post-booking review at /api/feedback/{token} above, a different route on a different table for a different audience. Written through the caller's own RLS-scoped client, not service-role: book_feedback_submissions' tenant_insert_own policy (migration 0182) already enforces company_id and user_id both matching the JWT, so this route trusts the database rather than re-checking what it already checks.

On success, also fires a best-effort alert email to ALERT_EMAIL || NOTIFICATIONS_FROM_EMAIL (the same operator address /api/cron/queue-health alerts to), synchronously rather than through book_job_queue: this is low-volume internal alerting, not a customer-facing send that needs retry resilience. A Resend failure here never turns a successful submission into an error response; the row is already saved.

Private inbox by design: nothing this route returns, and nothing /superadmin/feedback shows, is ever surfaced back to the submitter beyond the 200 itself.

Request body application/json

FieldTypeNotes
type*"bug" | "idea" | "other"
message*string(min length 1, max length 4000)
pageUrlstring(max length 500)

window.location.pathname at submit time, captured client-side. Optional, and never rendered as a link.

Responses

200

Saved.

FieldType
ok*true
id*string (uuid)
400

type is not one of bug/idea/other, or message is empty or over 4000 characters.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

500

The insert failed.

Raw OpenAPI operation
{
  "summary": "Send product feedback (bug report, idea, or general) to Solvintia",
  "description": "Reached from NavUser's account menu, present for every signed-in dashboard login regardless of role or vertical: deliberately NOT the guest post-booking review at /api/feedback/{token} above, a different route on a different table for a different audience. Written through the caller's own RLS-scoped client, not service-role: book_feedback_submissions' `tenant_insert_own` policy (migration 0182) already enforces `company_id` and `user_id` both matching the JWT, so this route trusts the database rather than re-checking what it already checks.\n\nOn success, also fires a best-effort alert email to `ALERT_EMAIL || NOTIFICATIONS_FROM_EMAIL` (the same operator address /api/cron/queue-health alerts to), synchronously rather than through book_job_queue: this is low-volume internal alerting, not a customer-facing send that needs retry resilience. A Resend failure here never turns a successful submission into an error response; the row is already saved.\n\nPrivate inbox by design: nothing this route returns, and nothing /superadmin/feedback shows, is ever surfaced back to the submitter beyond the 200 itself.",
  "tags": [
    "Shared"
  ],
  "operationId": "sendProductFeedback",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "type",
            "message"
          ],
          "properties": {
            "type": {
              "type": "string",
              "enum": [
                "bug",
                "idea",
                "other"
              ]
            },
            "message": {
              "type": "string",
              "minLength": 1,
              "maxLength": 4000
            },
            "pageUrl": {
              "type": "string",
              "maxLength": 500,
              "description": "window.location.pathname at submit time, captured client-side. Optional, and never rendered as a link."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "id"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "id": {
                "type": "string",
                "format": "uuid"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`type` is not one of bug/idea/other, or `message` is empty or over 4000 characters.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The insert failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/feedback-submissions/{id}/screenshot

Attach a screenshot to a feedback submission you just sent

Session cookie

A second request, not folded into the JSON POST above: the same "create the entity, then a dedicated route for its image" shape every other upload in this app takes. Every uploaded byte is decoded and RE-ENCODED through sharp before it ever reaches storage, never stored verbatim: the whole point, since a screenshot is the one attachment surface in this app that could carry a hidden payload for something other than a browser to read later ("AI reading this on the other end", 2026-09-01). See the route's own header for the full four-part threat model (format sniffed against a hardcoded magic-number allowlist before sharp ever sees the bytes; EXIF/XMP/ICC and anything appended past the image's own end-of-file marker dropped by the re-encode; input pixel count capped before decode to refuse a decompression bomb; and an ownership + 10-minute recency check, since the caller's own JWT cannot even SELECT this table to guess at one).

One attachment per submission. A second attempt is a 409, not a silent replace.

Parameters

  • id*pathstring

    The book_feedback_submissions row id, from the JSON POST's own response.

Request body multipart/form-data

The screenshot. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 15 MB or smaller. Anything else is a 400.

Responses

200

Re-encoded, stored, and the row updated with its path.

FieldType
ok*true
400

No file field, the file is empty or over 15 MB, its first bytes do not match PNG/JPEG/WebP, sharp could not decode it, or the re-encoded result is still over 5 MB.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No submission with that id belonging to the caller's own company and user_id (the ownership check reads this via service-role; the caller's own JWT has no SELECT grant here at all).

409

A screenshot is already attached, or more than 10 minutes have passed since the submission was created.

500

The storage upload or the row update failed.

Raw OpenAPI operation
{
  "summary": "Attach a screenshot to a feedback submission you just sent",
  "description": "A second request, not folded into the JSON POST above: the same \"create the entity, then a dedicated route for its image\" shape every other upload in this app takes. Every uploaded byte is decoded and RE-ENCODED through sharp before it ever reaches storage, never stored verbatim: the whole point, since a screenshot is the one attachment surface in this app that could carry a hidden payload for something other than a browser to read later (\"AI reading this on the other end\", 2026-09-01). See the route's own header for the full four-part threat model (format sniffed against a hardcoded magic-number allowlist before sharp ever sees the bytes; EXIF/XMP/ICC and anything appended past the image's own end-of-file marker dropped by the re-encode; input pixel count capped before decode to refuse a decompression bomb; and an ownership + 10-minute recency check, since the caller's own JWT cannot even SELECT this table to guess at one).\n\nOne attachment per submission. A second attempt is a 409, not a silent replace.",
  "tags": [
    "Shared"
  ],
  "operationId": "attachFeedbackScreenshot",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The book_feedback_submissions row id, from the JSON POST's own response."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "PNG, JPEG or WebP, 15 MB or smaller. Anything else is a 400."
            }
          }
        },
        "encoding": {
          "file": {
            "contentType": "image/png, image/jpeg, image/webp"
          }
        }
      }
    },
    "description": "The screenshot. Sent as multipart/form-data under the field name `file`."
  },
  "responses": {
    "200": {
      "description": "Re-encoded, stored, and the row updated with its path.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file field, the file is empty or over 15 MB, its first bytes do not match PNG/JPEG/WebP, sharp could not decode it, or the re-encoded result is still over 5 MB.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No submission with that id belonging to the caller's own company and user_id (the ownership check reads this via service-role; the caller's own JWT has no SELECT grant here at all).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "A screenshot is already attached, or more than 10 minutes have passed since the submission was created.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The storage upload or the row update failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/clients

Create a client

Session cookie

A client with no booking behind it yet. Shares its find-or-create-by-email path with the two staff-side create routes, and reports which branch it took so the UI can say "you have merged into an existing record" instead of silently touching someone else's.

With no email there is no dedup key at all, so a fresh row is always created. That is an accepted tradeoff, not a gap: book_customers allows a null email and the unique index is total.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
emailstring | null (email)(max length 254)
phone*string(min length 1, max length 40)

Required. Enforced in resolveOrCreateCustomer, shared with the two staff-side create routes, so all three agree. This is also why it matters here rather than being belt-and-braces: email is optional, so without it a client record could carry no way to reach the person at all.

notesstring | null(max length 1000)

The venue's PRIVATE note about this client. Never rendered to the client and never overwritten by guest checkout.

Responses

200

Created or merged. Note this route returns neither {ok:true} nor a bare {id}; a third create shape.

FieldTypeNotes
id*string (uuid)
merged*boolean

True when an existing client matched on email and was updated instead of a new one being created.

existingNamestring

Present only when merged is true: the name that record already had.

400

A validation message, or a Postgres error from the write.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Create a client",
  "description": "A client with no booking behind it yet. Shares its find-or-create-by-email path with the two staff-side create routes, and reports which branch it took so the UI can say \"you have merged into an existing record\" instead of silently touching someone else's.\n\nWith no email there is no dedup key at all, so a fresh row is always created. That is an accepted tradeoff, not a gap: `book_customers` allows a null email and the unique index is total.",
  "tags": [
    "Shared"
  ],
  "operationId": "createClient",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "name",
            "phone"
          ],
          "properties": {
            "name": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120
            },
            "email": {
              "type": [
                "string",
                "null"
              ],
              "format": "email",
              "maxLength": 254
            },
            "phone": {
              "type": "string",
              "minLength": 1,
              "maxLength": 40,
              "description": "Required. Enforced in `resolveOrCreateCustomer`, shared with the two staff-side create routes, so all three agree. This is also why it matters here rather than being belt-and-braces: `email` is optional, so without it a client record could carry no way to reach the person at all."
            },
            "notes": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 1000,
              "description": "The venue's PRIVATE note about this client. Never rendered to the client and never overwritten by guest checkout."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Created or merged. Note this route returns neither `{ok:true}` nor a bare `{id}`; a third create shape.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "merged"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "merged": {
                "type": "boolean",
                "description": "True when an existing client matched on email and was updated instead of a new one being created."
              },
              "existingName": {
                "type": "string",
                "description": "Present only when `merged` is true: the name that record already had."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message, or a Postgres error from 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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/clients/{id}

Update a client

Session cookie

Partial: only present keys are written.

Email is deliberately not editable. It is the client identity (the unique index on company_id, email), so changing it is a merge or a split of two records and their histories, not a field edit. Sending email is silently ignored.

tags and notes are both the venue's PRIVATE data about the client. Neither is ever rendered on a guest-facing surface or included in any email or SMS; the notify payload builders name their columns explicitly so they cannot start doing so by accident.

`marketingStatus` (migration 0143) is one-directional. The only accepted value is 'unsubscribed'; any other value, including 'subscribed', is a 400. Consent is the client's to give, never staff's to restore on their behalf, so this route has no way to resubscribe someone.

Parameters

  • id*pathstring

    Client id.

Request body application/json

FieldTypeNotes
namestring(min length 1, max length 120)
phonestring | null(max length 40)

May be CORRECTED but not REMOVED. Omitting the key leaves the number alone, as with every field here. Sending an explicit null or empty string is a 400 when the record already has a number, and is accepted (as the no-op it always was) when it does not, so a client who predates the mandatory-phone rule stays fully editable and simply stays blank. Requiring presence outright would have meant a host could not add a note or a tag to any of those records without first inventing a number, since the drawer sends its whole form on save.

notesstring | null(max length 1000)
tagsstring[](max items 10)

The venue's own free-text labels for this client; 'vip', 'gluten free', 'no-show risk'. There is no managed vocabulary: a tag exists as soon as somebody types it, and the clients screen suggests from what this org has already used.

Whole-array REPLACEMENT, not a merge; send the full set, and [] clears them.

Normalized on the way in: each entry is trimmed, blank entries are dropped, and the list is deduped case-insensitively keeping the first spelling. The 10-tag limit is applied AFTER deduping. An entry over 24 characters is a 400 rather than a silent truncation.

The same bounds are enforced by a CHECK constraint (book_customers_tags_valid, migration 0043), because authenticated holds a table-wide UPDATE grant on book_customers and can reach the column without passing this route.

marketingStatus"unsubscribed"

The only accepted value. Any other value (including 'subscribed') is a 400: this route can decline consent on a client's behalf, never restore it.

Responses

200

Updated.

FieldType
ok*true
400

A validation message, or "Nothing to update" when the body carried no recognised key.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No client with that id in this org, or a malformed uuid.

Raw OpenAPI operation
{
  "summary": "Update a client",
  "description": "Partial: only present keys are written.\n\n**Email is deliberately not editable.** It is the client identity (the unique index on `company_id, email`), so changing it is a merge or a split of two records and their histories, not a field edit. Sending `email` is silently ignored.\n\n`tags` and `notes` are both the venue's PRIVATE data about the client. Neither is ever rendered on a guest-facing surface or included in any email or SMS; the notify payload builders name their columns explicitly so they cannot start doing so by accident.\n\n**`marketingStatus` (migration 0143) is one-directional.** The only accepted value is `'unsubscribed'`; any other value, including `'subscribed'`, is a 400. Consent is the client's to give, never staff's to restore on their behalf, so this route has no way to resubscribe someone.",
  "tags": [
    "Shared"
  ],
  "operationId": "updateClient",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Client id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "minProperties": 1,
          "properties": {
            "name": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120
            },
            "phone": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 40,
              "description": "May be CORRECTED but not REMOVED. Omitting the key leaves the number alone, as with every field here. Sending an explicit null or empty string is a 400 when the record already has a number, and is accepted (as the no-op it always was) when it does not, so a client who predates the mandatory-phone rule stays fully editable and simply stays blank. Requiring presence outright would have meant a host could not add a note or a tag to any of those records without first inventing a number, since the drawer sends its whole form on save."
            },
            "notes": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 1000
            },
            "tags": {
              "type": "array",
              "maxItems": 10,
              "items": {
                "type": "string",
                "minLength": 1,
                "maxLength": 24
              },
              "description": "The venue's own free-text labels for this client; 'vip', 'gluten free', 'no-show risk'. There is no managed vocabulary: a tag exists as soon as somebody types it, and the clients screen suggests from what this org has already used.\n\nWhole-array REPLACEMENT, not a merge; send the full set, and `[]` clears them.\n\nNormalized on the way in: each entry is trimmed, blank entries are dropped, and the list is deduped case-insensitively keeping the first spelling. The 10-tag limit is applied AFTER deduping. An entry over 24 characters is a 400 rather than a silent truncation.\n\nThe same bounds are enforced by a CHECK constraint (`book_customers_tags_valid`, migration 0043), because `authenticated` holds a table-wide UPDATE grant on `book_customers` and can reach the column without passing this route."
            },
            "marketingStatus": {
              "type": "string",
              "enum": [
                "unsubscribed"
              ],
              "description": "The only accepted value. Any other value (including 'subscribed') is a 400: this route can decline consent on a client's behalf, never restore it."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Updated.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message, or \"Nothing to update\" when the body carried 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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No client with that id in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/clients/{id}/erase

Erase a client's personal data on request (admin)

Session cookieadmin only

Closes the gap the privacy policy already names: a business is responsible for honouring its own customers' access/deletion requests, but no tool existed to actually fulfil one for a single guest; only DELETE /api/account (the whole workspace) did.

Anonymises in place; never deletes the row. book_appointments.customer_id/book_reservations.customer_id are both NOT NULL with no ON DELETE clause, so a client with any booking history cannot be hard-deleted without also deleting that booking, which an erasure request never asked for.

Clears name (replaced with "Erased guest"), email, phone, notes, tags and the birthday fields, and sets marketingStatus to 'unsubscribed' permanently. Booking history, id, company_id and created_at are untouched. Never refuses on upcoming bookings: the count is reported instead, since refusing would let a venue stall a legitimate request indefinitely just by leaving one on the calendar.

Writes an append-only book_compliance_events row (customer_erased) recording who did it and when. Idempotent: re-running this on an already-erased record just rewrites the same values and logs another event.

Parameters

  • id*pathstring

    Client id.

Request body application/json

FieldTypeNotes
confirm*"ERASE"

Must be exactly the string "ERASE", so a stray fetch can never erase anything by accident.

Responses

200

Erased.

FieldTypeNotes
ok*true
upcomingBookingsAffected*integer(min 0)

Count of non-cancelled future bookings that will now show "Erased guest" with no phone or email on file.

400

confirm was not "ERASE", or a Postgres error from the write.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No client with that id in this org, or a malformed uuid.

Raw OpenAPI operation
{
  "summary": "Erase a client's personal data on request (admin)",
  "description": "Closes the gap the privacy policy already names: a business is responsible for honouring its own customers' access/deletion requests, but no tool existed to actually fulfil one for a single guest; only DELETE /api/account (the whole workspace) did.\n\nAnonymises in place; never deletes the row. `book_appointments.customer_id`/`book_reservations.customer_id` are both `NOT NULL` with no `ON DELETE` clause, so a client with any booking history cannot be hard-deleted without also deleting that booking, which an erasure request never asked for.\n\nClears `name` (replaced with \"Erased guest\"), `email`, `phone`, `notes`, `tags` and the birthday fields, and sets `marketingStatus` to `'unsubscribed'` permanently. Booking history, `id`, `company_id` and `created_at` are untouched. Never refuses on upcoming bookings: the count is reported instead, since refusing would let a venue stall a legitimate request indefinitely just by leaving one on the calendar.\n\nWrites an append-only `book_compliance_events` row (`customer_erased`) recording who did it and when. Idempotent: re-running this on an already-erased record just rewrites the same values and logs another event.",
  "tags": [
    "Shared"
  ],
  "operationId": "eraseClient",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Client id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "confirm"
          ],
          "properties": {
            "confirm": {
              "const": "ERASE",
              "description": "Must be exactly the string \"ERASE\", so a stray fetch can never erase anything by accident."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Erased.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "upcomingBookingsAffected"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "upcomingBookingsAffected": {
                "type": "integer",
                "minimum": 0,
                "description": "Count of non-cancelled future bookings that will now show \"Erased guest\" with no phone or email on file."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`confirm` was not \"ERASE\", or a Postgres error from 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": "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 client with that id in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/sales

Record a sale

Session cookie

The shared "Add item" cart BeNotely uses for both a standalone walk-in "New sale" (no bookingTable/bookingId) and a booking's close-out (both set, as a pair: CloseOutDialog's own POST). No hasPermission gate: recording a sale is ordinary front-of-house work, the same posture PATCH /api/appointments/{id} and /api/reservations/{id} already take for closing out a booking. Calls book_record_sale() (service_role), which atomically inserts the sale and its line items and, for each product line, decrements stock via book_product_stock_adjust(): either the whole sale and every stock delta commit, or none do.

Request body application/json

FieldTypeNotes
items*object[](min items 1)
items[].kind*"service" | "product" | "custom"
items[].referenceIdstring (uuid)

The service or product id. Required when kind is "product" (it decrements that product's stock); optional otherwise.

items[].name*string
items[].unitPriceCents*integer(min 0)
items[].quantity*integer(min 1)
discountType"percent" | "fixed"
discountValuenumber

A percent (0-100) or a minor-unit amount, per discountType. Required when discountType is set.

giftCardAppliedCentsinteger(min 0)

Amount an existing gift card covered. The redemption itself is a separate POST /api/marketing/gift-cards/{id}/redeem call; this is only the amount for this sale's own record.

tipCentsinteger(min 0, max 100000)

A gratuity, added to paid-in-person on top of the sale total (never covered by a gift card). Only meaningful on a standalone walk-in sale; a close-out-linked sale (bookingTable/bookingId set) should leave this null, since the booking's own tip_cents already covers the visit.

paymentMethod"cash" | "card" | "bank_transfer" | "other"

Required unless the gift card covers the whole total (paid-in-person would then be $0).

customerIdstring (uuid)
bookingTable"book_appointments" | "book_reservations"

Present only alongside bookingId, for a close-out-linked sale.

bookingIdstring (uuid)

Must be present exactly when bookingTable is.

soldByProviderIdstring (uuid)

Staff attribution (migration 0185): a plain snapshot onto the sale row, no behaviour depends on it. Only meaningful on the standalone walk-in "New sale" dialog; a close-out-linked sale has no field for this today.

Responses

200

Recorded.

FieldTypeNotes
sale*object
sale.idstring (uuid)
sale.company_idstring (uuid)
sale.booking_table"book_appointments" | "book_reservations" | null
sale.booking_idstring | null (uuid)
sale.customer_idstring | null (uuid)
sale.subtotal_centsinteger
sale.discount_type"percent" | "fixed" | null
sale.discount_valuenumber | null
sale.discount_centsinteger
sale.total_centsinteger
sale.gift_card_applied_centsinteger | null
sale.tip_centsinteger

A gratuity on top of the sale, folded into paid_in_person_cents but never total_cents. 0 unless the standalone walk-in flow collected one; a close-out-linked sale always has 0 here, since the booking's own tip_cents covers the visit.

sale.paid_in_person_centsinteger
sale.payment_method"cash" | "card" | "bank_transfer" | "other" | null
sale.status"completed" | "voided"
sale.void_reasonstring | null
sale.voided_atstring | null (date-time)
sale.created_bystring | null (uuid)
sale.created_atstring (date-time)
sale.sold_by_provider_idstring | null (uuid)

A plain attribution snapshot (migration 0185); no behaviour depends on it.

400

A validation message (an empty or malformed items array, a missing discountValue, an out-of-range tipCents, a missing payment method when paid-in-person is above $0, or bookingTable/bookingId present without its pair), or "Unknown or archived product {id}" if book_record_sale rejects a line item's product.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Record a sale",
  "description": "The shared \"Add item\" cart BeNotely uses for both a standalone walk-in \"New sale\" (no `bookingTable`/`bookingId`) and a booking's close-out (both set, as a pair: CloseOutDialog's own POST). No `hasPermission` gate: recording a sale is ordinary front-of-house work, the same posture PATCH /api/appointments/{id} and /api/reservations/{id} already take for closing out a booking. Calls `book_record_sale()` (service_role), which atomically inserts the sale and its line items and, for each product line, decrements stock via `book_product_stock_adjust()`: either the whole sale and every stock delta commit, or none do.",
  "tags": [
    "Shared"
  ],
  "operationId": "createSale",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "items"
          ],
          "additionalProperties": false,
          "properties": {
            "items": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "object",
                "required": [
                  "kind",
                  "name",
                  "unitPriceCents",
                  "quantity"
                ],
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "service",
                      "product",
                      "custom"
                    ]
                  },
                  "referenceId": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "The service or product id. Required when kind is \"product\" (it decrements that product's stock); optional otherwise."
                  },
                  "name": {
                    "type": "string"
                  },
                  "unitPriceCents": {
                    "type": "integer",
                    "minimum": 0
                  },
                  "quantity": {
                    "type": "integer",
                    "minimum": 1
                  }
                }
              }
            },
            "discountType": {
              "type": "string",
              "enum": [
                "percent",
                "fixed"
              ],
              "nullable": true
            },
            "discountValue": {
              "type": "number",
              "description": "A percent (0-100) or a minor-unit amount, per discountType. Required when discountType is set."
            },
            "giftCardAppliedCents": {
              "type": "integer",
              "minimum": 0,
              "nullable": true,
              "description": "Amount an existing gift card covered. The redemption itself is a separate POST /api/marketing/gift-cards/{id}/redeem call; this is only the amount for this sale's own record."
            },
            "tipCents": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100000,
              "nullable": true,
              "description": "A gratuity, added to paid-in-person on top of the sale total (never covered by a gift card). Only meaningful on a standalone walk-in sale; a close-out-linked sale (bookingTable/bookingId set) should leave this null, since the booking's own tip_cents already covers the visit."
            },
            "paymentMethod": {
              "type": "string",
              "enum": [
                "cash",
                "card",
                "bank_transfer",
                "other"
              ],
              "nullable": true,
              "description": "Required unless the gift card covers the whole total (paid-in-person would then be $0)."
            },
            "customerId": {
              "type": "string",
              "format": "uuid",
              "nullable": true
            },
            "bookingTable": {
              "type": "string",
              "enum": [
                "book_appointments",
                "book_reservations"
              ],
              "description": "Present only alongside bookingId, for a close-out-linked sale."
            },
            "bookingId": {
              "type": "string",
              "format": "uuid",
              "description": "Must be present exactly when bookingTable is."
            },
            "soldByProviderId": {
              "type": "string",
              "format": "uuid",
              "nullable": true,
              "description": "Staff attribution (migration 0185): a plain snapshot onto the sale row, no behaviour depends on it. Only meaningful on the standalone walk-in \"New sale\" dialog; a close-out-linked sale has no field for this today."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Recorded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "sale"
            ],
            "properties": {
              "sale": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "company_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "booking_table": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "book_appointments",
                      "book_reservations",
                      null
                    ]
                  },
                  "booking_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "customer_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "subtotal_cents": {
                    "type": "integer"
                  },
                  "discount_type": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "percent",
                      "fixed",
                      null
                    ]
                  },
                  "discount_value": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "discount_cents": {
                    "type": "integer"
                  },
                  "total_cents": {
                    "type": "integer"
                  },
                  "gift_card_applied_cents": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "tip_cents": {
                    "type": "integer",
                    "description": "A gratuity on top of the sale, folded into paid_in_person_cents but never total_cents. 0 unless the standalone walk-in flow collected one; a close-out-linked sale always has 0 here, since the booking's own tip_cents covers the visit."
                  },
                  "paid_in_person_cents": {
                    "type": "integer"
                  },
                  "payment_method": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "cash",
                      "card",
                      "bank_transfer",
                      "other",
                      null
                    ]
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "completed",
                      "voided"
                    ]
                  },
                  "void_reason": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "voided_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time"
                  },
                  "created_by": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "created_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "sold_by_provider_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "A plain attribution snapshot (migration 0185); no behaviour depends on it."
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message (an empty or malformed items array, a missing discountValue, an out-of-range tipCents, a missing payment method when paid-in-person is above $0, or bookingTable/bookingId present without its pair), or \"Unknown or archived product {id}\" if book_record_sale rejects a line item's product.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/sales/{id}/void

Void a sale

Session cookie

Reverses the sale's stock ONLY (book_void_sale(), reason sale_undo) and marks it voided: it does not un-collect any payment or reverse a gift-card redemption, the same "no undo here, correct it the way any other mistake is corrected" posture GiftCardRedeemField's own redemption already takes. No hasPermission gate, matching POST /api/sales.

Parameters

  • id*pathstring

    Sale id.

Request body application/json

FieldTypeNotes
reasonstring(max length 300)

Responses

200

Voided.

FieldTypeNotes
sale*object
sale.idstring (uuid)
sale.company_idstring (uuid)
sale.booking_table"book_appointments" | "book_reservations" | null
sale.booking_idstring | null (uuid)
sale.customer_idstring | null (uuid)
sale.subtotal_centsinteger
sale.discount_type"percent" | "fixed" | null
sale.discount_valuenumber | null
sale.discount_centsinteger
sale.total_centsinteger
sale.gift_card_applied_centsinteger | null
sale.tip_centsinteger

A gratuity on top of the sale, folded into paid_in_person_cents but never total_cents. 0 unless the standalone walk-in flow collected one; a close-out-linked sale always has 0 here, since the booking's own tip_cents covers the visit.

sale.paid_in_person_centsinteger
sale.payment_method"cash" | "card" | "bank_transfer" | "other" | null
sale.status"completed" | "voided"
sale.void_reasonstring | null
sale.voided_atstring | null (date-time)
sale.created_bystring | null (uuid)
sale.created_atstring (date-time)
sale.sold_by_provider_idstring | null (uuid)

A plain attribution snapshot (migration 0185); no behaviour depends on it.

400

A Postgres error other than the not-found case below, or "Cannot restore stock for archived or unknown product {id}" if a product line's product has since been archived.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No sale with that id in this org, or it was already voided.

Raw OpenAPI operation
{
  "summary": "Void a sale",
  "description": "Reverses the sale's stock ONLY (`book_void_sale()`, reason `sale_undo`) and marks it voided: it does not un-collect any payment or reverse a gift-card redemption, the same \"no undo here, correct it the way any other mistake is corrected\" posture GiftCardRedeemField's own redemption already takes. No `hasPermission` gate, matching POST /api/sales.",
  "tags": [
    "Shared"
  ],
  "operationId": "voidSale",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Sale id."
    }
  ],
  "requestBody": {
    "required": false,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "reason": {
              "type": "string",
              "maxLength": 300
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Voided.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "sale"
            ],
            "properties": {
              "sale": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "company_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "booking_table": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "book_appointments",
                      "book_reservations",
                      null
                    ]
                  },
                  "booking_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "customer_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "subtotal_cents": {
                    "type": "integer"
                  },
                  "discount_type": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "percent",
                      "fixed",
                      null
                    ]
                  },
                  "discount_value": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "discount_cents": {
                    "type": "integer"
                  },
                  "total_cents": {
                    "type": "integer"
                  },
                  "gift_card_applied_cents": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "tip_cents": {
                    "type": "integer",
                    "description": "A gratuity on top of the sale, folded into paid_in_person_cents but never total_cents. 0 unless the standalone walk-in flow collected one; a close-out-linked sale always has 0 here, since the booking's own tip_cents covers the visit."
                  },
                  "paid_in_person_cents": {
                    "type": "integer"
                  },
                  "payment_method": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "cash",
                      "card",
                      "bank_transfer",
                      "other",
                      null
                    ]
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "completed",
                      "voided"
                    ]
                  },
                  "void_reason": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "voided_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time"
                  },
                  "created_by": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "created_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "sold_by_provider_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "A plain attribution snapshot (migration 0185); no behaviour depends on it."
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A Postgres error other than the not-found case below, or \"Cannot restore stock for archived or unknown product {id}\" if a product line's product has since been archived.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No sale with that id in this org, or it was already voided.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/customers/search

Live client search for CustomerPicker

Session cookie

Backs the new-booking/new-reservation forms' client picker once a search term is typed (added 2026-08-30, audit fix). The picker's own initial page (fetched server-side by whichever dashboard page rendered it) is capped at 500 rows and filtered client-side for instant feedback on a small roster; past that cap a real client is otherwise unreachable, so this route exists as the live fallback once q is at least 2 characters.

Appointments: confined the same way the page-level picker list already is, via writeScope() (team-scope.ts): a scope-limited staff/manager login may only find a client they have at least one provider_confirmed appointment with. Hospitality: unconfined for every role, matching the reservations page's own unscoped list, since hospitality has no provider concept to confine by.

Redaction (staff_client_visibility) is applied for staff/manager on the appointments side, matching the page-level list exactly. Hospitality's own page-level list has never applied this redaction either, preserved as-is here rather than changed in passing.

Below 2 characters this returns an empty list rather than scanning: the picker's own local filter over its already-loaded page covers "just started typing" instantly.

Parameters

  • qquerystringoptional

    Matched against name, email and phone with ILIKE. Under 2 characters (after stripping ,/(/)) returns an empty list.

Responses

200

Up to 20 matches, name-ordered.

FieldTypeNotes
clients*object[](max items 20)
clients[].id*string (uuid)
clients[].name*string
clients[].email*string | null
clients[].phone*string | null
400

A Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Live client search for CustomerPicker",
  "description": "Backs the new-booking/new-reservation forms' client picker once a search term is typed (added 2026-08-30, audit fix). The picker's own initial page (fetched server-side by whichever dashboard page rendered it) is capped at 500 rows and filtered client-side for instant feedback on a small roster; past that cap a real client is otherwise unreachable, so this route exists as the live fallback once `q` is at least 2 characters.\n\n**Appointments**: confined the same way the page-level picker list already is, via `writeScope()` (team-scope.ts): a scope-limited staff/manager login may only find a client they have at least one `provider_confirmed` appointment with. **Hospitality**: unconfined for every role, matching the reservations page's own unscoped list, since hospitality has no provider concept to confine by.\n\nRedaction (`staff_client_visibility`) is applied for staff/manager on the appointments side, matching the page-level list exactly. Hospitality's own page-level list has never applied this redaction either, preserved as-is here rather than changed in passing.\n\nBelow 2 characters this returns an empty list rather than scanning: the picker's own local filter over its already-loaded page covers \"just started typing\" instantly.",
  "tags": [
    "Shared"
  ],
  "operationId": "searchCustomers",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string",
        "maxLength": 80
      },
      "description": "Matched against name, email and phone with ILIKE. Under 2 characters (after stripping `,`/`(`/`)`) returns an empty list."
    }
  ],
  "responses": {
    "200": {
      "description": "Up to 20 matches, name-ordered.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "clients"
            ],
            "properties": {
              "clients": {
                "type": "array",
                "maxItems": 20,
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "email",
                    "phone"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "email": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "phone": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/search

Global search, backs the dashboard's Cmd/Ctrl+K palette

Session cookie

One round trip across every entity a dashboard member might be hunting for mid-shift, instead of the palette racing several fetches itself. Reuses lib/booking/customer-search.ts's searchCustomers() for the customers half, so its scope confinement (appointments-only, staff/manager scoped to clients they share a confirmed appointment with) and staff_client_visibility redaction are identical to /api/customers/search rather than a second, driftable copy.

services and tables are vertical-exclusive: appointments gets services (from book_services) and an always-empty tables, hospitality gets tables (from book_tables) and an always-empty services. staff is populated in both, from book_providers (appointments) or book_servers (hospitality). customers and staff are always attempted regardless of vertical.

Below 2 characters this returns every key empty rather than scanning: the palette's own default state (no query yet) shows static quick links instead, so there is nothing useful to fetch this early. A failed sub-query (any of the four) degrades to an empty array for that key alone rather than failing the whole response: there is no 400 this route can emit.

Parameters

  • qquerystringoptional

    Matched against each entity's name (customers also match email/phone) with ILIKE. Under 2 characters (after stripping ,/(/)) returns every key empty.

Responses

200

Up to 5 matches per entity, name-ordered.

FieldTypeNotes
customers*object[](max items 5)
customers[].id*string (uuid)
customers[].name*string
customers[].email*string | null
customers[].phone*string | null
services*object[](max items 5)
services[].id*string (uuid)
services[].name*string
staff*object[](max items 5)
staff[].id*string (uuid)
staff[].name*string
tables*object[](max items 5)
tables[].id*string (uuid)
tables[].name*string
401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Global search, backs the dashboard's Cmd/Ctrl+K palette",
  "description": "One round trip across every entity a dashboard member might be hunting for mid-shift, instead of the palette racing several fetches itself. Reuses lib/booking/customer-search.ts's searchCustomers() for the `customers` half, so its scope confinement (appointments-only, staff/manager scoped to clients they share a confirmed appointment with) and staff_client_visibility redaction are identical to /api/customers/search rather than a second, driftable copy.\n\n`services` and `tables` are vertical-exclusive: appointments gets `services` (from `book_services`) and an always-empty `tables`, hospitality gets `tables` (from `book_tables`) and an always-empty `services`. `staff` is populated in both, from `book_providers` (appointments) or `book_servers` (hospitality). `customers` and `staff` are always attempted regardless of vertical.\n\nBelow 2 characters this returns every key empty rather than scanning: the palette's own default state (no query yet) shows static quick links instead, so there is nothing useful to fetch this early. A failed sub-query (any of the four) degrades to an empty array for that key alone rather than failing the whole response: there is no 400 this route can emit.",
  "tags": [
    "Shared"
  ],
  "operationId": "globalSearch",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "q",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string",
        "maxLength": 80
      },
      "description": "Matched against each entity's name (customers also match email/phone) with ILIKE. Under 2 characters (after stripping `,`/`(`/`)`) returns every key empty."
    }
  ],
  "responses": {
    "200": {
      "description": "Up to 5 matches per entity, name-ordered.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "customers",
              "services",
              "staff",
              "tables"
            ],
            "properties": {
              "customers": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "email",
                    "phone"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "email": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "phone": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              },
              "services": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              },
              "staff": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              },
              "tables": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/ai/chat

Send a message to the native AI assistant (v1, read-only)

Session cookie

Runs entirely as the signed-in user through requireMember()'s RLS-scoped client, never service-role, with one exception: book_usage_events has no RLS policy for authenticated at all (0064), so the atomic credit reservation opens its own serviceRoleClient() for that one read/write, never for anything the assistant itself can see or do (src/lib/usage.ts's reserveAiCredit).

requireMember() is called with no vertical argument on purpose: either engine's staff can reach this, and the tools it exposes (src/lib/ai/tools.ts) branch internally on ctx.businessType instead (getTodaysSchedule's two query shapes). No tool ever takes a company id as an argument, the same non-negotiable rule requireApiKey() follows for API keys, inherited here for a fourth trust model.

v1 is read-only: the assistant can look up this company's own bookings, clients and revenue, and search the help centre, but cannot create, change, or cancel anything; enforced by the system prompt, not by tool absence alone. Streams back an AI SDK v5+ UI-message stream (text/event-stream), not a JSON body, so the response schema below is a description rather than a checked shape (this file's own opening note: "prose, field-level schemas, and examples" are not machine-derived).

Calling OpenRouter's free-tier models (v1's only model tier) is a platform-wide shared cap on this one key, separate from the per-company credit gate below; see src/lib/ai/provider.ts. OpenRouter and the underlying free-tier model vendors are listed in SUBPROCESSORS (src/lib/marketing/legal.ts) since real customer/booking data reaches them as tool-call context.

Request body application/json

FieldTypeNotes
id*string (uuid)

The chat id: new (not yet in book_ai_chats), or resuming an existing one owned by this user.

messages*any[]

The full running UIMessage array for this chat, AI SDK v5+ shape (the client always resends the whole conversation). Schema-validated (chatBodySchema, route.ts): each message's role is constrained to user/assistant, so a client-supplied system or tool role is rejected with a 400 rather than reaching convertToModelMessages, where it would otherwise gain the same trust as the app's own system prompt. parts is capped at 50 entries per message, 200 messages per request, and any text-type part at 8000 characters.

Responses

200

A UI-message stream of the assistant's reply. One ai_credits unit is reserved ATOMICALLY (reserveAiCredit, a locked Postgres RPC) before the model is ever called, not recorded afterward: a real model call and its tool-call round trips cost the same whether or not the reply completes, so the reservation fires first and is never refunded. On completion (onEnd, only when outcome.status === 'completed' and the reply has real text: a provider error must never be persisted, confirmed live against a retired free model id) the exchange is written to book_ai_chats/book_ai_messages, with every tool-call/tool-result part stripped before storage (stripToolParts, message-text.ts) so a customer's name/email/phone surfaced by a tool never lands in a table POST /api/clients/{id}/erase has no way to reach.

400

Missing/invalid id, an empty messages array, or a body that fails chatBodySchema (e.g. a client-supplied system/tool role, or a field over its length cap).

401

No valid session cookie. {"error":"Not signed in"}.

402

This company has used every AI assistant message its tier includes this billing period (TIER_LIMITS.aiCreditsIncluded, billing/config.ts). Refused before OpenRouter is ever called, the same 'withhold, don't break' posture meteredSendSms established for a capped resource, and no usage row is written for the refused request.

403

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

429

Wired but INACTIVE until AI_RATE_LIMIT_PER_MINUTE is set (unset in production as of this writing: no real users yet, still on free OpenRouter models). Once set, more than that many requests in one minute FROM THIS USER (consumeAiRateLimit, book_ai_rate_limit_consume, migration 0199, same fixed-window-per-user shape as book_api_key_consume). Checked before the request body is even parsed. Carries retry-after.

Distinct from the 402 above: this caps REQUEST FREQUENCY, not usage against a company's plan, because OpenRouter's free-tier cap is platform-wide across every Solvintia customer sharing that one key; a single account looping past its own aiCreditsIncluded could otherwise exhaust the shared daily allowance for everyone.

Raw OpenAPI operation
{
  "summary": "Send a message to the native AI assistant (v1, read-only)",
  "description": "Runs entirely as the signed-in user through requireMember()'s RLS-scoped client, never service-role, with one exception: book_usage_events has no RLS policy for `authenticated` at all (0064), so the atomic credit reservation opens its own serviceRoleClient() for that one read/write, never for anything the assistant itself can see or do (src/lib/usage.ts's reserveAiCredit).\n\nrequireMember() is called with no `vertical` argument on purpose: either engine's staff can reach this, and the tools it exposes (src/lib/ai/tools.ts) branch internally on ctx.businessType instead (getTodaysSchedule's two query shapes). No tool ever takes a company id as an argument, the same non-negotiable rule requireApiKey() follows for API keys, inherited here for a fourth trust model.\n\nv1 is read-only: the assistant can look up this company's own bookings, clients and revenue, and search the help centre, but cannot create, change, or cancel anything; enforced by the system prompt, not by tool absence alone. Streams back an AI SDK v5+ UI-message stream (`text/event-stream`), not a JSON body, so the response schema below is a description rather than a checked shape (this file's own opening note: \"prose, field-level schemas, and examples\" are not machine-derived).\n\nCalling OpenRouter's free-tier models (v1's only model tier) is a platform-wide shared cap on this one key, separate from the per-company credit gate below; see src/lib/ai/provider.ts. OpenRouter and the underlying free-tier model vendors are listed in SUBPROCESSORS (src/lib/marketing/legal.ts) since real customer/booking data reaches them as tool-call context.",
  "tags": [
    "Shared"
  ],
  "operationId": "aiChat",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "id",
            "messages"
          ],
          "properties": {
            "id": {
              "type": "string",
              "format": "uuid",
              "description": "The chat id: new (not yet in book_ai_chats), or resuming an existing one owned by this user."
            },
            "messages": {
              "type": "array",
              "description": "The full running UIMessage array for this chat, AI SDK v5+ shape (the client always resends the whole conversation). Schema-validated (chatBodySchema, route.ts): each message's `role` is constrained to `user`/`assistant`, so a client-supplied `system` or `tool` role is rejected with a 400 rather than reaching convertToModelMessages, where it would otherwise gain the same trust as the app's own system prompt. `parts` is capped at 50 entries per message, 200 messages per request, and any `text`-type part at 8000 characters."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "A UI-message stream of the assistant's reply. One ai_credits unit is reserved ATOMICALLY (reserveAiCredit, a locked Postgres RPC) before the model is ever called, not recorded afterward: a real model call and its tool-call round trips cost the same whether or not the reply completes, so the reservation fires first and is never refunded. On completion (onEnd, only when outcome.status === 'completed' and the reply has real text: a provider error must never be persisted, confirmed live against a retired free model id) the exchange is written to book_ai_chats/book_ai_messages, with every tool-call/tool-result part stripped before storage (stripToolParts, message-text.ts) so a customer's name/email/phone surfaced by a tool never lands in a table POST /api/clients/{id}/erase has no way to reach.",
      "content": {
        "text/event-stream": {
          "schema": {
            "type": "string"
          }
        }
      }
    },
    "400": {
      "description": "Missing/invalid `id`, an empty `messages` array, or a body that fails chatBodySchema (e.g. a client-supplied `system`/`tool` role, or a field over its length cap).",
      "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": "This company has used every AI assistant message its tier includes this billing period (TIER_LIMITS.aiCreditsIncluded, billing/config.ts). Refused before OpenRouter is ever called, the same 'withhold, don't break' posture meteredSendSms established for a capped resource, and no usage row is written for the refused request.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "429": {
      "description": "Wired but INACTIVE until AI_RATE_LIMIT_PER_MINUTE is set (unset in production as of this writing: no real users yet, still on free OpenRouter models). Once set, more than that many requests in one minute FROM THIS USER (consumeAiRateLimit, book_ai_rate_limit_consume, migration 0199, same fixed-window-per-user shape as book_api_key_consume). Checked before the request body is even parsed. Carries `retry-after`.\n\nDistinct from the 402 above: this caps REQUEST FREQUENCY, not usage against a company's plan, because OpenRouter's free-tier cap is platform-wide across every Solvintia customer sharing that one key; a single account looping past its own aiCreditsIncluded could otherwise exhaust the shared daily allowance for everyone.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/ai/chat/attachments

Attach a CSV/TSV/XLSX file to an AI assistant conversation

Session cookie

Phase 1 only: CSV, TSV and XLSX. PDF and images are later phases (PDF needs a text-extraction dependency this app does not have yet; images need a vision-capable model actually wired into the free-tier rotation).

Validation IS successfully parsing the file (parseImport, src/lib/data/parse.ts), the same 'prove it's real by processing it' posture the avatar routes take for images: a file that fails to parse is rejected outright and never reaches Storage. Uploads through serviceRoleClient() end to end, same posture as every data-imports/data-exports route for an equally sensitive 'a venue's own file' surface; the private ai-attachments bucket carries no policies for authenticated at all.

Only the extracted grid (capped rows and characters, attachmentContextText) is what the model ever reads (readAttachment, src/lib/ai/tools.ts); the raw file is never inlined into a chat message. The chat row is upserted if it does not exist yet, since a file can be attached before the first message is ever sent.

No upload-specific rate limit or credit charge yet, same posture as AI_RATE_LIMIT_PER_MINUTE on POST /api/ai/chat itself: no real users yet, needs a real answer before production traffic.

Request body multipart/form-data

The file and which conversation it belongs to. multipart/form-data.

FieldTypeNotes
file*string (binary)

CSV, TSV, or XLSX. 10MB or smaller (MAX_IMPORT_BYTES, src/lib/data/parse.ts).

chatId*string (uuid)

The chat this attachment belongs to; the chat row is created if it doesn't exist yet.

Responses

200

Attached.

FieldTypeNotes
id*string (uuid)

The book_ai_attachments row id; pass this to readAttachment via the "[Attached: filename, id <uuid>]" note in the chat message.

filename*string
rowCount*integer
400

No file or chatId, an unsupported extension, over the 10MB limit, or the file failed to parse.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

500

The chat row could not be created, or the storage/database write failed.

Raw OpenAPI operation
{
  "summary": "Attach a CSV/TSV/XLSX file to an AI assistant conversation",
  "description": "Phase 1 only: CSV, TSV and XLSX. PDF and images are later phases (PDF needs a text-extraction dependency this app does not have yet; images need a vision-capable model actually wired into the free-tier rotation).\n\nValidation IS successfully parsing the file (parseImport, src/lib/data/parse.ts), the same 'prove it's real by processing it' posture the avatar routes take for images: a file that fails to parse is rejected outright and never reaches Storage. Uploads through serviceRoleClient() end to end, same posture as every data-imports/data-exports route for an equally sensitive 'a venue's own file' surface; the private `ai-attachments` bucket carries no policies for `authenticated` at all.\n\nOnly the extracted grid (capped rows and characters, attachmentContextText) is what the model ever reads (readAttachment, src/lib/ai/tools.ts); the raw file is never inlined into a chat message. The chat row is upserted if it does not exist yet, since a file can be attached before the first message is ever sent.\n\nNo upload-specific rate limit or credit charge yet, same posture as `AI_RATE_LIMIT_PER_MINUTE` on POST /api/ai/chat itself: no real users yet, needs a real answer before production traffic.",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadAiAttachment",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "description": "The file and which conversation it belongs to. multipart/form-data.",
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file",
            "chatId"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "CSV, TSV, or XLSX. 10MB or smaller (MAX_IMPORT_BYTES, src/lib/data/parse.ts)."
            },
            "chatId": {
              "type": "string",
              "format": "uuid",
              "description": "The chat this attachment belongs to; the chat row is created if it doesn't exist yet."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Attached.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "filename",
              "rowCount"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "description": "The book_ai_attachments row id; pass this to readAttachment via the \"[Attached: filename, id <uuid>]\" note in the chat message."
              },
              "filename": {
                "type": "string"
              },
              "rowCount": {
                "type": "integer"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file or chatId, an unsupported extension, over the 10MB limit, or the file failed to parse.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The chat row could not be created, or the storage/database write failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/ai/chat/attachments/{id}

Get a short-lived download link for an attached file

Session cookie

Re-derives book_ai_attachments' own tenancy boundary by hand (company_id AND user_id), since this route holds a service-role client and RLS never applies to it. Returns 404, not the raw storage error, whether the attachment never existed for this caller or the 90-day sweep (GET /api/cron/ai-attachments-sweep) already removed the raw file: the caller cannot tell those apart and does not need to. The signed URL is a 15-minute bearer credential, same TTL reasoning as the data-exports download link.

Parameters

  • id*pathstring

    The attachment id.

Responses

200

A signed download link.

FieldType
url*string (uri)
filename*string
401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No attachment with that id for this company and user, or its raw file has already aged out.

500

Could not create a signed URL.

Raw OpenAPI operation
{
  "summary": "Get a short-lived download link for an attached file",
  "description": "Re-derives book_ai_attachments' own tenancy boundary by hand (company_id AND user_id), since this route holds a service-role client and RLS never applies to it. Returns 404, not the raw storage error, whether the attachment never existed for this caller or the 90-day sweep (GET /api/cron/ai-attachments-sweep) already removed the raw file: the caller cannot tell those apart and does not need to. The signed URL is a 15-minute bearer credential, same TTL reasoning as the data-exports download link.",
  "tags": [
    "Shared"
  ],
  "operationId": "downloadAiAttachment",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The attachment id."
    }
  ],
  "responses": {
    "200": {
      "description": "A signed download link.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "url",
              "filename"
            ],
            "properties": {
              "url": {
                "type": "string",
                "format": "uri"
              },
              "filename": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No attachment with that id for this company and user, or its raw file has already aged out.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "Could not create a signed URL.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/members

Invite a teammate (admin)

Session cookieadmin only

Three privileged calls in a fixed order: send the invite, write the company_id claim, insert the roster row. The claim is set BEFORE the invitee ever signs in, so their very first JWT already carries it. Any failure after the invite deletes the auth user so the invite can simply be retried.

An email that already has an account anywhere in the platform is refused; moving accounts between organizations is not supported.

Request body application/json

FieldTypeNotes
email*string (email)(max length 254)
role*"admin" | "staff"

Role gates specific actions in app code. It is NOT in RLS; see the security notes.

Responses

200

Invite sent.

FieldType
ok*true
400

Invalid email, or a role other than admin/staff.

401

No valid session cookie. {"error":"Not signed in"}.

402

The plan's seat cap is full and another seat cannot be billed (reserveSeat, src/lib/billing/seats.ts). Solo and Free stop dead at one seat and sell no overage; Basic and Pro do sell it, but still refuse when Stripe is unconfigured or the subscription carries no tier item to raise the quantity on. A pending invite counts against the cap, so the cap can be reached with nobody having signed in yet. Re-sending an invite that is already pending never hits this: it occupies the seat it already claimed.

403

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

409

That email already has an account.

500

The invite went out but the claim or roster write failed; the auth user was deleted again so the invite can be retried.

Raw OpenAPI operation
{
  "summary": "Invite a teammate (admin)",
  "description": "Three privileged calls in a fixed order: send the invite, write the `company_id` claim, insert the roster row. The claim is set BEFORE the invitee ever signs in, so their very first JWT already carries it. Any failure after the invite deletes the auth user so the invite can simply be retried.\n\nAn email that already has an account anywhere in the platform is refused; moving accounts between organizations is not supported.",
  "tags": [
    "Shared"
  ],
  "operationId": "inviteMember",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "email",
            "role"
          ],
          "properties": {
            "email": {
              "type": "string",
              "format": "email",
              "maxLength": 254
            },
            "role": {
              "type": "string",
              "enum": [
                "admin",
                "staff"
              ],
              "description": "Role gates specific actions in app code. It is NOT in RLS; see the security notes."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Invite sent.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Invalid email, or a role other than admin/staff.",
      "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). Solo and Free stop dead at one seat and sell no overage; Basic and Pro do sell it, but still refuse when Stripe is unconfigured or the subscription carries no tier item to raise the quantity on. A pending invite counts against the cap, so the cap can be reached with nobody having signed in yet. Re-sending an invite that is already pending never hits this: it occupies the seat it already claimed.",
      "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"
          }
        }
      }
    },
    "409": {
      "description": "That email already has an account.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The invite went out but the claim or roster write failed; the auth user was deleted again so the invite can be retried.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/notification-prefs

Set the caller's own staff-alert preferences

Session cookie

A login's own choices for which staff-facing booking alerts (a booking is made, cancelled or moved) reach them by email or text (migration 0121's Notifications tab). SELF ONLY, unlike PATCH /api/members/{id} (admin-gated, changes what someone ELSE may do): this changes what reaches the CALLER's own inbox and phone, so it takes no id at all, just requireMember() with no role, scoped to the caller's own book_org_members row by their session user id, never a client-supplied one.

A missing key inside notification_prefs for an event/channel pair means the default (email on, sms off), not "never notify": see src/lib/notifications/staff-prefs.ts. Whole-object replacement, not a merge; send the full set every time.

Request body application/json

FieldTypeNotes
notification_prefs*object

Keyed by event (booking_created, booking_cancelled, booking_changed); each value is an object of {email?, sms?: boolean}. An unknown event or channel key is a 400 rather than being stored and silently ignored.

Responses

200

Saved.

FieldType
ok*true
400

The body carried no notification_prefs object, an unknown event or channel name, or a non-boolean value.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

501

Migration 0121 has not been applied to this database yet (notification_prefs column does not exist).

Raw OpenAPI operation
{
  "summary": "Set the caller's own staff-alert preferences",
  "description": "A login's own choices for which staff-facing booking alerts (a booking is made, cancelled or moved) reach them by email or text (migration 0121's Notifications tab). SELF ONLY, unlike PATCH /api/members/{id} (admin-gated, changes what someone ELSE may do): this changes what reaches the CALLER's own inbox and phone, so it takes no id at all, just requireMember() with no role, scoped to the caller's own book_org_members row by their session user id, never a client-supplied one.\n\nA missing key inside `notification_prefs` for an event/channel pair means the default (email on, sms off), not \"never notify\": see src/lib/notifications/staff-prefs.ts. Whole-object replacement, not a merge; send the full set every time.",
  "tags": [
    "Shared"
  ],
  "operationId": "setNotificationPrefs",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "notification_prefs"
          ],
          "properties": {
            "notification_prefs": {
              "type": "object",
              "description": "Keyed by event (booking_created, booking_cancelled, booking_changed); each value is an object of {email?, sms?: boolean}. An unknown event or channel key is a 400 rather than being stored and silently ignored.",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "boolean"
                  },
                  "sms": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "The body carried no `notification_prefs` object, an unknown event or channel name, or a non-boolean value.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "501": {
      "description": "Migration 0121 has not been applied to this database yet (`notification_prefs` column does not exist).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/recipe-prefs

Toggle a Recipe on/off, or edit its message (admin)

Session cookieadmin only

Org-wide marketing configuration for one Recipe (docs/competitive-gap-analysis.md:1260-1424): ADMIN-gated, unlike PATCH /api/notification-prefs' self-only shape, since this changes what every customer of the business receives, not one login's own alert preferences. Writes book_companies.recipe_prefs past its SELECT-only grant (migration 0156) via service_role.

state accepts only off/automatic today. ask_first is a valid value already at rest in storage (recipe_prefs is free text) but has no landing surface yet (no "Today" page), so this route 400s it rather than silently accepting a setting nothing acts on. subject/body are optional and independent of state: sending either (or both) sets a per-org override for that recipe's message, read by recipeTemplate() (src/lib/marketing/recipes/definitions.ts) ahead of the shipped default; an explicit null on either resets it back to the default.

Request body application/json

FieldTypeNotes
recipe*string

A RecipeId (e.g. welcome_first_visit, birthday).

state"off" | "automatic"
subjectstring

Null resets to the shipped default subject.

bodystring

Null resets to the shipped default message.

Responses

200

Saved.

FieldType
ok*true
400

recipe was missing or not a known RecipeId, state was sent but not off/automatic, or subject/body exceeded its length limit, or nothing in the body needed updating.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

The company row was not found.

Raw OpenAPI operation
{
  "summary": "Toggle a Recipe on/off, or edit its message (admin)",
  "description": "Org-wide marketing configuration for one Recipe (docs/competitive-gap-analysis.md:1260-1424): ADMIN-gated, unlike PATCH /api/notification-prefs' self-only shape, since this changes what every customer of the business receives, not one login's own alert preferences. Writes book_companies.recipe_prefs past its SELECT-only grant (migration 0156) via service_role.\n\n`state` accepts only `off`/`automatic` today. `ask_first` is a valid value already at rest in storage (recipe_prefs is free text) but has no landing surface yet (no \"Today\" page), so this route 400s it rather than silently accepting a setting nothing acts on. `subject`/`body` are optional and independent of `state`: sending either (or both) sets a per-org override for that recipe's message, read by recipeTemplate() (src/lib/marketing/recipes/definitions.ts) ahead of the shipped default; an explicit `null` on either resets it back to the default.",
  "tags": [
    "Shared"
  ],
  "operationId": "setRecipePrefs",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "recipe"
          ],
          "properties": {
            "recipe": {
              "type": "string",
              "description": "A RecipeId (e.g. welcome_first_visit, birthday)."
            },
            "state": {
              "type": "string",
              "enum": [
                "off",
                "automatic"
              ]
            },
            "subject": {
              "type": "string",
              "nullable": true,
              "description": "Null resets to the shipped default subject."
            },
            "body": {
              "type": "string",
              "nullable": true,
              "description": "Null resets to the shipped default message."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`recipe` was missing or not a known RecipeId, `state` was sent but not `off`/`automatic`, or `subject`/`body` exceeded its length limit, or nothing in the body needed updating.",
      "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": "The company row was not found.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/members/{id}

Set a teammate's granular permissions (admin)

Session cookieadmin only

Grants a staff login one or more of the manage_services, manage_staff and manage_org_settings permissions (migration 0082). Admin-gated with no manage_org_settings-style escape hatch, deliberately: this is the route that hands out permissions, so letting it be reached by anything short of a full admin would let a grant-holder mint grants, the same reasoning that keeps depositStaffEditable and unlinkedStaffFullAccess out of PATCH /api/companies' staff-writable set.

An admin target is refused outright: an admin already has every permission unconditionally (hasPermission short-circuits on role), so storing any here would be a value nothing reads, and a UI showing ticked boxes on an admin would wrongly imply they could be unticked to take access away.

Parameters

  • id*pathstring

    The book_org_members row id; NOT the user id.

Request body application/json

FieldTypeNotes
permissions*object

Keyed by permission name (manage_services, manage_staff, manage_org_settings); every value must be a real boolean. An unknown key is a 400 rather than being stored and silently ignored.

Responses

200

Saved.

FieldType
ok*true
400

The body carried no permissions object, an unknown permission name, a non-boolean value, or the target is an admin ("Admins already have every permission. Change their role to staff first").

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No such member in this org, or a malformed uuid.

Raw OpenAPI operation
{
  "summary": "Set a teammate's granular permissions (admin)",
  "description": "Grants a staff login one or more of the manage_services, manage_staff and manage_org_settings permissions (migration 0082). Admin-gated with no manage_org_settings-style escape hatch, deliberately: this is the route that hands out permissions, so letting it be reached by anything short of a full admin would let a grant-holder mint grants, the same reasoning that keeps depositStaffEditable and unlinkedStaffFullAccess out of PATCH /api/companies' staff-writable set.\n\nAn admin target is refused outright: an admin already has every permission unconditionally (hasPermission short-circuits on role), so storing any here would be a value nothing reads, and a UI showing ticked boxes on an admin would wrongly imply they could be unticked to take access away.",
  "tags": [
    "Shared"
  ],
  "operationId": "updateMemberPermissions",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The `book_org_members` row id; NOT the user id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "permissions"
          ],
          "properties": {
            "permissions": {
              "type": "object",
              "description": "Keyed by permission name (manage_services, manage_staff, manage_org_settings); every value must be a real boolean. An unknown key is a 400 rather than being stored and silently ignored.",
              "additionalProperties": {
                "type": "boolean"
              }
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "The body carried no `permissions` object, an unknown permission name, a non-boolean value, or the target is an admin (\"Admins already have every permission. Change their role to staff first\").",
      "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 such member in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/members/{id}

Remove a teammate (admin)

Session cookieadmin only

Order matters and is deliberate: the company_id claim is nulled FIRST, then the roster row is deleted. The reverse could leave a claim-holding non-member with live RLS access to everything. If the row delete fails the user is locked out but still listed; retrying fixes it, and the dangerous state cannot occur.

You cannot remove yourself, and you cannot remove the last admin.

Parameters

  • id*pathstring

    The book_org_members row id; NOT the user id.

Responses

200

Removed.

FieldType
ok*true
400

"You can't remove yourself", or "Cannot remove the last admin".

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No such member in this org, or a malformed uuid.

500

The claim could not be nulled (nothing changed), or it was nulled but the roster row survived (retry).

Raw OpenAPI operation
{
  "summary": "Remove a teammate (admin)",
  "description": "Order matters and is deliberate: the `company_id` claim is nulled FIRST, then the roster row is deleted. The reverse could leave a claim-holding non-member with live RLS access to everything. If the row delete fails the user is locked out but still listed; retrying fixes it, and the dangerous state cannot occur.\n\nYou cannot remove yourself, and you cannot remove the last admin.",
  "tags": [
    "Shared"
  ],
  "operationId": "removeMember",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The `book_org_members` row id; NOT the user id."
    }
  ],
  "responses": {
    "200": {
      "description": "Removed.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "\"You can't remove yourself\", or \"Cannot remove the last admin\".",
      "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 such member in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The claim could not be nulled (nothing changed), or it was nulled but the roster row survived (retry).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/{id}/resend

Resend a pending invite (admin)

Session cookieadmin only

Admin-triggered resend from the teammate roster; distinct from POST /api/invites/resend, which is public and token-keyed for someone acting on their own expired invite link. This one is id-keyed and admin-gated, for "they still haven't accepted, send it again."

RLS's tenant-scoped select already proves the id belongs to the caller's own company, so an id from a different company is simply not found, the same reasoning DELETE /api/members/{id} relies on. An invite that has already been accepted or already revoked is likewise not found: this route only ever acts on a still-pending one.

Issues a fresh token and a fresh expiry (the old link stops working the moment this succeeds) and re-sends the invite email.

Parameters

  • id*pathstring

    The book_invites row id.

Responses

200

A new link was emailed.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No pending invite with that id in this org; wrong company, already accepted, already revoked, or a malformed uuid.

500

APP_URL is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.

Raw OpenAPI operation
{
  "summary": "Resend a pending invite (admin)",
  "description": "Admin-triggered resend from the teammate roster; distinct from `POST /api/invites/resend`, which is public and token-keyed for someone acting on their own expired invite link. This one is id-keyed and admin-gated, for \"they still haven't accepted, send it again.\"\n\nRLS's tenant-scoped select already proves the id belongs to the caller's own company, so an id from a different company is simply not found, the same reasoning `DELETE /api/members/{id}` relies on. An invite that has already been accepted or already revoked is likewise not found: this route only ever acts on a still-pending one.\n\nIssues a fresh token and a fresh expiry (the old link stops working the moment this succeeds) and re-sends the invite email.",
  "tags": [
    "Shared"
  ],
  "operationId": "resendInviteById",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The `book_invites` row id."
    }
  ],
  "responses": {
    "200": {
      "description": "A new link was emailed.",
      "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": "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 pending invite with that id in this org; wrong company, already accepted, already revoked, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "`APP_URL` is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/{id}/revoke

Cancel a pending invite (admin)

Session cookieadmin only

Sets revoked_at rather than deleting the row: a revoked invite's token must stop working (checked by both accept and resend), but the row staying around is the only record that an invite existed and was cancelled.

Same lookup as resend; only a still-pending invite in the caller's own company can be revoked; RLS proves the tenancy, and an already-accepted or already-revoked row reads as not found.

Parameters

  • id*pathstring

    The book_invites row id.

Responses

200

Revoked.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No pending invite with that id in this org; wrong company, already accepted, already revoked, or a malformed uuid.

500

The revoked_at write failed.

Raw OpenAPI operation
{
  "summary": "Cancel a pending invite (admin)",
  "description": "Sets `revoked_at` rather than deleting the row: a revoked invite's token must stop working (checked by both accept and resend), but the row staying around is the only record that an invite existed and was cancelled.\n\nSame lookup as resend; only a still-pending invite in the caller's own company can be revoked; RLS proves the tenancy, and an already-accepted or already-revoked row reads as not found.",
  "tags": [
    "Shared"
  ],
  "operationId": "revokeInvite",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "The `book_invites` row id."
    }
  ],
  "responses": {
    "200": {
      "description": "Revoked.",
      "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": "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 pending invite with that id in this org; wrong company, already accepted, already revoked, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The `revoked_at` write failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/accept

Accept an invite

Session cookie

Called by the embedded auth step on /invite/{token} right after a session exists (fresh signup or sign-in), never before. requireMember() is deliberately NOT the gate here; it requires an existing company_id claim, which is exactly the thing this route is about to create.

The token proves someone had access to the original email; matching it against the signed-in user's own address proves the person accepting right now is that same person; without it, a forwarded or leaked token could be redeemed by whoever is signed in when they click it. An account already carrying a company_id claim from a different organization is refused: moving an existing account between organizations is not supported yet, checked here against the account actually accepting rather than only at invite-send time against an email that may not have had one.

On success, three writes happen in order: the company_id claim, then the book_org_members row with the invite's role, then the invite is marked accepted.

Request body application/json

FieldTypeNotes
token*string

The token from the /invite/{token} link.

Responses

200

Accepted; the account is now a member of the invite's organization.

FieldType
ok*true
400

Missing invite token.

401

No valid session cookie. {"error":"Not signed in"}.

403

Signed in, but with an email that does not match the invite's. Names the expected address and asks them to sign in with it or request a new invite.

404

The token does not resolve to any invite.

409

The signed-in account already carries a company_id claim from a different organization. Moving accounts between organizations is not supported yet.

410

The invite was cancelled, has already been used, or has passed its expires_at.

500

The company_id claim write failed, or the book_org_members insert failed.

Raw OpenAPI operation
{
  "summary": "Accept an invite",
  "description": "Called by the embedded auth step on `/invite/{token}` right after a session exists (fresh signup or sign-in), never before. `requireMember()` is deliberately NOT the gate here; it requires an existing `company_id` claim, which is exactly the thing this route is about to create.\n\nThe token proves someone had access to the original email; matching it against the signed-in user's own address proves the person accepting right now is that same person; without it, a forwarded or leaked token could be redeemed by whoever is signed in when they click it. An account already carrying a `company_id` claim from a different organization is refused: moving an existing account between organizations is not supported yet, checked here against the account actually accepting rather than only at invite-send time against an email that may not have had one.\n\nOn success, three writes happen in order: the `company_id` claim, then the `book_org_members` row with the invite's role, then the invite is marked accepted.",
  "tags": [
    "Shared"
  ],
  "operationId": "acceptInvite",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "token"
          ],
          "properties": {
            "token": {
              "type": "string",
              "description": "The token from the `/invite/{token}` link."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Accepted; the account is now a member of the invite's organization.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Missing invite token.",
      "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 with an email that does not match the invite's. Names the expected address and asks them to sign in with it or request a new invite.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "The token does not resolve to any invite.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "The signed-in account already carries a `company_id` claim from a different organization. Moving accounts between organizations is not supported yet.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "410": {
      "description": "The invite was cancelled, has already been used, or has passed its `expires_at`.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The `company_id` claim write failed, or the `book_org_members` insert failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/resend

Request a new invite link (public)

Public · no auth

The self-service "send me a new link" button on an expired /invite/{token} page, no session required. Deliberately keyed on the TOKEN rather than an email address: unlike an email-lookup endpoint, which would let anyone probe arbitrary addresses for "does this person have a pending invite, and where", possessing a specific 256-bit token already proves access to the original message, so nothing new is disclosed by honouring it. That is also why this can return specific errors instead of a deliberately generic response; "invite not found" leaks nothing an attacker didn't already need to have.

Issues a fresh token and expiry and re-sends the invite email to the address on file, which is echoed back so the page can say where it went.

Request body application/json

FieldTypeNotes
token*string

The (expired, or otherwise unusable) token from the original /invite/{token} link.

Responses

200

A new link was emailed.

FieldType
ok*true
email*string (email)
400

Missing invite token.

404

This invite link is not valid, or has already been used; an unrecognised token, or one already accepted or revoked.

500

APP_URL is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.

Raw OpenAPI operation
{
  "summary": "Request a new invite link (public)",
  "description": "The self-service \"send me a new link\" button on an expired `/invite/{token}` page, no session required. Deliberately keyed on the TOKEN rather than an email address: unlike an email-lookup endpoint, which would let anyone probe arbitrary addresses for \"does this person have a pending invite, and where\", possessing a specific 256-bit token already proves access to the original message, so nothing new is disclosed by honouring it. That is also why this can return specific errors instead of a deliberately generic response; \"invite not found\" leaks nothing an attacker didn't already need to have.\n\nIssues a fresh token and expiry and re-sends the invite email to the address on file, which is echoed back so the page can say where it went.",
  "tags": [
    "Shared"
  ],
  "operationId": "resendInviteByToken",
  "security": [],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "token"
          ],
          "properties": {
            "token": {
              "type": "string",
              "description": "The (expired, or otherwise unusable) token from the original `/invite/{token}` link."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "A new link was emailed.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "email"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "email": {
                "type": "string",
                "format": "email"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Missing invite token.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "This invite link is not valid, or has already been used; an unrecognised token, or one already accepted or revoked.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "`APP_URL` is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/resend-mine

Request a new invite link for the signed-in account (session-gated)

Session cookie

The onboarding page's "you already have an invitation" dialog (PendingInviteDialog), for someone who signed up fresh instead of using their invite email. Session-gated rather than token-keyed, unlike POST /api/invites/resend: this caller never held a token to begin with (migration 0142 stopped book_invites from storing one anyone but the emailed recipient could ever reconstruct), only a live session proving their own email address. Looks up the pending invite by the CALLER'S OWN verified email, then does exactly what the token-keyed resend does: mint a fresh token, rotate it in, email it. Not gated by requireMember(): the caller has no company_id claim yet, which is exactly what accepting the invite is about to create.

Responses

200

A new link was emailed.

FieldType
ok*true
email*string (email)
401

No valid session cookie. {"error":"Not signed in"}.

404

No pending, unexpired, unrevoked invite found for the signed-in account's own email.

500

APP_URL is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.

Raw OpenAPI operation
{
  "summary": "Request a new invite link for the signed-in account (session-gated)",
  "description": "The onboarding page's \"you already have an invitation\" dialog (PendingInviteDialog), for someone who signed up fresh instead of using their invite email. Session-gated rather than token-keyed, unlike POST /api/invites/resend: this caller never held a token to begin with (migration 0142 stopped book_invites from storing one anyone but the emailed recipient could ever reconstruct), only a live session proving their own email address. Looks up the pending invite by the CALLER'S OWN verified email, then does exactly what the token-keyed resend does: mint a fresh token, rotate it in, email it. Not gated by requireMember(): the caller has no `company_id` claim yet, which is exactly what accepting the invite is about to create.",
  "tags": [
    "Shared"
  ],
  "operationId": "resendInviteMine",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "A new link was emailed.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "email"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "email": {
                "type": "string",
                "format": "email"
              }
            }
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No pending, unexpired, unrevoked invite found for the signed-in account's own email.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "`APP_URL` is not configured, the organization row could not be read, the new token/expiry could not be saved, or the invite email failed to send.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/invites/register

Create an invited account (public)

Public · no auth

The "create account and join" half of the /invite/{token} form, no session required, because the visitor is about to establish their first one. Creates the account with its email already confirmed, then the browser signs in with the same password and goes on to POST /api/invites/accept.

Skipping the confirmation email is the reason this route exists rather than a shortcut taken around one: holding the token means having opened the message sent to the invite's address, so it is already proven, on the same bearer-capability reasoning that lets /invite/{token} be viewed with no auth and lets resend be honoured on a token alone. A second confirmation round-trip proves nothing the token did not.

The address is read off the invite row and never off the body; a token is authority over exactly one address, and honouring a caller-supplied one would turn a leaked invite into a create-an-account-anywhere primitive.

Replaced a browser-side supabase.auth.signUp(), which could not work here: with confirmations enabled GoTrue answers an already-registered address with a session-less 200 and no error at all (enumeration protection) and sends no mail, so the form's only remaining branch was to strand a returning invitee on "check your email" for a message that was never coming. The admin API does not obfuscate, which is what makes the 409 below possible.

Request body application/json

FieldTypeNotes
token*string

The token from the /invite/{token} link. Also names the email the account is created on.

password*string

Length and strength are GoTrue's to judge; a rejection comes back as its own 400 message.

Responses

200

The account exists and its email is confirmed. Sign in with it, then accept the invite.

FieldType
ok*true
400

Missing token or password, or a password GoTrue refused (its wording is passed through).

404

The token does not resolve to any invite.

409

An account already exists on this address. Deliberately NOT a password reset; otherwise a leaked token would be an account takeover, so the form flips to sign-in and asks for the existing password.

410

The invite was cancelled, has already been used, or has passed its expires_at.

Raw OpenAPI operation
{
  "summary": "Create an invited account (public)",
  "description": "The \"create account and join\" half of the `/invite/{token}` form, no session required, because the visitor is about to establish their first one. Creates the account with its email already confirmed, then the browser signs in with the same password and goes on to POST `/api/invites/accept`.\n\nSkipping the confirmation email is the reason this route exists rather than a shortcut taken around one: holding the token means having opened the message sent to the invite's address, so it is already proven, on the same bearer-capability reasoning that lets `/invite/{token}` be viewed with no auth and lets resend be honoured on a token alone. A second confirmation round-trip proves nothing the token did not.\n\nThe address is read off the invite row and never off the body; a token is authority over exactly one address, and honouring a caller-supplied one would turn a leaked invite into a create-an-account-anywhere primitive.\n\nReplaced a browser-side `supabase.auth.signUp()`, which could not work here: with confirmations enabled GoTrue answers an already-registered address with a session-less 200 and no error at all (enumeration protection) and sends no mail, so the form's only remaining branch was to strand a returning invitee on \"check your email\" for a message that was never coming. The admin API does not obfuscate, which is what makes the 409 below possible.",
  "tags": [
    "Shared"
  ],
  "operationId": "registerInvitedAccount",
  "security": [],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "token",
            "password"
          ],
          "properties": {
            "token": {
              "type": "string",
              "description": "The token from the `/invite/{token}` link. Also names the email the account is created on."
            },
            "password": {
              "type": "string",
              "description": "Length and strength are GoTrue's to judge; a rejection comes back as its own 400 message."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The account exists and its email is confirmed. Sign in with it, then accept the invite.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Missing token or password, or a password GoTrue refused (its wording is passed through).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "The token does not resolve to any invite.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "An account already exists on this address. Deliberately NOT a password reset; otherwise a leaked token would be an account takeover, so the form flips to sign-in and asks for the existing password.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "410": {
      "description": "The invite was cancelled, has already been used, or has passed its `expires_at`.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/beta/request-access

Request beta access (public)

Public · no auth

The public entry point for the beta-access gate (book_beta_access_requests/book_beta_codes, migration 0188): POST /api/onboarding/complete refuses to create a brand new tenant without a valid, unredeemed code, and this is how a prospect asks for one. Rate-limited (beta-request: bucket, 5/min-window/IP via the same consumeBucket() the guest booking surface uses) and bot-checked (guestBotCheck(), registered in bot-paths.ts). Always answers {ok:true} regardless of outcome, whether fresh, a resubmission of an already-pending request (23505 on the partial-unique index, swallowed), or auto-approved, so the response can never be used to enumerate which emails have already asked or which auto-approve rules exist.

Request body application/json

FieldTypeNotes
email*string (email)(max length 254)
name*string(min length 1, max length 120)
businessNamestring | null(max length 120)
vertical*"appointments" | "hospitality"
industry*string(max length 80)

One of INDUSTRIES_BY_TYPE[vertical] (src/lib/booking/industries.ts). Rejected with a 400 when blank, or when it belongs to the OTHER vertical's list: the same industryConflicts() guard PATCH /api/companies and POST /api/onboarding/complete already apply.

sourcestring | null(max length 200)

Free text: a referral, a UTM value, "how did you hear about us". Never parsed structurally; matched only against a fixed server-side allowlist for auto-approval.

Responses

200

Received. Deliberately identical whether the request was fresh, a duplicate pending one, or auto-approved.

FieldType
ok*true
400

A missing or malformed field.

429

Too many requests from this network this minute.

500

A Postgres error inserting the request row (anything other than the pending-duplicate 23505, which is swallowed and answers 200).

Raw OpenAPI operation
{
  "summary": "Request beta access (public)",
  "description": "The public entry point for the beta-access gate (book_beta_access_requests/book_beta_codes, migration 0188): `POST /api/onboarding/complete` refuses to create a brand new tenant without a valid, unredeemed code, and this is how a prospect asks for one. Rate-limited (`beta-request:` bucket, 5/min-window/IP via the same `consumeBucket()` the guest booking surface uses) and bot-checked (`guestBotCheck()`, registered in `bot-paths.ts`). Always answers `{ok:true}` regardless of outcome, whether fresh, a resubmission of an already-pending request (23505 on the partial-unique index, swallowed), or auto-approved, so the response can never be used to enumerate which emails have already asked or which auto-approve rules exist.",
  "tags": [
    "Shared"
  ],
  "operationId": "requestBetaAccess",
  "security": [],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "email",
            "name",
            "vertical",
            "industry"
          ],
          "properties": {
            "email": {
              "type": "string",
              "format": "email",
              "maxLength": 254
            },
            "name": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120
            },
            "businessName": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 120
            },
            "vertical": {
              "type": "string",
              "enum": [
                "appointments",
                "hospitality"
              ]
            },
            "industry": {
              "type": "string",
              "maxLength": 80,
              "description": "One of INDUSTRIES_BY_TYPE[vertical] (src/lib/booking/industries.ts). Rejected with a 400 when blank, or when it belongs to the OTHER vertical's list: the same industryConflicts() guard PATCH /api/companies and POST /api/onboarding/complete already apply."
            },
            "source": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 200,
              "description": "Free text: a referral, a UTM value, \"how did you hear about us\". Never parsed structurally; matched only against a fixed server-side allowlist for auto-approval."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Received. Deliberately identical whether the request was fresh, a duplicate pending one, or auto-approved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A missing or malformed field.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "429": {
      "description": "Too many requests from this network this minute.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "A Postgres error inserting the request row (anything other than the pending-duplicate 23505, which is swallowed and answers 200).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/companies

Update organization settings (admin, or manage_org_settings, or deposit_staff_editable)

Session cookie

A true partial update: a key that is not in the body is not in the UPDATE.

This paragraph used to say the opposite, and the opposite used to be true; every field was derived with a fallback, so omitting tagline nulled it, omitting notifyEmailEnabled forced it true, and omitting bookingTheme reset it to light. That was fixed in the route (a has() gate, the same discipline the reservations, appointments and clients PATCH routes use) and the description was not moved with it. scripts/check-hardening.ts asserts the current behaviour column by column.

Three ways in (migration 0082), checked in order of breadth. An admin may write anything below. A staff login granted manage_org_settings may write anything EXCEPT five admin-only keys that either change the org's public identity (slug, status) or grant a permission (depositStaffEditable, unlinkedStaffFullAccess, staffClientVisibility); a permission that can grant permissions is not delegable, it is the thing doing the delegating. Absent that, a staff login may still write the six deposit-policy fields alone, but only once this org's admin has turned on depositStaffEditable (migration 0075).

slug, status and the three admin-only permission flags are written with service-role, because migration 0022 (and 0075 for the deposit flag) leaves those columns out of the tenant column grant; a staff JWT provably could flip them through raw PostgREST before that. Everything else goes through the RLS-scoped client.

business_type is NOT settable. Flipping a live org's vertical strands whatever it already has; a conversion is a data migration, not a settings toggle.

Request body application/json

FieldTypeNotes
name*string(min length 1, max length 120)
slug*string(pattern ^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$)

Changing this moves the public booking page and busts both the old and new cache tags.

status*"active" | "inactive"

inactive takes the public booking page offline immediately.

industrystring | null(max length 80)

Validated against the org's existing business_type, which cannot be changed here, so settings cannot drift the pair apart after the fact.

accentColorstring | null

Must be the base of one of BRAND_COLORS. Lowercased.

bookingTheme"light" | "dark" | "system"(default "light")

The theme the org's own booking page wears. The visitor does not choose it, unless bookingThemeToggleEnabled is also on.

bookingThemeToggleEnabledboolean(default false)

When true, adds a visitor-facing switch to the booking page that can move a visitor away from bookingTheme for their own viewing. Off by default; bookingTheme alone is authoritative until an org opts in.

siteTheme"light" | "dark" | "system" | null

Migration 0194. The theme the org's WEBSITE wears, independent of bookingTheme above. Null means no site-specific choice, which falls back to bookingTheme live.

siteThemeToggleEnabledboolean(default false)

Migration 0194. Sibling of bookingThemeToggleEnabled, for the website's own visitor-facing switch.

tierBadgeDisplay"auto" | "blue" | "off"(default "auto")

Migration 0204. The org's own say over the plan-tier mark next to its name on the booking widget. 'auto' shows the tier's natural colour (blue for basic, gold for pro/enterprise), 'blue' forces the calmer colour even on pro/enterprise, 'off' hides it regardless of tier. There is no value that lets an org show a colour above its actual tier.

notifyEmailEnabledboolean(default true)

Defaults to true; only the literal false turns it off.

notifySmsEnabledboolean(default false)

Defaults to false; only the literal true turns it on.

taglinestring | null(max length 160)
phonestring | null(max length 40)

The venue's own public number; what the booking page shows as the way to reach it, and what a party too big to book online is told to ring. Required at onboarding, and here it may be CORRECTED but not REMOVED: omitting the key leaves it alone, and an explicit null or blank is a 400 when a number is on file, a no-op when one is not. Without that, the onboarding requirement would be undone from Settings a minute later.

locationPickerEnabledboolean(default false)

Hospitality only (migration 0030). Lets a guest choose a level/area before seeing times. Defaults to false.

tablePickerEnabledboolean(default false)

Hospitality only (migration 0030). Lets a guest choose a specific table. Defaults to false.

maxOnlinePartySizeinteger | null(min 1, max 30)

Hospitality only (migration 0036). The largest party the venue confirms online without a human looking. Above it, the booking page stops offering times and invites a big-group enquiry, and BOTH public reservation routes refuse with enquiryRequired: true, so a direct POST cannot walk past it. null means no ceiling and is the default; sending null explicitly CLEARS an existing one (available on every tier), while a non-null value REQUIRES a paid tier, Basic Table or above (src/lib/plan.ts, party_size_rules; moved down from Venue Pro on 2026-08-14); setting a ceiling is also what turns on the big-group-enquiry pathway, since it never fires with no ceiling to exceed. Omitting the field leaves it untouched. Deliberately not derived from the largest table: capacity is what a venue can seat, this is what it will confirm unattended, and those are different numbers.

staffSeatLimitEnforcedboolean(default false)

Hospitality only (migration 0036). When false; the default, and the behaviour these routes have always had; staff may book more guests onto a table than it seats, because a host putting five on a four-top is a real thing venues do. When true, POST /api/reservations and PATCH /api/reservations/{id} refuse it with a message naming the table and both numbers. Never affects double-booking, which no setting can override.

availabilityApprovalHorizonDaysinteger(min 1, max 60, default 7)

Appointments only (migration 0052). How many days out a Standard-tier staff member's own date-specific schedule request applies immediately, with just a notification, rather than needing admin approval first. NOT NULL with a bounded default, so no null branch; a value outside 1-60 is a 400.

serviceLayout"list" | "pills"(default "pills")

Appointments only (migration 0054). How the public widget presents services once the org has service groups: pills is a scrollable group bar over grouped sections, list keeps the flat list (groups stay an admin-side organizing tool). With no groups the widget always renders the flat list, whatever this holds. A third value, steps, was retired in migration 0107 and is no longer accepted.

multiServiceEnabledboolean(default false)

Appointments only (migration 0054). When true, the public widget lets a guest select several services and books them as ONE visit (one appointment row spanning the total duration, itemized in book_appointment_services). When false; the default; tapping a service advances immediately.

staffPickerEnabledboolean(default true)

Appointments only (migration 0054). When false, the public widget skips the staff step entirely and books as "any available"; exactly what choosing Any Available always did. Defaults to true: the step keeps rendering until someone turns it off.

addressstring | null(max length 240)

Nullable free text; an empty string clears it, same normalisation as tagline.

currency"AUD" | "USD" | "EUR" | "GBP" | "NZD" | "CAD" | "SGD" | "JPY"(default "AUD")

The price/deposit denomination (migration 0012, tenant-writable since 0022). No null state; an unsupported code is a 400.

dateFormat"dd_mm_yyyy" | "mm_dd_yyyy" | "yyyy_mm_dd"(default "dd_mm_yyyy")

Migration 0060. No null state; an unrecognised value is a 400.

timezonestring

An IANA zone from Intl.supportedValuesOf('timeZone'), the same list the Organization settings dropdown is built from. No null state; an unsupported value is a 400.

googleReviewUrlstring | null(max length 500)

The post-visit review request link (migration 0059). Must start with https:// when present; an empty string clears it.

receiptAbnstring

Normalised to 11 digits; an empty string clears it. Shown on every receipt, and what makes one a tax invoice under ATO rules.

receiptTradingNamestring | null(max length 120)

Printed on receipts instead of the business name. Leave blank to use it.

receiptFooterstring | null(max length 1000)

Shown at the bottom of every receipt. Multi-line; line breaks are kept, only the ends are trimmed.

receiptAutoSendboolean

When true, a receipt is emailed the moment a deposit is paid (or the end-of-day sweep catches it). Manual resend is always available regardless of this setting.

closeOutUsualPaymentMethod"cash" | "card" | "bank_transfer" | "other"(default "cash")

The default a completed booking's own payment_method is seeded with; editable per booking afterward.

closeOutTipsEnabledboolean(default false)

Adds a tip field to close-out. Off by default; when off, the field never appears.

depositEnabledboolean

Refused with a 400 if cardOnFileEnabled would also end up true: the two payment modes are mutually exclusive (migration 0129's own DB CHECK is the last line of defence).

depositType"fixed" | "percentage"(default "fixed")

Migration 0123. Which of the next two fields depositDueCents() reads. Both stay populated independently, so flipping this never loses either number.

depositAmountCentsinteger | null(min 1, max 100000)

Smallest currency unit (matches Stripe's own convention; whole yen for JPY, not hundredths). Only meaningful when depositType is fixed. null means "on, but no amount set yet".

depositPercentageinteger | null(min 1, max 100)

Only meaningful when depositType is percentage. 100 asks for the full price at booking. null means "on, but no amount set yet", the same shape as depositAmountCents.

depositPerPersonboolean

Hospitality only in practice. Multiplies the active amount by party size instead of charging one flat figure per booking.

depositMinPartySizeinteger | null(min 1, max 30)

The smallest party a deposit is asked of. null means every booking pays.

depositRefundWindowHoursinteger(min 0, max 720, default 24)

Cancelling this many hours ahead refunds the deposit automatically. Zero is legal and means never auto-refunded; NOT NULL, so no null branch.

depositStaffEditableboolean

Admin-only, service-role write (migration 0075). Grants a permission, so it is excluded from the staff-writable set and cannot be sent by a staff caller at all.

cardOnFileEnabledboolean

Refused with a 400 if depositEnabled would also end up true. Saves a card at booking with zero charge; a staff member charges the fee later, by hand, if ever.

noShowFeeType"fixed" | "percentage"(default "fixed")

Same fixed/percentage split as depositType, for the no-show fee instead.

noShowFeeAmountCentsinteger | null(min 1, max 100000)

Only meaningful when noShowFeeType is fixed. Locked in when the card is saved at booking; staff decide later whether to actually charge it.

noShowFeePercentageinteger | null(min 1, max 100)

Only meaningful when noShowFeeType is percentage. Same "on but unset" null state as noShowFeeAmountCents.

unlinkedStaffFullAccessboolean

Admin-only, service-role write. Grants a permission, excluded from the staff-writable set, docs/staff-privacy.md.

staffClientVisibility"full" | "no_contact" | "anonymized"

Admin-only, service-role write. Same reasoning as unlinkedStaffFullAccess.

backgroundColorstring | null

A preset id from PAGE_BACKGROUNDS, or a custom #rrggbb hex, told apart by a leading #. "default" (or omission) means no override. The 60% "dominant" role in the colour-role system (migration 0229); shared with the tenant website, never forked.

secondaryColorstring | null

Migration 0229. Any #rrggbb hex, no allowlist: SECONDARY_COLORS (palette.ts) is a curated set of suggestions, not an enforced list. The 30% "secondary" role, overriding only the card/surface tone (via withSecondary), never canvas or border. Null derives the card tone from backgroundColor automatically, the behaviour every pre-existing row already has. Shared with the tenant website, same posture as accentColor/backgroundColor.

emailAccentColorstring | null

Migration 0229. Any #rrggbb hex. Guest-facing transactional email (confirmation/reminder/change/cancellation, review request, receipt, waitlist offer) already inherits accentColor automatically; this is an optional per-email override for an org whose brand accent reads differently once it is the only colour on an otherwise-plain email render. Null (the default) keeps inheriting accentColor.

headingFontstring | null

A preset id from HEADING_FONTS. "default" (or omission) means no override.

headingScale"compact" | "default" | "large"(default "default")

Migration 0027. NOT NULL, database-constrained too, so an invalid value is a 400 here for a legible message rather than a raw 23514.

cornerStyle"square" | "default" | "round"(default "default")

Migration 0027. Same posture as headingScale.

look"minimal" | "bold" | "solo" | "warm" | null

Migration 0113. A site-look preset id off SITE_LOOKS, or null for none chosen. Sent alone, never bundled with backgroundColor/headingFont/cornerStyle: see this route's own comment on why that bundling was a real, shipped bug.

textMotion"gradient-sweep" | "materialize" | "unfold" | "card-flip" | null

Migration 0117. A heading text-motion preset id, or null for none. Its own field, never bundled with look.

showPhoneOnSiteboolean

Migration 0122. Site-only; never affects the booking wizard or its confirmation.

backgroundImageAltstring | null(max length 200)

Migration 0122. Describes the one photo SiteShell renders as every page's hero. An empty string clears back to the generated default.

socialLinksobject[](max items 6)

Migration 0115. Replaces the whole list; there is no per-link PATCH.

socialLinks[].platform*"instagram" | "facebook" | "tiktok" | "youtube" | "linkedin" | "x" | "website"
socialLinks[].url*string

Must start with https://.

bookingMetaTitlestring | null(max length 60)

Migration 0180. Overrides the booking route's own <title>; a sibling of siteMetaTitle, not a read of it. Empty string clears back to the generated "Book: {name}" default.

bookingMetaDescriptionstring | null(max length 160)

Migration 0180. Same story as bookingMetaTitle, for the meta description. Empty string clears back to the generated tagline/industry fallback.

nameBoldboolean
nameItalicboolean
nameUnderlineboolean
nameFontSize"small" | "default" | "large" | "xlarge"(default "default")
nameMotion"gradient-sweep" | "materialize" | "unfold" | "card-flip" | "sparkle-glow" | null

Migration 0179 (widened to include sparkle-glow by 0216). The booking widget's own heading text-motion preset id, or null for none. Its own column, a sibling of textMotion (site-wide) rather than a read of it.

taglineBoldboolean
taglineItalicboolean
taglineUnderlineboolean
taglineFontSize"small" | "default" | "large" | "xlarge"(default "default")
taglineMotion"gradient-sweep" | "materialize" | "unfold" | "card-flip" | "sparkle-glow" | null

Migration 0217. nameMotion's sibling for the tagline, independent rather than a fallback: picking a preset for one never changes what the other renders.

backgroundPosDesktopXinteger(min 0, max 100)
backgroundPosDesktopYinteger(min 0, max 100)
backgroundPosMobileXinteger(min 0, max 100)
backgroundPosMobileYinteger(min 0, max 100)
aboutImagePosXinteger(min 0, max 100)

The About section's own photo (aboutImageUrl on /organization/website). One frame, unlike the hero's desktop/mobile pair.

aboutImagePosYinteger(min 0, max 100)
siteBackgroundPosDesktopXinteger(min 0, max 100)

The website's own hero photo (siteBackgroundImageUrl, set via POST/DELETE /api/organization/site-background, not this route). Desktop/mobile pair, same shape as backgroundPosDesktopX above but a sibling column, never a write to it.

siteBackgroundPosDesktopYinteger(min 0, max 100)
siteBackgroundPosMobileXinteger(min 0, max 100)
siteBackgroundPosMobileYinteger(min 0, max 100)
soloOperatorboolean

Migration 0119. The merchant's own "I work solo" statement, set by the Setup Guide's dismiss action on the team step. A normal, independently re-enabled setting afterward.

payInPersonboolean

Migration 0120. The merchant's own "I take payment in person" statement, set by the Setup Guide's dismiss action on the payments step. Purely a declaration; no second field to flip alongside it.

gstRegisteredboolean

No country check here on purpose. gstRateFor (lib/billing/tax.ts) already answers null for anywhere this product has no rate for, so turning this on for an unsupported country is harmless, just inert.

headerShowIndustryboolean
headerShowAddressboolean
headerShowPhoneboolean
appointmentSlotMinutes5 | 10 | 15 | 20 | 30 | 60

What the public widget offers start times at, and the finest gridline tier the dashboard calendar draws.

reminderLeadHoursinteger(min 1, max 168, default 24)

How many hours before a booking sendDueReminders() sends its one reminder.

Responses

200

Saved.

FieldType
ok*true
400

A validation message, an industry belonging to the other vertical, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

maxOnlinePartySize was sent a non-null value, but this org's plan does not include party-size rules; Basic Table and above (src/lib/plan.ts, party_size_rules), so in practice only a lapsed or never-subscribed company on the free floor sees this. Clearing the ceiling (explicit null) never hits this.

403

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 who sent one of the five admin-only keys, sent any key outside the deposit-policy set with no manage_org_settings permission, or sent a deposit-policy key while depositStaffEditable is off for this org (migration 0082, Admin access required either way).

404

The update matched no row through service-role, where RLS cannot be the explanation, so the company genuinely no longer exists. Answered rather than reported as success because PostgREST returns 204 with no error for a zero-row UPDATE (see the 409 below).

409

Either the requested slug is already taken by another org (checked before any cosmetic field is written, so a duplicate slug leaves nothing half-saved), or the tenant-scoped update matched no row.

That second case is the stale-claim window. requireMember() reads companyId from getUser(), which asks the Auth server and sees the CURRENT app_metadata, while RLS evaluates auth.jwt(): the access token in the request, carrying its own copy of the claim frozen when it was minted. Switching business rewrites the claim server-side, so until the session refreshes this route targets the new company while the policy still admits only the old one. The row is invisible to the write, nothing matches, and PostgREST reports 204 with error: null. This used to answer 200 and the client showed a value that silently reverted on the next read. scripts/check-zero-row-update.ts pins the behaviour the guard depends on.

Raw OpenAPI operation
{
  "summary": "Update organization settings (admin, or manage_org_settings, or deposit_staff_editable)",
  "description": "A true partial update: a key that is not in the body is not in the UPDATE.\n\nThis paragraph used to say the opposite, and the opposite used to be true; every field was derived with a fallback, so omitting `tagline` nulled it, omitting `notifyEmailEnabled` forced it `true`, and omitting `bookingTheme` reset it to `light`. That was fixed in the route (a `has()` gate, the same discipline the reservations, appointments and clients PATCH routes use) and the description was not moved with it. `scripts/check-hardening.ts` asserts the current behaviour column by column.\n\n**Three ways in (migration 0082), checked in order of breadth.** An admin may write anything below. A staff login granted `manage_org_settings` may write anything EXCEPT five admin-only keys that either change the org's public identity (`slug`, `status`) or grant a permission (`depositStaffEditable`, `unlinkedStaffFullAccess`, `staffClientVisibility`); a permission that can grant permissions is not delegable, it is the thing doing the delegating. Absent that, a staff login may still write the six deposit-policy fields alone, but only once this org's admin has turned on `depositStaffEditable` (migration 0075).\n\n`slug`, `status` and the three admin-only permission flags are written with service-role, because migration 0022 (and 0075 for the deposit flag) leaves those columns out of the tenant column grant; a staff JWT provably could flip them through raw PostgREST before that. Everything else goes through the RLS-scoped client.\n\n`business_type` is NOT settable. Flipping a live org's vertical strands whatever it already has; a conversion is a data migration, not a settings toggle.",
  "tags": [
    "Shared"
  ],
  "operationId": "updateCompany",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "name",
            "slug",
            "status"
          ],
          "properties": {
            "name": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120
            },
            "slug": {
              "type": "string",
              "pattern": "^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$",
              "description": "Changing this moves the public booking page and busts both the old and new cache tags."
            },
            "status": {
              "type": "string",
              "enum": [
                "active",
                "inactive"
              ],
              "description": "`inactive` takes the public booking page offline immediately."
            },
            "industry": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 80,
              "description": "Validated against the org's existing business_type, which cannot be changed here, so settings cannot drift the pair apart after the fact."
            },
            "accentColor": {
              "type": [
                "string",
                "null"
              ],
              "description": "Must be the `base` of one of BRAND_COLORS. Lowercased."
            },
            "bookingTheme": {
              "type": "string",
              "enum": [
                "light",
                "dark",
                "system"
              ],
              "default": "light",
              "description": "The theme the org's own booking page wears. The visitor does not choose it, unless bookingThemeToggleEnabled is also on."
            },
            "bookingThemeToggleEnabled": {
              "type": "boolean",
              "default": false,
              "description": "When true, adds a visitor-facing switch to the booking page that can move a visitor away from bookingTheme for their own viewing. Off by default; bookingTheme alone is authoritative until an org opts in."
            },
            "siteTheme": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "light",
                "dark",
                "system",
                null
              ],
              "description": "Migration 0194. The theme the org's WEBSITE wears, independent of bookingTheme above. Null means no site-specific choice, which falls back to bookingTheme live."
            },
            "siteThemeToggleEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Migration 0194. Sibling of bookingThemeToggleEnabled, for the website's own visitor-facing switch."
            },
            "tierBadgeDisplay": {
              "type": "string",
              "enum": [
                "auto",
                "blue",
                "off"
              ],
              "default": "auto",
              "description": "Migration 0204. The org's own say over the plan-tier mark next to its name on the booking widget. 'auto' shows the tier's natural colour (blue for basic, gold for pro/enterprise), 'blue' forces the calmer colour even on pro/enterprise, 'off' hides it regardless of tier. There is no value that lets an org show a colour above its actual tier."
            },
            "notifyEmailEnabled": {
              "type": "boolean",
              "default": true,
              "description": "Defaults to true; only the literal `false` turns it off."
            },
            "notifySmsEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Defaults to false; only the literal `true` turns it on."
            },
            "tagline": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 160
            },
            "phone": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 40,
              "description": "The venue's own public number; what the booking page shows as the way to reach it, and what a party too big to book online is told to ring. Required at onboarding, and here it may be CORRECTED but not REMOVED: omitting the key leaves it alone, and an explicit null or blank is a 400 when a number is on file, a no-op when one is not. Without that, the onboarding requirement would be undone from Settings a minute later."
            },
            "locationPickerEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Hospitality only (migration 0030). Lets a guest choose a level/area before seeing times. Defaults to false."
            },
            "tablePickerEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Hospitality only (migration 0030). Lets a guest choose a specific table. Defaults to false."
            },
            "maxOnlinePartySize": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 30,
              "description": "Hospitality only (migration 0036). The largest party the venue confirms online without a human looking. Above it, the booking page stops offering times and invites a big-group enquiry, and BOTH public reservation routes refuse with `enquiryRequired: true`, so a direct POST cannot walk past it. **null means no ceiling** and is the default; sending null explicitly CLEARS an existing one (available on every tier), while a non-null value REQUIRES a paid tier, Basic Table or above (src/lib/plan.ts, `party_size_rules`; moved down from Venue Pro on 2026-08-14); setting a ceiling is also what turns on the big-group-enquiry pathway, since it never fires with no ceiling to exceed. Omitting the field leaves it untouched. Deliberately not derived from the largest table: capacity is what a venue can seat, this is what it will confirm unattended, and those are different numbers."
            },
            "staffSeatLimitEnforced": {
              "type": "boolean",
              "default": false,
              "description": "Hospitality only (migration 0036). When false; the default, and the behaviour these routes have always had; staff may book more guests onto a table than it seats, because a host putting five on a four-top is a real thing venues do. When true, POST /api/reservations and PATCH /api/reservations/{id} refuse it with a message naming the table and both numbers. Never affects double-booking, which no setting can override."
            },
            "availabilityApprovalHorizonDays": {
              "type": "integer",
              "minimum": 1,
              "maximum": 60,
              "default": 7,
              "description": "Appointments only (migration 0052). How many days out a Standard-tier staff member's own date-specific schedule request applies immediately, with just a notification, rather than needing admin approval first. NOT NULL with a bounded default, so no null branch; a value outside 1-60 is a 400."
            },
            "serviceLayout": {
              "type": "string",
              "enum": [
                "list",
                "pills"
              ],
              "default": "pills",
              "description": "Appointments only (migration 0054). How the public widget presents services once the org has service groups: `pills` is a scrollable group bar over grouped sections, `list` keeps the flat list (groups stay an admin-side organizing tool). With no groups the widget always renders the flat list, whatever this holds. A third value, `steps`, was retired in migration 0107 and is no longer accepted."
            },
            "multiServiceEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Appointments only (migration 0054). When true, the public widget lets a guest select several services and books them as ONE visit (one appointment row spanning the total duration, itemized in book_appointment_services). When false; the default; tapping a service advances immediately."
            },
            "staffPickerEnabled": {
              "type": "boolean",
              "default": true,
              "description": "Appointments only (migration 0054). When false, the public widget skips the staff step entirely and books as \"any available\"; exactly what choosing Any Available always did. Defaults to true: the step keeps rendering until someone turns it off."
            },
            "address": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 240,
              "description": "Nullable free text; an empty string clears it, same normalisation as tagline."
            },
            "currency": {
              "type": "string",
              "enum": [
                "AUD",
                "USD",
                "EUR",
                "GBP",
                "NZD",
                "CAD",
                "SGD",
                "JPY"
              ],
              "default": "AUD",
              "description": "The price/deposit denomination (migration 0012, tenant-writable since 0022). No null state; an unsupported code is a 400."
            },
            "dateFormat": {
              "type": "string",
              "enum": [
                "dd_mm_yyyy",
                "mm_dd_yyyy",
                "yyyy_mm_dd"
              ],
              "default": "dd_mm_yyyy",
              "description": "Migration 0060. No null state; an unrecognised value is a 400."
            },
            "timezone": {
              "type": "string",
              "description": "An IANA zone from Intl.supportedValuesOf('timeZone'), the same list the Organization settings dropdown is built from. No null state; an unsupported value is a 400."
            },
            "googleReviewUrl": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 500,
              "description": "The post-visit review request link (migration 0059). Must start with https:// when present; an empty string clears it."
            },
            "receiptAbn": {
              "type": "string",
              "description": "Normalised to 11 digits; an empty string clears it. Shown on every receipt, and what makes one a tax invoice under ATO rules."
            },
            "receiptTradingName": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 120,
              "description": "Printed on receipts instead of the business name. Leave blank to use it."
            },
            "receiptFooter": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 1000,
              "description": "Shown at the bottom of every receipt. Multi-line; line breaks are kept, only the ends are trimmed."
            },
            "receiptAutoSend": {
              "type": "boolean",
              "description": "When true, a receipt is emailed the moment a deposit is paid (or the end-of-day sweep catches it). Manual resend is always available regardless of this setting."
            },
            "closeOutUsualPaymentMethod": {
              "type": "string",
              "enum": [
                "cash",
                "card",
                "bank_transfer",
                "other"
              ],
              "default": "cash",
              "description": "The default a completed booking's own payment_method is seeded with; editable per booking afterward."
            },
            "closeOutTipsEnabled": {
              "type": "boolean",
              "default": false,
              "description": "Adds a tip field to close-out. Off by default; when off, the field never appears."
            },
            "depositEnabled": {
              "type": "boolean",
              "description": "Refused with a 400 if cardOnFileEnabled would also end up true: the two payment modes are mutually exclusive (migration 0129's own DB CHECK is the last line of defence)."
            },
            "depositType": {
              "type": "string",
              "enum": [
                "fixed",
                "percentage"
              ],
              "default": "fixed",
              "description": "Migration 0123. Which of the next two fields depositDueCents() reads. Both stay populated independently, so flipping this never loses either number."
            },
            "depositAmountCents": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100000,
              "description": "Smallest currency unit (matches Stripe's own convention; whole yen for JPY, not hundredths). Only meaningful when depositType is fixed. null means \"on, but no amount set yet\"."
            },
            "depositPercentage": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100,
              "description": "Only meaningful when depositType is percentage. 100 asks for the full price at booking. null means \"on, but no amount set yet\", the same shape as depositAmountCents."
            },
            "depositPerPerson": {
              "type": "boolean",
              "description": "Hospitality only in practice. Multiplies the active amount by party size instead of charging one flat figure per booking."
            },
            "depositMinPartySize": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 30,
              "description": "The smallest party a deposit is asked of. null means every booking pays."
            },
            "depositRefundWindowHours": {
              "type": "integer",
              "minimum": 0,
              "maximum": 720,
              "default": 24,
              "description": "Cancelling this many hours ahead refunds the deposit automatically. Zero is legal and means never auto-refunded; NOT NULL, so no null branch."
            },
            "depositStaffEditable": {
              "type": "boolean",
              "description": "Admin-only, service-role write (migration 0075). Grants a permission, so it is excluded from the staff-writable set and cannot be sent by a staff caller at all."
            },
            "cardOnFileEnabled": {
              "type": "boolean",
              "description": "Refused with a 400 if depositEnabled would also end up true. Saves a card at booking with zero charge; a staff member charges the fee later, by hand, if ever."
            },
            "noShowFeeType": {
              "type": "string",
              "enum": [
                "fixed",
                "percentage"
              ],
              "default": "fixed",
              "description": "Same fixed/percentage split as depositType, for the no-show fee instead."
            },
            "noShowFeeAmountCents": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100000,
              "description": "Only meaningful when noShowFeeType is fixed. Locked in when the card is saved at booking; staff decide later whether to actually charge it."
            },
            "noShowFeePercentage": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100,
              "description": "Only meaningful when noShowFeeType is percentage. Same \"on but unset\" null state as noShowFeeAmountCents."
            },
            "unlinkedStaffFullAccess": {
              "type": "boolean",
              "description": "Admin-only, service-role write. Grants a permission, excluded from the staff-writable set, docs/staff-privacy.md."
            },
            "staffClientVisibility": {
              "type": "string",
              "enum": [
                "full",
                "no_contact",
                "anonymized"
              ],
              "description": "Admin-only, service-role write. Same reasoning as unlinkedStaffFullAccess."
            },
            "backgroundColor": {
              "type": [
                "string",
                "null"
              ],
              "description": "A preset id from PAGE_BACKGROUNDS, or a custom #rrggbb hex, told apart by a leading #. \"default\" (or omission) means no override. The 60% \"dominant\" role in the colour-role system (migration 0229); shared with the tenant website, never forked."
            },
            "secondaryColor": {
              "type": [
                "string",
                "null"
              ],
              "description": "Migration 0229. Any #rrggbb hex, no allowlist: SECONDARY_COLORS (palette.ts) is a curated set of suggestions, not an enforced list. The 30% \"secondary\" role, overriding only the card/surface tone (via withSecondary), never canvas or border. Null derives the card tone from backgroundColor automatically, the behaviour every pre-existing row already has. Shared with the tenant website, same posture as accentColor/backgroundColor."
            },
            "emailAccentColor": {
              "type": [
                "string",
                "null"
              ],
              "description": "Migration 0229. Any #rrggbb hex. Guest-facing transactional email (confirmation/reminder/change/cancellation, review request, receipt, waitlist offer) already inherits accentColor automatically; this is an optional per-email override for an org whose brand accent reads differently once it is the only colour on an otherwise-plain email render. Null (the default) keeps inheriting accentColor."
            },
            "headingFont": {
              "type": [
                "string",
                "null"
              ],
              "description": "A preset id from HEADING_FONTS. \"default\" (or omission) means no override."
            },
            "headingScale": {
              "type": "string",
              "enum": [
                "compact",
                "default",
                "large"
              ],
              "default": "default",
              "description": "Migration 0027. NOT NULL, database-constrained too, so an invalid value is a 400 here for a legible message rather than a raw 23514."
            },
            "cornerStyle": {
              "type": "string",
              "enum": [
                "square",
                "default",
                "round"
              ],
              "default": "default",
              "description": "Migration 0027. Same posture as headingScale."
            },
            "look": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "minimal",
                "bold",
                "solo",
                "warm",
                null
              ],
              "description": "Migration 0113. A site-look preset id off SITE_LOOKS, or null for none chosen. Sent alone, never bundled with backgroundColor/headingFont/cornerStyle: see this route's own comment on why that bundling was a real, shipped bug."
            },
            "textMotion": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "gradient-sweep",
                "materialize",
                "unfold",
                "card-flip",
                null
              ],
              "description": "Migration 0117. A heading text-motion preset id, or null for none. Its own field, never bundled with look."
            },
            "showPhoneOnSite": {
              "type": "boolean",
              "description": "Migration 0122. Site-only; never affects the booking wizard or its confirmation."
            },
            "backgroundImageAlt": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 200,
              "description": "Migration 0122. Describes the one photo SiteShell renders as every page's hero. An empty string clears back to the generated default."
            },
            "socialLinks": {
              "type": "array",
              "maxItems": 6,
              "items": {
                "type": "object",
                "required": [
                  "platform",
                  "url"
                ],
                "properties": {
                  "platform": {
                    "type": "string",
                    "enum": [
                      "instagram",
                      "facebook",
                      "tiktok",
                      "youtube",
                      "linkedin",
                      "x",
                      "website"
                    ]
                  },
                  "url": {
                    "type": "string",
                    "description": "Must start with https://."
                  }
                }
              },
              "description": "Migration 0115. Replaces the whole list; there is no per-link PATCH."
            },
            "bookingMetaTitle": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 60,
              "description": "Migration 0180. Overrides the booking route's own <title>; a sibling of siteMetaTitle, not a read of it. Empty string clears back to the generated \"Book: {name}\" default."
            },
            "bookingMetaDescription": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 160,
              "description": "Migration 0180. Same story as bookingMetaTitle, for the meta description. Empty string clears back to the generated tagline/industry fallback."
            },
            "nameBold": {
              "type": "boolean"
            },
            "nameItalic": {
              "type": "boolean"
            },
            "nameUnderline": {
              "type": "boolean"
            },
            "nameFontSize": {
              "type": "string",
              "enum": [
                "small",
                "default",
                "large",
                "xlarge"
              ],
              "default": "default"
            },
            "nameMotion": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "gradient-sweep",
                "materialize",
                "unfold",
                "card-flip",
                "sparkle-glow",
                null
              ],
              "description": "Migration 0179 (widened to include sparkle-glow by 0216). The booking widget's own heading text-motion preset id, or null for none. Its own column, a sibling of textMotion (site-wide) rather than a read of it."
            },
            "taglineBold": {
              "type": "boolean"
            },
            "taglineItalic": {
              "type": "boolean"
            },
            "taglineUnderline": {
              "type": "boolean"
            },
            "taglineFontSize": {
              "type": "string",
              "enum": [
                "small",
                "default",
                "large",
                "xlarge"
              ],
              "default": "default"
            },
            "taglineMotion": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "gradient-sweep",
                "materialize",
                "unfold",
                "card-flip",
                "sparkle-glow",
                null
              ],
              "description": "Migration 0217. nameMotion's sibling for the tagline, independent rather than a fallback: picking a preset for one never changes what the other renders."
            },
            "backgroundPosDesktopX": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "backgroundPosDesktopY": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "backgroundPosMobileX": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "backgroundPosMobileY": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "aboutImagePosX": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100,
              "description": "The About section's own photo (aboutImageUrl on /organization/website). One frame, unlike the hero's desktop/mobile pair."
            },
            "aboutImagePosY": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "siteBackgroundPosDesktopX": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100,
              "description": "The website's own hero photo (siteBackgroundImageUrl, set via POST/DELETE /api/organization/site-background, not this route). Desktop/mobile pair, same shape as backgroundPosDesktopX above but a sibling column, never a write to it."
            },
            "siteBackgroundPosDesktopY": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "siteBackgroundPosMobileX": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "siteBackgroundPosMobileY": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "soloOperator": {
              "type": "boolean",
              "description": "Migration 0119. The merchant's own \"I work solo\" statement, set by the Setup Guide's dismiss action on the team step. A normal, independently re-enabled setting afterward."
            },
            "payInPerson": {
              "type": "boolean",
              "description": "Migration 0120. The merchant's own \"I take payment in person\" statement, set by the Setup Guide's dismiss action on the payments step. Purely a declaration; no second field to flip alongside it."
            },
            "gstRegistered": {
              "type": "boolean",
              "description": "No country check here on purpose. gstRateFor (lib/billing/tax.ts) already answers null for anywhere this product has no rate for, so turning this on for an unsupported country is harmless, just inert."
            },
            "headerShowIndustry": {
              "type": "boolean"
            },
            "headerShowAddress": {
              "type": "boolean"
            },
            "headerShowPhone": {
              "type": "boolean"
            },
            "appointmentSlotMinutes": {
              "type": "integer",
              "enum": [
                5,
                10,
                15,
                20,
                30,
                60
              ],
              "description": "What the public widget offers start times at, and the finest gridline tier the dashboard calendar draws."
            },
            "reminderLeadHours": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168,
              "default": 24,
              "description": "How many hours before a booking sendDueReminders() sends its one reminder."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message, an industry belonging to the other vertical, 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"
          }
        }
      }
    },
    "402": {
      "description": "`maxOnlinePartySize` was sent a non-null value, but this org's plan does not include party-size rules; Basic Table and above (src/lib/plan.ts, `party_size_rules`), so in practice only a lapsed or never-subscribed company on the `free` floor sees this. Clearing the ceiling (explicit `null`) never hits this.",
      "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 who sent one of the five admin-only keys, sent any key outside the deposit-policy set with no `manage_org_settings` permission, or sent a deposit-policy key while `depositStaffEditable` is off for this org (migration 0082, `Admin access required` either way).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "The update matched no row through service-role, where RLS cannot be the explanation, so the company genuinely no longer exists. Answered rather than reported as success because PostgREST returns 204 with no error for a zero-row UPDATE (see the 409 below).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Either the requested slug is already taken by another org (checked before any cosmetic field is written, so a duplicate slug leaves nothing half-saved), or the tenant-scoped update matched no row.\n\nThat second case is the stale-claim window. `requireMember()` reads companyId from `getUser()`, which asks the Auth server and sees the CURRENT app_metadata, while RLS evaluates `auth.jwt()`: the access token in the request, carrying its own copy of the claim frozen when it was minted. Switching business rewrites the claim server-side, so until the session refreshes this route targets the new company while the policy still admits only the old one. The row is invisible to the write, nothing matches, and PostgREST reports 204 with `error: null`. This used to answer 200 and the client showed a value that silently reverted on the next read. `scripts/check-zero-row-update.ts` pins the behaviour the guard depends on.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/messages

One thread's messages

Session cookie

A single booking or enquiry's conversation, for the compact panel inside a booking's Drawer (ReservationsClient/BookingsClient); the full inbox at /inbox assembles every thread server-side in one page load; this is the one-thread equivalent for a client component that only has an id.

Same staff-scope as /inbox's own appointment branch (docs/staff-privacy.md): a kind=appointment booking outside a scoped staff member's own confirmed provider resolves to nothing; a 404, identical to a booking id from another tenant or one that no longer exists. reservation and enquiry are never scoped.

senderName/senderAvatarUrl (0057) are present on staff messages only: the specific provider's own identity when the reply is attributed to one, else the company's own; "the business replied."

Parameters

  • kind*query"reservation" | "appointment" | "enquiry"
  • bookingId*querystring (uuid)

Responses

200

The thread, oldest first.

FieldTypeNotes
messages*object[]
messages[].id*string (uuid)
messages[].author*"guest" | "staff"
messages[].body*string
messages[].createdAt*string (date-time)
messages[].senderNamestring

Staff messages only (0057).

messages[].senderAvatarUrlstring | null
400

An unknown kind or a missing booking id.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

A booking id from another tenant, one that no longer exists, or (appointments only) one outside a scoped staff member's own bookings.

Raw OpenAPI operation
{
  "summary": "One thread's messages",
  "description": "A single booking or enquiry's conversation, for the compact panel inside a booking's Drawer (ReservationsClient/BookingsClient); the full inbox at /inbox assembles every thread server-side in one page load; this is the one-thread equivalent for a client component that only has an id.\n\nSame staff-scope as /inbox's own appointment branch (docs/staff-privacy.md): a `kind=appointment` booking outside a scoped staff member's own confirmed provider resolves to nothing; a 404, identical to a booking id from another tenant or one that no longer exists. `reservation` and `enquiry` are never scoped.\n\n`senderName`/`senderAvatarUrl` (0057) are present on staff messages only: the specific provider's own identity when the reply is attributed to one, else the company's own; \"the business replied.\"",
  "tags": [
    "Shared"
  ],
  "operationId": "getThread",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "kind",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "enum": [
          "reservation",
          "appointment",
          "enquiry"
        ]
      }
    },
    {
      "name": "bookingId",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "format": "uuid"
      }
    }
  ],
  "responses": {
    "200": {
      "description": "The thread, oldest first.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "messages"
            ],
            "properties": {
              "messages": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "author",
                    "body",
                    "createdAt"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "author": {
                      "type": "string",
                      "enum": [
                        "guest",
                        "staff"
                      ]
                    },
                    "body": {
                      "type": "string"
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "senderName": {
                      "type": "string",
                      "description": "Staff messages only (0057)."
                    },
                    "senderAvatarUrl": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown kind or a missing booking 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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "A booking id from another tenant, one that no longer exists, or (appointments only) one outside a scoped staff member's own bookings.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/messages

Reply to a guest

Session cookie

Any member, not admin-only: answering a customer is the job, and gating it behind admin would leave the person actually on shift unable to respond.

The author value is pinned to staff by the RLS WITH CHECK, not merely set here; a crafted request cannot post in a guest's name.

After the reply is recorded, notifyMessageReply() sends it to the guest on the requested channel (email by default); awaited, not fired-and-forgotten, and never throws, so a delivery failure changes nothing about this response. sms additionally requires the SMS entitlement (plan.ts's planAllows(companyId, 'sms'), i.e. tier inclusion or the bought add-on, and never during a trial) and a phone number that can be resolved to E.164 against the company's country/timezone; either failing means only the log records it, the reply itself still succeeds.

Not `smsIncluded`. That limit is 0 on every tier since the pay-as-you-go rewrite, so gating on it refused everyone; the /inbox toggle did exactly that until 2026-08-11.

Request body application/json

FieldTypeNotes
kind*"reservation" | "appointment" | "enquiry"

Which subject this thread hangs off; a booking on either engine, or a big-group enquiry (0037) that has not become one yet. The only place a shared route names all three.

bookingId*string (uuid)
message*string(min length 1, max length 2000)
channel"email" | "sms"

Which channel notifies the guest of this specific reply. Defaults to email when omitted or unrecognised; a delivery preference, not a validated field, so a malformed value never 400s a reply that otherwise sent fine.

Responses

200

Sent. Does not mean the guest was notified; see the notifyMessageReply note above.

FieldType
ok*true
400

An unknown kind, a missing booking id, an empty reply, or one over 2000 characters.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

A booking id from another tenant or one that no longer exists (foreign key violation).

Raw OpenAPI operation
{
  "summary": "Reply to a guest",
  "description": "Any member, not admin-only: answering a customer is the job, and gating it behind admin would leave the person actually on shift unable to respond.\n\nThe `author` value is pinned to `staff` by the RLS WITH CHECK, not merely set here; a crafted request cannot post in a guest's name.\n\nAfter the reply is recorded, `notifyMessageReply()` sends it to the guest on the requested `channel` (email by default); awaited, not fired-and-forgotten, and never throws, so a delivery failure changes nothing about this response. `sms` additionally requires the SMS entitlement (`plan.ts`'s `planAllows(companyId, 'sms')`, i.e. tier inclusion or the bought add-on, and never during a trial) and a phone number that can be resolved to E.164 against the company's country/timezone; either failing means only the log records it, the reply itself still succeeds.\n\n**Not `smsIncluded`.** That limit is 0 on every tier since the pay-as-you-go rewrite, so gating on it refused everyone; the `/inbox` toggle did exactly that until 2026-08-11.",
  "tags": [
    "Shared"
  ],
  "operationId": "replyToGuest",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "kind",
            "bookingId",
            "message"
          ],
          "properties": {
            "kind": {
              "type": "string",
              "enum": [
                "reservation",
                "appointment",
                "enquiry"
              ],
              "description": "Which subject this thread hangs off; a booking on either engine, or a big-group enquiry (0037) that has not become one yet. The only place a shared route names all three."
            },
            "bookingId": {
              "type": "string",
              "format": "uuid"
            },
            "message": {
              "type": "string",
              "minLength": 1,
              "maxLength": 2000
            },
            "channel": {
              "type": "string",
              "enum": [
                "email",
                "sms"
              ],
              "description": "Which channel notifies the guest of this specific reply. Defaults to `email` when omitted or unrecognised; a delivery preference, not a validated field, so a malformed value never 400s a reply that otherwise sent fine."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Sent. Does not mean the guest was notified; see the notifyMessageReply note above.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown kind, a missing booking id, an empty reply, or one over 2000 characters.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "A booking id from another tenant or one that no longer exists (foreign key violation).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/messages

Mark a thread read

Session cookie

Marks this booking's unread GUEST messages as read. Separate from POST so opening a conversation does not require writing one.

The booking is looked up before the update, and an id that is not this org's is a 404. The number of messages actually flipped is deliberately NOT reflected in the status: a thread of your own with nothing currently unread matches zero rows and is still a 200, because marking a read thread read is idempotent, not an error.

Request body application/json

FieldTypeNotes
kind*"reservation" | "appointment" | "enquiry"

enquiry since migration 0040; a big-group enquiry carries the same internal_note column, and is where a venue accumulates the most note-shaped material of all, being a negotiation rather than a confirmed fact.

bookingId*string (uuid)

Responses

200

Marked read; including when the thread had nothing unread.

FieldType
ok*true
400

An unknown kind or a missing booking id, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No such booking in this org, or a malformed uuid.

Raw OpenAPI operation
{
  "summary": "Mark a thread read",
  "description": "Marks this booking's unread GUEST messages as read. Separate from POST so opening a conversation does not require writing one.\n\nThe booking is looked up before the update, and an id that is not this org's is a 404. The number of messages actually flipped is deliberately NOT reflected in the status: a thread of your own with nothing currently unread matches zero rows and is still a 200, because marking a read thread read is idempotent, not an error.",
  "tags": [
    "Shared"
  ],
  "operationId": "markThreadRead",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "kind",
            "bookingId"
          ],
          "properties": {
            "kind": {
              "type": "string",
              "enum": [
                "reservation",
                "appointment",
                "enquiry"
              ],
              "description": "`enquiry` since migration 0040; a big-group enquiry carries the same internal_note column, and is where a venue accumulates the most note-shaped material of all, being a negotiation rather than a confirmed fact."
            },
            "bookingId": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Marked read; including when the thread had nothing unread.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown kind or a missing booking id, 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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No such booking in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/notifications

What the notification bell shows

Session cookie

The next few bookings and the newest unread guest messages, in one read. Polled by NotificationDataProvider.tsx every 60 seconds: there is no Supabase Realtime wiring in this product (no supabase.channel() calls, no publication on the booking tables), and standing that up for one bell would be a great deal of infrastructure for what is, per tenant, a handful of rows a minute.

Shared, and genuinely so rather than merely ungated. It calls requireMember() with no vertical and then branches on business_type itself; upcoming appointments for one engine, upcoming reservations for the other; because one bell serves either product. Naming a vertical here would be wrong, not missing.

Both lists are capped at 5 and are a display feed, not a paged resource: there is no cursor, and asking for more is what the Bookings and Inbox screens are for. An account with no company answers 200 with two empty arrays, not a 403; the bell renders on every dashboard page and must not be the thing that breaks a half-provisioned session.

seenAt (migration 0208) is the caller's own "last opened the bell" cursor: bookings/messages/overrides created at or before it render as seen (greyed, not removed) in the dropdown, and only unseen ones count toward its badge. null means never opened. Set via PATCH on this same path.

Responses

200

The feed. Both arrays are present and may be empty.

FieldTypeNotes
bookings*object[]

The next 5 upcoming bookings, soonest first. Cancelled is excluded from both engines, and hold additionally from reservations; an unconfirmed hold is not something to notify anyone about.

bookings[].id*string (uuid)
bookings[].startsAt*string (date-time)
bookings[].customerName*string

Falls back to (unknown) on the appointments side and (hold) on the hospitality side when no customer is linked.

bookings[].title*string

Engine-shaped: <service> with <staff> for appointments, Table for <n> for reservations.

bookings[].color*string | null

The service's colour. Always null on the hospitality side; reservations have no service to take one from.

bookings[].href*string

The dashboard route that opens this booking, already engine-correct.

bookings[].createdAtstring (date-time)

Row creation time, for the seen/unseen cursor above.

messages*object[]

The 5 newest unread GUEST messages, newest first. Staff replies are never included; author is filtered to guest.

messages[].id*string (uuid)
messages[].customerName*string
messages[].preview*string

The message body, truncated to 80 characters with an ellipsis appended.

messages[].createdAt*string (date-time)
messages[].href*string

Deep link into the thread on /inbox.

seenAt*string | null (date-time)

The bell's own seen/unseen cursor (see above).

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "What the notification bell shows",
  "description": "The next few bookings and the newest unread guest messages, in one read. Polled by `NotificationDataProvider.tsx` every 60 seconds: there is no Supabase Realtime wiring in this product (no `supabase.channel()` calls, no publication on the booking tables), and standing that up for one bell would be a great deal of infrastructure for what is, per tenant, a handful of rows a minute.\n\n**Shared, and genuinely so** rather than merely ungated. It calls `requireMember()` with no vertical and then branches on `business_type` itself; upcoming appointments for one engine, upcoming reservations for the other; because one bell serves either product. Naming a vertical here would be wrong, not missing.\n\nBoth lists are capped at 5 and are a display feed, not a paged resource: there is no cursor, and asking for more is what the Bookings and Inbox screens are for. **An account with no company answers 200 with two empty arrays**, not a 403; the bell renders on every dashboard page and must not be the thing that breaks a half-provisioned session.\n\n`seenAt` (migration 0208) is the caller's own \"last opened the bell\" cursor: bookings/messages/overrides created at or before it render as seen (greyed, not removed) in the dropdown, and only unseen ones count toward its badge. `null` means never opened. Set via PATCH on this same path.",
  "tags": [
    "Shared"
  ],
  "operationId": "getNotifications",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The feed. Both arrays are present and may be empty.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "bookings",
              "messages",
              "seenAt"
            ],
            "properties": {
              "bookings": {
                "type": "array",
                "description": "The next 5 upcoming bookings, soonest first. Cancelled is excluded from both engines, and `hold` additionally from reservations; an unconfirmed hold is not something to notify anyone about.",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "startsAt",
                    "customerName",
                    "title",
                    "color",
                    "href"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "startsAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "customerName": {
                      "type": "string",
                      "description": "Falls back to `(unknown)` on the appointments side and `(hold)` on the hospitality side when no customer is linked."
                    },
                    "title": {
                      "type": "string",
                      "description": "Engine-shaped: `<service> with <staff>` for appointments, `Table for <n>` for reservations."
                    },
                    "color": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The service's colour. Always null on the hospitality side; reservations have no service to take one from."
                    },
                    "href": {
                      "type": "string",
                      "description": "The dashboard route that opens this booking, already engine-correct."
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Row creation time, for the seen/unseen cursor above."
                    }
                  }
                }
              },
              "messages": {
                "type": "array",
                "description": "The 5 newest unread GUEST messages, newest first. Staff replies are never included; `author` is filtered to `guest`.",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "customerName",
                    "preview",
                    "createdAt",
                    "href"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "customerName": {
                      "type": "string"
                    },
                    "preview": {
                      "type": "string",
                      "description": "The message body, truncated to 80 characters with an ellipsis appended."
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "href": {
                      "type": "string",
                      "description": "Deep link into the thread on /inbox."
                    }
                  }
                }
              },
              "seenAt": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "The bell's own seen/unseen cursor (see above)."
              }
            }
          }
        }
      }
    },
    "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/notifications

Mark the notification bell seen

Session cookie

Fired the instant the bell dropdown opens (NotificationDataProvider.tsx's markSeen()), not per item: sets one cursor, book_org_members.notifications_seen_at, to now for the caller's own row. Nothing is deleted or marked read by this alone: a message still needs its own read_at (PATCH /api/messages) to leave the Inbox nav badge, and that is deliberate, since the bell and the Inbox counter are independent by design (Notification System Requirements, 2026-09-07). No body, no id: always the caller's own book_org_members row via session user id, same self-only shape as PATCH /api/notification-prefs, and written through service-role rather than the RLS-scoped client for the same reason that route is: UPDATE on that table was revoked from authenticated after migration 0082 (docs/grants-and-rls.md).

Responses

200

The new cursor.

FieldType
ok*true
seenAt*string (date-time)
400

The update itself failed for a reason other than the migration being missing.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

501

Migration 0208 has not been applied to this database yet (notifications_seen_at column does not exist).

Raw OpenAPI operation
{
  "summary": "Mark the notification bell seen",
  "description": "Fired the instant the bell dropdown opens (`NotificationDataProvider.tsx`'s `markSeen()`), not per item: sets one cursor, `book_org_members.notifications_seen_at`, to now for the caller's own row. Nothing is deleted or marked read by this alone: a message still needs its own `read_at` (PATCH /api/messages) to leave the Inbox nav badge, and that is deliberate, since the bell and the Inbox counter are independent by design (Notification System Requirements, 2026-09-07). No body, no id: always the caller's own `book_org_members` row via session user id, same self-only shape as PATCH /api/notification-prefs, and written through service-role rather than the RLS-scoped client for the same reason that route is: UPDATE on that table was revoked from `authenticated` after migration 0082 (docs/grants-and-rls.md).",
  "tags": [
    "Shared"
  ],
  "operationId": "markNotificationsSeen",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The new cursor.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "seenAt"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "seenAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "The update itself failed for a reason other than the migration being missing.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "501": {
      "description": "Migration 0208 has not been applied to this database yet (`notifications_seen_at` column does not exist).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/booking-note

Write the private staff note on a booking or enquiry

Session cookie

Its own route rather than a field on any guest-facing handler, so there is no code path where a note can be written by the same request that renders something to a customer. Nothing guest-facing selects internal_note, and the guest-side loader names its columns explicitly so it cannot start doing so by accident.

An empty note clears the column rather than storing a blank string, so "has a note" stays a null check everywhere else.

This is the BOOKING-level note. The other level; the one that follows the person across every booking; is book_customers.notes, written through PATCH /api/clients/{id}.

Request body application/json

FieldTypeNotes
kind*"reservation" | "appointment"
bookingId*string (uuid)
notestring | null(max length 2000)

Responses

200

Saved.

FieldType
ok*true
400

An unknown kind, a missing booking id, or a note over 2000 characters.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No such booking in this org, or a malformed uuid.

Raw OpenAPI operation
{
  "summary": "Write the private staff note on a booking or enquiry",
  "description": "Its own route rather than a field on any guest-facing handler, so there is no code path where a note can be written by the same request that renders something to a customer. Nothing guest-facing selects `internal_note`, and the guest-side loader names its columns explicitly so it cannot start doing so by accident.\n\nAn empty note clears the column rather than storing a blank string, so \"has a note\" stays a null check everywhere else.\n\nThis is the BOOKING-level note. The other level; the one that follows the person across every booking; is `book_customers.notes`, written through PATCH /api/clients/{id}.",
  "tags": [
    "Shared"
  ],
  "operationId": "setBookingNote",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "kind",
            "bookingId"
          ],
          "properties": {
            "kind": {
              "type": "string",
              "enum": [
                "reservation",
                "appointment"
              ]
            },
            "bookingId": {
              "type": "string",
              "format": "uuid"
            },
            "note": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 2000
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown kind, a missing booking id, or a note over 2000 characters.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No such booking in this org, or a malformed uuid.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
put/api/intake

Create or replace an intake form (admin)

Session cookieadmin only

One form per target, where the target is either a single service or the whole org. serviceId: null means "every booking in this org", which is also what a table-reservations org always uses; it has no services. Two partial unique indexes in migration 0024 make that unambiguous, and this route upserts against them by reading first: PostgREST's onConflict emits a bare ON CONFLICT (company_id, service_id), which Postgres cannot infer against an index carrying a WHERE clause.

Written with the caller's own RLS-scoped client, NOT service-role. Unlike book_notification_templates, 0024 gives book_intake_forms a plain tenant policy, so RLS plus the JWT company_id claim already is the boundary. The composite foreign key means a serviceId belonging to another org is refused by the database rather than by route code, and surfaces as a 400.

There is no DELETE, deliberately. book_intake_responses references the form on delete cascade, so dropping a form would take every answer any guest ever gave with it, and a submitted intake is a record. active: false is the retire switch and it is part of this body.

Shared by both engines and therefore NOT vertical-guarded: a form asks a guest questions, which is plumbing like customers and notifications, not booking logic.

Request body application/json

FieldTypeNotes
serviceIdstring | null (uuid)

The service this form belongs to, or null/absent for every booking in the org. Must be a service of the CALLER's org; the composite FK from 0024 refuses anything else.

title*string(min length 1, max length 120)

The heading a guest sees above the questions.

descriptionstring | null(max length 500)

Why you are asking. Optional, and the thing that stops a form reading as a data grab.

activeboolean(default true)

False hides it from guests without touching answers already given. Omitting it means true.

fields*object[](min items 1, max items 30)

The questions, in the order a guest sees them. Re-validated here against the same rules as book_intake_fields_valid, plus one the CHECK cannot express: two questions may not share a key, because answers are stored under it and a collision makes one question unanswerable.

fields[].keystring(pattern ^[a-z][a-z0-9_]{0,39}$)

Stable storage key. Derived from the label when omitted. Answers are filed under it, so changing it on a live question strands every answer already given, which is exactly why a key exists rather than keying on the label.

fields[].label*string(min length 1, max length 160)
fields[].type*"text" | "textarea" | "select" | "checkbox"
fields[].requiredboolean(default false)

On a checkbox this means "must be ticked", i.e. a consent box.

fields[].optionsstring[](min items 1, max items 20)

Required for select. Blank entries are dropped (the builder uses one to mean "being typed"), duplicates are a 400. Ignored and stripped for every other type, rather than refused; a question someone has just switched to free text legitimately still carries its old choices.

Responses

200

Saved.

FieldTypeNotes
ok*true
id*string (uuid)

The form row, whether it was created or updated.

400

The form failed validation and the message says which question and why, or the serviceId does not belong to this org, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

Digital intake forms are Pro+ (appointments) / Custom-plan (venue) only (src/lib/plan.ts, intake_forms).

403

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

409

Two admins saved the same target at once and the partial unique index caught the loser.

Raw OpenAPI operation
{
  "summary": "Create or replace an intake form (admin)",
  "description": "One form per target, where the target is either a single service or the whole org. `serviceId: null` means \"every booking in this org\", which is also what a table-reservations org always uses; it has no services. Two *partial* unique indexes in migration 0024 make that unambiguous, and this route upserts against them by reading first: PostgREST's `onConflict` emits a bare `ON CONFLICT (company_id, service_id)`, which Postgres cannot infer against an index carrying a WHERE clause.\n\nWritten with the caller's own RLS-scoped client, NOT service-role. Unlike `book_notification_templates`, 0024 gives `book_intake_forms` a plain tenant policy, so RLS plus the JWT `company_id` claim already is the boundary. The composite foreign key means a `serviceId` belonging to another org is refused by the database rather than by route code, and surfaces as a 400.\n\n**There is no DELETE, deliberately.** `book_intake_responses` references the form `on delete cascade`, so dropping a form would take every answer any guest ever gave with it, and a submitted intake is a record. `active: false` is the retire switch and it is part of this body.\n\nShared by both engines and therefore NOT vertical-guarded: a form asks a guest questions, which is plumbing like customers and notifications, not booking logic.",
  "tags": [
    "Shared"
  ],
  "operationId": "putIntakeForm",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "title",
            "fields"
          ],
          "properties": {
            "serviceId": {
              "type": [
                "string",
                "null"
              ],
              "format": "uuid",
              "description": "The service this form belongs to, or null/absent for every booking in the org. Must be a service of the CALLER's org; the composite FK from 0024 refuses anything else."
            },
            "title": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120,
              "description": "The heading a guest sees above the questions."
            },
            "description": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 500,
              "description": "Why you are asking. Optional, and the thing that stops a form reading as a data grab."
            },
            "active": {
              "type": "boolean",
              "default": true,
              "description": "False hides it from guests without touching answers already given. Omitting it means true."
            },
            "fields": {
              "type": "array",
              "minItems": 1,
              "maxItems": 30,
              "description": "The questions, in the order a guest sees them. Re-validated here against the same rules as `book_intake_fields_valid`, plus one the CHECK cannot express: two questions may not share a key, because answers are stored under it and a collision makes one question unanswerable.",
              "items": {
                "type": "object",
                "required": [
                  "label",
                  "type"
                ],
                "properties": {
                  "key": {
                    "type": "string",
                    "pattern": "^[a-z][a-z0-9_]{0,39}$",
                    "description": "Stable storage key. Derived from the label when omitted. Answers are filed under it, so changing it on a live question strands every answer already given, which is exactly why a key exists rather than keying on the label."
                  },
                  "label": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 160
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "text",
                      "textarea",
                      "select",
                      "checkbox"
                    ]
                  },
                  "required": {
                    "type": "boolean",
                    "default": false,
                    "description": "On a checkbox this means \"must be ticked\", i.e. a consent box."
                  },
                  "options": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "maxLength": 80
                    },
                    "minItems": 1,
                    "maxItems": 20,
                    "description": "Required for `select`. Blank entries are dropped (the builder uses one to mean \"being typed\"), duplicates are a 400. Ignored and stripped for every other type, rather than refused; a question someone has just switched to free text legitimately still carries its old choices."
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "id"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "id": {
                "type": "string",
                "format": "uuid",
                "description": "The form row, whether it was created or updated."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "The form failed validation and the message says which question and why, or the `serviceId` does not belong to this org, 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"
          }
        }
      }
    },
    "402": {
      "description": "Digital intake forms are Pro+ (appointments) / Custom-plan (venue) only (src/lib/plan.ts, `intake_forms`).",
      "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"
          }
        }
      }
    },
    "409": {
      "description": "Two admins saved the same target at once and the partial unique index caught the loser.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/booking-policy

Set the guest self-service policy (admin, or manage_org_settings)

Session cookie

Deliberately NOT folded into PATCH /api/companies: these two columns sit outside migration 0022's tenant column grant and are written with service-role, while most of /api/companies is tenant-writable. A true partial update, same has() discipline as /api/companies: only a key actually present in the body is written, and at least one of the two must be sent or the whole request is a 400. Gated to admin, or a staff login granted manage_org_settings (migration 0082): this decides what every customer of the org may do to their own booking without asking staff, which is an org-settings decision rather than front-of-house work.

Request body application/json

FieldTypeNotes
guestManageEnabledboolean

Whether guests may change or cancel their own bookings from the link in their confirmation email.

guestManageCutoffHoursinteger(min 0, max 720)

Notice period. A guest cannot change or cancel inside this many hours of the booking, and cannot reschedule INTO it either.

Responses

200

Saved.

FieldType
ok*true
400

Neither field was sent, the cutoff is not a whole number of hours between 0 and 720, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Set the guest self-service policy (admin, or manage_org_settings)",
  "description": "Deliberately NOT folded into PATCH /api/companies: these two columns sit outside migration 0022's tenant column grant and are written with service-role, while most of /api/companies is tenant-writable. A true partial update, same `has()` discipline as /api/companies: only a key actually present in the body is written, and at least one of the two must be sent or the whole request is a 400. Gated to admin, or a staff login granted `manage_org_settings` (migration 0082): this decides what every customer of the org may do to their own booking without asking staff, which is an org-settings decision rather than front-of-house work.",
  "tags": [
    "Shared"
  ],
  "operationId": "setBookingPolicy",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "minProperties": 1,
          "properties": {
            "guestManageEnabled": {
              "type": "boolean",
              "description": "Whether guests may change or cancel their own bookings from the link in their confirmation email."
            },
            "guestManageCutoffHours": {
              "type": "integer",
              "minimum": 0,
              "maximum": 720,
              "description": "Notice period. A guest cannot change or cancel inside this many hours of the booking, and cannot reschedule INTO it either."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Neither field was sent, the cutoff is not a whole number of hours between 0 and 720, 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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/marketing-consent

Attest or withdraw the pre-existing-list marketing declaration (admin, or manage_org_settings)

Session cookie

Writes book_companies' marketing_attested_at/marketing_attested_by/marketing_attested_text (migration 0143). Deliberately its own route, not folded into PATCH /api/companies or PATCH /api/booking-policy: these three columns carry NO grant to authenticated at all, being a legal declaration about permission to contact other people, so a column grant would let any staff login mint or withdraw it via raw PostgREST (0143's own comment on that column group). Written with service-role, past this route's own admin gate, the same posture booking-policy.ts already uses for its own ungranted columns.

{ action: "attest" } sets marketing_attested_at to now, marketing_attested_by to the caller's user id, and snapshots MARKETING_ATTESTATION_TEXT (consent.ts) into marketing_attested_text verbatim, so the string an admin agreed to and the string kept on file are the same value by construction. { action: "withdraw" } clears marketing_attested_at back to null and leaves marketing_attested_by/marketing_attested_text alone as a record of the last declaration made: the audience rule (canReceiveMarketing, consent.ts) is time-scoped by attested_at alone, so this one flip instantly narrows the emailable audience back to explicit opt-ins for every customer at once, with nothing to backfill on the book_customers side.

The widget-visibility toggle (booking_marketing_optin_enabled) is a SEPARATE column with its own grant to authenticated, and goes through the ordinary PATCH /api/companies save instead, since it is a display setting, not itself a consent record, so it does not belong on this route.

Request body application/json

FieldTypeNotes
action*"attest" | "withdraw"

attest records the declaration now, snapshotting the current MARKETING_ATTESTATION_TEXT. withdraw clears it, instantly narrowing the emailable audience back to explicit opt-ins only.

Responses

200

Saved.

FieldType
ok*true
400

action is missing or is neither "attest" nor "withdraw", or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Attest or withdraw the pre-existing-list marketing declaration (admin, or manage_org_settings)",
  "description": "Writes book_companies' marketing_attested_at/marketing_attested_by/marketing_attested_text (migration 0143). Deliberately its own route, not folded into PATCH /api/companies or PATCH /api/booking-policy: these three columns carry NO grant to `authenticated` at all, being a legal declaration about permission to contact other people, so a column grant would let any staff login mint or withdraw it via raw PostgREST (0143's own comment on that column group). Written with service-role, past this route's own admin gate, the same posture booking-policy.ts already uses for its own ungranted columns.\n\n`{ action: \"attest\" }` sets `marketing_attested_at` to now, `marketing_attested_by` to the caller's user id, and snapshots `MARKETING_ATTESTATION_TEXT` (consent.ts) into `marketing_attested_text` verbatim, so the string an admin agreed to and the string kept on file are the same value by construction. `{ action: \"withdraw\" }` clears `marketing_attested_at` back to null and leaves `marketing_attested_by`/`marketing_attested_text` alone as a record of the last declaration made: the audience rule (`canReceiveMarketing`, consent.ts) is time-scoped by `attested_at` alone, so this one flip instantly narrows the emailable audience back to explicit opt-ins for every customer at once, with nothing to backfill on the book_customers side.\n\nThe widget-visibility toggle (`booking_marketing_optin_enabled`) is a SEPARATE column with its own grant to `authenticated`, and goes through the ordinary PATCH /api/companies save instead, since it is a display setting, not itself a consent record, so it does not belong on this route.",
  "tags": [
    "Shared"
  ],
  "operationId": "setMarketingConsent",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "action"
          ],
          "properties": {
            "action": {
              "type": "string",
              "enum": [
                "attest",
                "withdraw"
              ],
              "description": "`attest` records the declaration now, snapshotting the current MARKETING_ATTESTATION_TEXT. `withdraw` clears it, instantly narrowing the emailable audience back to explicit opt-ins only."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`action` is missing or is neither \"attest\" nor \"withdraw\", 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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/notification-templates

Read notification templates

Session cookie

The only GET among the session-authenticated routes; everything else the dashboard reads comes through server components, not the API. Returns the stock templates alongside the org's overrides so the editor can show both and offer "revert to default" without a second source of truth on the client.

Responses

200

The template set for this org.

FieldTypeNotes
defaults*object[]

The stock templates shipped in code, one per (type, channel).

overrides*object[]

This org's customised slots. Absent slots fall back to the default.

overrides[].type"confirmation" | "reminder" | "cancellation"
overrides[].channel"email" | "sms"
overrides[].subjectstring | null
overrides[].bodystring
overrides[].updated_atstring (date-time)
variables*object[]

The {{variable}} names a template may use, with descriptions.

canCustomize*boolean

Whether the org's plan includes custom templates. Cosmetic here; PUT re-checks server-side.

canEdit*boolean

Whether the CALLER is an admin. Cosmetic likewise.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Read notification templates",
  "description": "The only GET among the session-authenticated routes; everything else the dashboard reads comes through server components, not the API. Returns the stock templates alongside the org's overrides so the editor can show both and offer \"revert to default\" without a second source of truth on the client.",
  "tags": [
    "Shared"
  ],
  "operationId": "getNotificationTemplates",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The template set for this org.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "defaults",
              "overrides",
              "variables",
              "canCustomize",
              "canEdit"
            ],
            "properties": {
              "defaults": {
                "type": "array",
                "description": "The stock templates shipped in code, one per (type, channel).",
                "items": {
                  "type": "object"
                }
              },
              "overrides": {
                "type": "array",
                "description": "This org's customised slots. Absent slots fall back to the default.",
                "items": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": "string",
                      "enum": [
                        "confirmation",
                        "reminder",
                        "cancellation"
                      ]
                    },
                    "channel": {
                      "type": "string",
                      "enum": [
                        "email",
                        "sms"
                      ]
                    },
                    "subject": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "body": {
                      "type": "string"
                    },
                    "updated_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              },
              "variables": {
                "type": "array",
                "description": "The `{{variable}}` names a template may use, with descriptions.",
                "items": {
                  "type": "object"
                }
              },
              "canCustomize": {
                "type": "boolean",
                "description": "Whether the org's plan includes custom templates. Cosmetic here; PUT re-checks server-side."
              },
              "canEdit": {
                "type": "boolean",
                "description": "Whether the CALLER is an admin. Cosmetic likewise."
              }
            }
          }
        }
      }
    },
    "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
put/api/notification-templates

Customise one template slot (admin, paid)

Session cookieadmin only

Upserts one (type, channel) slot. Written with service-role because the table has no INSERT/UPDATE policy for authenticated at all; this route is the plan gate, so it has to be the only writer.

Unknown {{variables}} are rejected at the write boundary rather than degrading to a blank word at render time. That is the difference between "your template is wrong" and "your guests got a broken email".

Request body application/json

FieldTypeNotes
type*"confirmation" | "reminder" | "cancellation"
channel*"email" | "sms"
subjectstring | null(max length 200)

Required for email, ignored for sms (which has no subject).

body*string(min length 1)

Max 4000 characters for email, 400 for SMS; the SMS cap is a cost control, since SMS bills per 160-character segment.

Responses

200

Saved.

FieldType
ok*true
400

An unknown type/channel, an empty or over-length body, a missing email subject, or an unknown {{variable}} (the message names which).

401

No valid session cookie. {"error":"Not signed in"}.

402

The org's plan does not include custom templates. Fails closed: no billing row, an unrecognised tier, or a read error all resolve to no access.

403

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

Raw OpenAPI operation
{
  "summary": "Customise one template slot (admin, paid)",
  "description": "Upserts one (type, channel) slot. Written with service-role because the table has no INSERT/UPDATE policy for `authenticated` at all; this route is the plan gate, so it has to be the only writer.\n\nUnknown `{{variables}}` are rejected at the write boundary rather than degrading to a blank word at render time. That is the difference between \"your template is wrong\" and \"your guests got a broken email\".",
  "tags": [
    "Shared"
  ],
  "operationId": "putNotificationTemplate",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "type",
            "channel",
            "body"
          ],
          "properties": {
            "type": {
              "type": "string",
              "enum": [
                "confirmation",
                "reminder",
                "cancellation"
              ]
            },
            "channel": {
              "type": "string",
              "enum": [
                "email",
                "sms"
              ]
            },
            "subject": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 200,
              "description": "Required for `email`, ignored for `sms` (which has no subject)."
            },
            "body": {
              "type": "string",
              "minLength": 1,
              "description": "Max 4000 characters for email, 400 for SMS; the SMS cap is a cost control, since SMS bills per 160-character segment."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown type/channel, an empty or over-length body, a missing email subject, or an unknown `{{variable}}` (the message names which).",
      "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 org's plan does not include custom templates. Fails closed: no billing row, an unrecognised tier, or a read error all resolve to no access.",
      "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"
          }
        }
      }
    }
  }
}
delete/api/notification-templates

Revert one template slot to the default (admin)

Session cookieadmin only

Deletes the override row. Deliberately NOT plan-gated: an org that has downgraded must always be able to get back to the stock templates.

Request body application/json

FieldType
type*"confirmation" | "reminder" | "cancellation"
channel*"email" | "sms"

Responses

200

Reverted. Also returned when there was no override to remove.

FieldType
ok*true
400

An unknown type or channel, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Revert one template slot to the default (admin)",
  "description": "Deletes the override row. Deliberately NOT plan-gated: an org that has downgraded must always be able to get back to the stock templates.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteNotificationTemplate",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "type",
            "channel"
          ],
          "properties": {
            "type": {
              "type": "string",
              "enum": [
                "confirmation",
                "reminder",
                "cancellation"
              ]
            },
            "channel": {
              "type": "string",
              "enum": [
                "email",
                "sms"
              ]
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Reverted. Also returned when there was no override to remove.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "An unknown type or channel, 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": "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"
          }
        }
      }
    }
  }
}
get/api/marketing/campaigns

List this organization's campaigns

Session cookie

History for the Campaigns hub: up to 50 most recent, newest first, with a live reach/sent count per row computed against book_campaign_recipients rather than a stored counter. Any signed-in member may read this; only manage_org_settings may create, edit or send one (see the other operations on this path and on /api/marketing/campaigns/{id}).

Responses

200

The campaign list.

FieldType
campaigns*object[]
400

A Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan (2026-08-24: enterprise tier only, pending a pricing decision).

403

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

Raw OpenAPI operation
{
  "summary": "List this organization's campaigns",
  "description": "History for the Campaigns hub: up to 50 most recent, newest first, with a live `reach`/`sent` count per row computed against book_campaign_recipients rather than a stored counter. Any signed-in member may read this; only manage_org_settings may create, edit or send one (see the other operations on this path and on /api/marketing/campaigns/{id}).",
  "tags": [
    "Shared"
  ],
  "operationId": "listCampaigns",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The campaign list.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "campaigns"
            ],
            "properties": {
              "campaigns": {
                "type": "array",
                "items": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan (2026-08-24: `enterprise` tier only, pending a pricing decision).",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/campaigns

Start a new campaign draft (admin, or manage_org_settings)

Session cookie

Inserts an empty book_campaigns row (every column already carries its own DB default, see migration 0144) and returns its id. The client navigates to /dashboard/marketing/campaigns/{id} on success; deliberately not a page-level redirect, since a GET-rendered page performing this insert would fire on Next.js Link prefetch.

Responses

200

Created.

FieldType
id*string (uuid)
400

A Postgres error on insert.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Start a new campaign draft (admin, or manage_org_settings)",
  "description": "Inserts an empty `book_campaigns` row (every column already carries its own DB default, see migration 0144) and returns its id. The client navigates to /dashboard/marketing/campaigns/{id} on success; deliberately not a page-level redirect, since a GET-rendered page performing this insert would fire on Next.js Link prefetch.",
  "tags": [
    "Shared"
  ],
  "operationId": "createCampaign",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "Created.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A Postgres error on insert.",
      "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": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/marketing/campaigns/{id}

Autosave a campaign draft (admin, or manage_org_settings)

Session cookie

Accepts a PARTIAL object: only the keys present are validated and written, so one keystroke never re-sends every field. .eq('status','draft') is a second WHERE clause, not a separate read-then-write: a campaign that started sending between page load and this autosave firing must not have its content rewritten out from under the send in flight. 409 is deliberately ambiguous between "no such campaign for this company" and "it already left draft"; the compose page's own status banner is what actually tells the operator which one happened.

Parameters

  • id*pathstring

    Campaign id.

Request body application/json

FieldTypeNotes
subjectstring(max length 300)
replyTostring | null (email)
headlinestring | null(max length 300)
bodystring(max length 20000)
ctaMode"booking_page" | "custom_link" | null
ctaLabelstring | null(max length 60)
ctaUrlstring | null(max length 2048)
audienceKind"segment" | "tag"
audienceValuestring(max length 60)

Responses

200

Saved.

FieldType
ok*true
400

No recognised key present, a field fails its own validation (subject/headline/body too long, reply-to not email-shaped, an unknown ctaMode/audienceKind), or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Malformed uuid (PostgREST 22P02).

409

No campaign matched company + id + status='draft': either it does not exist for this company, or it already left the draft status.

Raw OpenAPI operation
{
  "summary": "Autosave a campaign draft (admin, or manage_org_settings)",
  "description": "Accepts a PARTIAL object: only the keys present are validated and written, so one keystroke never re-sends every field. `.eq('status','draft')` is a second WHERE clause, not a separate read-then-write: a campaign that started sending between page load and this autosave firing must not have its content rewritten out from under the send in flight. 409 is deliberately ambiguous between \"no such campaign for this company\" and \"it already left draft\"; the compose page's own status banner is what actually tells the operator which one happened.",
  "tags": [
    "Shared"
  ],
  "operationId": "patchCampaign",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "subject": {
              "type": "string",
              "maxLength": 300
            },
            "replyTo": {
              "type": [
                "string",
                "null"
              ],
              "format": "email"
            },
            "headline": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 300
            },
            "body": {
              "type": "string",
              "maxLength": 20000
            },
            "ctaMode": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "booking_page",
                "custom_link",
                null
              ]
            },
            "ctaLabel": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 60
            },
            "ctaUrl": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 2048
            },
            "audienceKind": {
              "type": "string",
              "enum": [
                "segment",
                "tag"
              ]
            },
            "audienceValue": {
              "type": "string",
              "maxLength": 60
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No recognised key present, a field fails its own validation (subject/headline/body too long, reply-to not email-shaped, an unknown ctaMode/audienceKind), 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Malformed uuid (PostgREST 22P02).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "No campaign matched company + id + status='draft': either it does not exist for this company, or it already left the draft status.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/marketing/campaigns/{id}

Delete a campaign draft (admin, or manage_org_settings)

Session cookie

Hard delete, and ONLY of a draft: a sent (or sending) campaign's book_campaign_recipients rows are the audit record of a consent-gated send, and on delete cascade (migration 0144) would take them with it. Same 409-is-ambiguous reasoning as the PATCH on this path.

Parameters

  • id*pathstring

    Campaign id.

Responses

200

Deleted.

FieldType
ok*true
400

A Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Malformed uuid (PostgREST 22P02).

409

Not found for this company, or not a draft.

Raw OpenAPI operation
{
  "summary": "Delete a campaign draft (admin, or manage_org_settings)",
  "description": "Hard delete, and ONLY of a draft: a sent (or sending) campaign's book_campaign_recipients rows are the audit record of a consent-gated send, and `on delete cascade` (migration 0144) would take them with it. Same 409-is-ambiguous reasoning as the PATCH on this path.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteCampaign",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id."
    }
  ],
  "responses": {
    "200": {
      "description": "Deleted.",
      "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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Malformed uuid (PostgREST 22P02).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Not found for this company, or not a draft.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/campaigns/{id}/photo

Upload a campaign photo (admin, or manage_org_settings)

Session cookie

Same upload/remove shape as POST /api/organization/logo, except the row write goes through service-role rather than the RLS-scoped client: book_campaigns has no grant to authenticated at all (see PATCH /api/marketing/campaigns/{id}'s own header). Draft-only, same as the PATCH/DELETE on the parent path.

Parameters

  • id*pathstring

    Campaign id.

Request body multipart/form-data

The campaign photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
photoUrl*string (uri)
400

No file, wrong MIME type, over 2 MB, or a storage/row error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Malformed uuid, or no draft campaign with that id for this company.

Raw OpenAPI operation
{
  "summary": "Upload a campaign photo (admin, or manage_org_settings)",
  "description": "Same upload/remove shape as POST /api/organization/logo, except the row write goes through service-role rather than the RLS-scoped client: book_campaigns has no grant to `authenticated` at all (see PATCH /api/marketing/campaigns/{id}'s own header). Draft-only, same as the PATCH/DELETE on the parent path.",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadCampaignPhoto",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign 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 campaign photo. Sent as multipart/form-data under the field name `file`."
  },
  "responses": {
    "200": {
      "description": "Uploaded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "photoUrl"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "photoUrl": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, wrong MIME type, over 2 MB, or a storage/row 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": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Malformed uuid, or no draft campaign with that id for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/marketing/campaigns/{id}/photo

Remove a campaign photo (admin, or manage_org_settings)

Session cookie

Deletes the object and nulls photo_url. Draft-only. Takes no body.

Parameters

  • id*pathstring

    Campaign id.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Malformed uuid (PostgREST 22P02), or no draft campaign with that id for this company.

Raw OpenAPI operation
{
  "summary": "Remove a campaign photo (admin, or manage_org_settings)",
  "description": "Deletes the object and nulls `photo_url`. Draft-only. Takes no body.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteCampaignPhoto",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Malformed uuid (PostgREST 22P02), or no draft campaign with that id for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/marketing/campaigns/{id}/audience-count

Live "will reach N" count for a segment or tag

Session cookie

The compose screen's live audience preview, debounced against IN-MEMORY form state rather than the saved draft: it has to answer before autosave persists. {id} is not read; kept only for URL consistency with the rest of this feature's nested routes, since the count depends purely on company + kind + value. The emailable/consent filter (applyMarketingAudienceFilter, migration 0143) is always applied on top, same as the clients page's own emailable chip.

Parameters

  • id*pathstring

    Campaign id (unused by the handler; see description).

  • kind*query"segment" | "tag"

    Which audience-picker mode this count is for.

  • valuequerystringoptional

    A ClientSegment id when kind='segment', or a free-text tag when kind='tag'. An empty tag short-circuits to {reach:0} without a query.

Responses

200

The live count.

FieldTypeNotes
reach*integer(min 0)
400

kind is missing/invalid, or (for segment) value is not a real ClientSegment id, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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

Raw OpenAPI operation
{
  "summary": "Live \"will reach N\" count for a segment or tag",
  "description": "The compose screen's live audience preview, debounced against IN-MEMORY form state rather than the saved draft: it has to answer before autosave persists. `{id}` is not read; kept only for URL consistency with the rest of this feature's nested routes, since the count depends purely on company + kind + value. The emailable/consent filter (applyMarketingAudienceFilter, migration 0143) is always applied on top, same as the clients page's own `emailable` chip.",
  "tags": [
    "Shared"
  ],
  "operationId": "getCampaignAudienceCount",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id (unused by the handler; see description)."
    },
    {
      "name": "kind",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "enum": [
          "segment",
          "tag"
        ]
      },
      "description": "Which audience-picker mode this count is for."
    },
    {
      "name": "value",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string",
        "maxLength": 60
      },
      "description": "A ClientSegment id when kind='segment', or a free-text tag when kind='tag'. An empty tag short-circuits to `{reach:0}` without a query."
    }
  ],
  "responses": {
    "200": {
      "description": "The live count.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "reach"
            ],
            "properties": {
              "reach": {
                "type": "integer",
                "minimum": 0
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`kind` is missing/invalid, or (for `segment`) `value` is not a real ClientSegment id, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/campaigns/{id}/preview

Render a live email preview of a draft campaign

Session cookie

A server round trip, DELIBERATELY not a zero-round-trip client-side render the way TemplateStudio.tsx's own preview works: {{booking_url}} resolves through appBaseUrl() (booking/manage.ts), which reads a server-only environment variable Next.js never inlines into a client bundle. Draft fields are read from the REQUEST BODY, never the saved row, since the point is previewing what the operator has typed but not yet autosaved. Renders against a fixed sample customer name, the same "real renderer against a sample" approach TemplateStudio.tsx uses for its own booking preview.

Parameters

  • id*pathstring

    Campaign id (unused by the handler; the rendered company comes from the caller's own session).

Request body application/json

FieldType
subjectstring
headlinestring | null
bodystring
photoUrlstring | null
ctaMode"booking_page" | "custom_link" | null
ctaLabelstring | null
ctaUrlstring | null

Responses

200

The rendered preview.

FieldType
subject*string
html*string
400

Missing/invalid request body, or the company row could not be read.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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

Raw OpenAPI operation
{
  "summary": "Render a live email preview of a draft campaign",
  "description": "A server round trip, DELIBERATELY not a zero-round-trip client-side render the way TemplateStudio.tsx's own preview works: {{booking_url}} resolves through appBaseUrl() (booking/manage.ts), which reads a server-only environment variable Next.js never inlines into a client bundle. Draft fields are read from the REQUEST BODY, never the saved row, since the point is previewing what the operator has typed but not yet autosaved. Renders against a fixed sample customer name, the same \"real renderer against a sample\" approach TemplateStudio.tsx uses for its own booking preview.",
  "tags": [
    "Shared"
  ],
  "operationId": "previewCampaign",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id (unused by the handler; the rendered company comes from the caller's own session)."
    }
  ],
  "requestBody": {
    "required": false,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "subject": {
              "type": "string"
            },
            "headline": {
              "type": [
                "string",
                "null"
              ]
            },
            "body": {
              "type": "string"
            },
            "photoUrl": {
              "type": [
                "string",
                "null"
              ]
            },
            "ctaMode": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "booking_page",
                "custom_link",
                null
              ]
            },
            "ctaLabel": {
              "type": [
                "string",
                "null"
              ]
            },
            "ctaUrl": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The rendered preview.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "subject",
              "html"
            ],
            "properties": {
              "subject": {
                "type": "string"
              },
              "html": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Missing/invalid request body, or the company row could not be read.",
      "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": "`marketing` is not held by this company's current plan.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/campaigns/{id}/send

Send a campaign for real (admin, or manage_org_settings)

Session cookie

Re-checks every client-side checklist item server-side: never trust the client gate alone. Order matters: (1) validate and compute the audience READ-ONLY, (2) flip draft -> sending ATOMICALLY (.eq('status','draft') is what makes a double-click or a retry produce a 409 instead of a second materialization), (3) materialize book_campaign_recipients in 200-row chunks, (4) enqueue the first campaign_chunk job. If step 4's enqueue fails the response is still 200 with a warning field; the campaigns list route's own read self-heals a stuck send by re-enqueueing, the same "swept when somebody reads the job list" idiom flagStalledJobs already establishes for data jobs.

Parameters

  • id*pathstring

    Campaign id.

Responses

200

Sending started (or, per the warning field, started but the first chunk could not be scheduled yet).

FieldType
ok*true
reachinteger
warningstring
400

A Postgres read error, a failed checklist item (empty subject/body, a reply-to that is not email-shaped, a CTA missing its label or, for a custom link, a valid https:// URL, no audience chosen), or a zero-reach audience.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Campaign not found for this company.

409

The campaign is not status='draft': already sent, already sending, or lost the atomic lock race to a concurrent request.

500

The atomic lock succeeded (the campaign is now sending) but the chunked recipient insert failed partway; reported rather than rolled back, since undoing the status flip here would race the exact double-send class this design exists to prevent.

Raw OpenAPI operation
{
  "summary": "Send a campaign for real (admin, or manage_org_settings)",
  "description": "Re-checks every client-side checklist item server-side: never trust the client gate alone. Order matters: (1) validate and compute the audience READ-ONLY, (2) flip draft -> sending ATOMICALLY (`.eq('status','draft')` is what makes a double-click or a retry produce a 409 instead of a second materialization), (3) materialize book_campaign_recipients in 200-row chunks, (4) enqueue the first `campaign_chunk` job. If step 4's enqueue fails the response is still 200 with a `warning` field; the campaigns list route's own read self-heals a stuck send by re-enqueueing, the same \"swept when somebody reads the job list\" idiom flagStalledJobs already establishes for data jobs.",
  "tags": [
    "Shared"
  ],
  "operationId": "sendCampaign",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id."
    }
  ],
  "responses": {
    "200": {
      "description": "Sending started (or, per the warning field, started but the first chunk could not be scheduled yet).",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "reach": {
                "type": "integer"
              },
              "warning": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A Postgres read error, a failed checklist item (empty subject/body, a reply-to that is not email-shaped, a CTA missing its label or, for a custom link, a valid https:// URL, no audience chosen), or a zero-reach audience.",
      "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": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Campaign not found for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "The campaign is not `status='draft'`: already sent, already sending, or lost the atomic lock race to a concurrent request.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The atomic lock succeeded (the campaign is now `sending`) but the chunked recipient insert failed partway; reported rather than rolled back, since undoing the status flip here would race the exact double-send class this design exists to prevent.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/campaigns/{id}/test

Send a test of a campaign to yourself (admin, or manage_org_settings)

Session cookie

Sends to the SIGNED-IN ADMIN'S OWN ACCOUNT EMAIL (user.email, never a book_customers lookup) via sendEmail() directly: not meteredSendEmail, not sendMarketingEmail, deliberately bypassing the consent gate and the usage ledger. [TEST] subject prefix; no List-Unsubscribe header and no unsubscribe footer line, same as every transactional email. No status restriction: useful before, during or after a real send.

Parameters

  • id*pathstring

    Campaign id.

Responses

200

Sent.

FieldType
ok*true
to*string (email)
400

The signed-in account has no email on file, the campaign has no subject/body yet, or a Postgres read error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Campaign not found for this company.

502

Resend refused or could not be reached.

Raw OpenAPI operation
{
  "summary": "Send a test of a campaign to yourself (admin, or manage_org_settings)",
  "description": "Sends to the SIGNED-IN ADMIN'S OWN ACCOUNT EMAIL (`user.email`, never a book_customers lookup) via sendEmail() directly: not meteredSendEmail, not sendMarketingEmail, deliberately bypassing the consent gate and the usage ledger. `[TEST] ` subject prefix; no `List-Unsubscribe` header and no unsubscribe footer line, same as every transactional email. No status restriction: useful before, during or after a real send.",
  "tags": [
    "Shared"
  ],
  "operationId": "sendCampaignTest",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Campaign id."
    }
  ],
  "responses": {
    "200": {
      "description": "Sent.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "to"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "to": {
                "type": "string",
                "format": "email"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "The signed-in account has no email on file, the campaign has no subject/body yet, or a Postgres read 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": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Campaign not found for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "Resend refused or could not be reached.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/gift-cards

Issue a gift card (admin, or manage_org_settings)

Session cookie

v1: desk-issued only (migration 0162). No online sale and no close-out dialog wiring yet; see that migration's own header for the full staged plan. Generates an 8-character code (src/lib/marketing/gift-cards/codes.ts), retrying up to 3 times on the rare per-company collision (book_gift_cards_company_code). Gated the same permission Campaigns' own draft-create route uses: issuing a card is a manager-level decision to put real value on the books, not a front-desk errand.

Request body application/json

FieldTypeNotes
amountCents*integer(min 1)

Smallest currency unit. Capped at $2,000 as a typo guard, not a business rule.

recipientNamestring(max length 120)
recipientEmailstring (email)
messagestring(max length 500)
purchaserNamestring(max length 120)
expiresAtstring (date-time)

Must be strictly in the future. Omit for a card that never expires.

currencystring

Defaults to the company's own currency (book_companies.currency) when omitted.

Responses

200

Issued.

FieldType
giftCard*object
400

amountCents missing, non-positive or over the cap, recipientEmail not email-shaped, expiresAt not a future date, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

500

Could not generate a unique code after 3 attempts.

Raw OpenAPI operation
{
  "summary": "Issue a gift card (admin, or manage_org_settings)",
  "description": "v1: desk-issued only (migration 0162). No online sale and no close-out dialog wiring yet; see that migration's own header for the full staged plan. Generates an 8-character code (src/lib/marketing/gift-cards/codes.ts), retrying up to 3 times on the rare per-company collision (book_gift_cards_company_code). Gated the same permission Campaigns' own draft-create route uses: issuing a card is a manager-level decision to put real value on the books, not a front-desk errand.",
  "tags": [
    "Shared"
  ],
  "operationId": "issueGiftCard",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "amountCents"
          ],
          "properties": {
            "amountCents": {
              "type": "integer",
              "minimum": 1,
              "description": "Smallest currency unit. Capped at $2,000 as a typo guard, not a business rule."
            },
            "recipientName": {
              "type": "string",
              "maxLength": 120
            },
            "recipientEmail": {
              "type": "string",
              "format": "email"
            },
            "message": {
              "type": "string",
              "maxLength": 500
            },
            "purchaserName": {
              "type": "string",
              "maxLength": 120
            },
            "expiresAt": {
              "type": "string",
              "format": "date-time",
              "description": "Must be strictly in the future. Omit for a card that never expires."
            },
            "currency": {
              "type": "string",
              "description": "Defaults to the company's own currency (book_companies.currency) when omitted."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Issued.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "giftCard"
            ],
            "properties": {
              "giftCard": {
                "type": "object"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "amountCents missing, non-positive or over the cap, recipientEmail not email-shaped, expiresAt not a future date, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "Could not generate a unique code after 3 attempts.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/gift-cards/{id}/redeem

Record a gift card redemption

Session cookie

Loosened from admin-only to any signed-in member (2026-08-30): CloseOutDialog's own GiftCardRedeemField now calls this same route to apply a card at close-out, and CloseOutDialog's own PATCH (appointments/reservations) has no minRole, so this route's permission now matches the flow that actually takes payment, exactly as this route's own prior comment said it should once close-out wiring existed. Still reached standalone from the Gift Cards tab too. Atomically decrements balance_cents and writes an audit row via book_gift_card_redeem() (service_role only): PostgREST cannot express a balance decrement as one atomic expression, and a read-then-write from this route would race under concurrent redemptions.

Parameters

  • id*pathstring

    Gift card id.

Request body application/json

FieldTypeNotes
amountCents*integer(min 1)
notestring(max length 300)

Responses

200

Redeemed.

FieldType
id*string (uuid)
balanceCents*integer
status*"active" | "redeemed" | "void" | "expired"
400

amountCents missing/non-positive, more than the card's remaining balance, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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

404

Gift card not found for this company.

409

The card is not active (already redeemed, void or expired), or book_gift_card_redeem() lost a concurrent race against another redemption of the same card.

Raw OpenAPI operation
{
  "summary": "Record a gift card redemption",
  "description": "Loosened from admin-only to any signed-in member (2026-08-30): CloseOutDialog's own GiftCardRedeemField now calls this same route to apply a card at close-out, and CloseOutDialog's own PATCH (appointments/reservations) has no minRole, so this route's permission now matches the flow that actually takes payment, exactly as this route's own prior comment said it should once close-out wiring existed. Still reached standalone from the Gift Cards tab too. Atomically decrements `balance_cents` and writes an audit row via `book_gift_card_redeem()` (service_role only): PostgREST cannot express a balance decrement as one atomic expression, and a read-then-write from this route would race under concurrent redemptions.",
  "tags": [
    "Shared"
  ],
  "operationId": "redeemGiftCard",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Gift card id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "amountCents"
          ],
          "properties": {
            "amountCents": {
              "type": "integer",
              "minimum": 1
            },
            "note": {
              "type": "string",
              "maxLength": 300
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Redeemed.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "balanceCents",
              "status"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "balanceCents": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "redeemed",
                  "void",
                  "expired"
                ]
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "amountCents missing/non-positive, more than the card's remaining balance, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Gift card not found for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "The card is not active (already redeemed, void or expired), or book_gift_card_redeem() lost a concurrent race against another redemption of the same card.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/marketing/gift-cards/lookup

Resolve a gift card code to its id and balance

Session cookie

The lookup half of the redeem flow (2026-08-30, added alongside CloseOutDialog's GiftCardRedeemField): a staff member types a code, this resolves it to the card, and the redeem route above takes the id. Case/whitespace-insensitive. Scoped to the caller's own company: a code is unique per company, not globally (book_gift_cards_company_code, migration 0162), exactly because redemption always happens inside a company's own dashboard.

Parameters

  • code*querystring

    e.g. "K7H4-QX9M". Uppercased and trimmed before matching.

Responses

200

Found.

FieldType
id*string (uuid)
code*string
balanceCents*integer
status*"active" | "redeemed" | "void" | "expired"
currency*string
400

code missing/empty, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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

404

No gift card with that code for this company.

Raw OpenAPI operation
{
  "summary": "Resolve a gift card code to its id and balance",
  "description": "The lookup half of the redeem flow (2026-08-30, added alongside CloseOutDialog's GiftCardRedeemField): a staff member types a code, this resolves it to the card, and the redeem route above takes the id. Case/whitespace-insensitive. Scoped to the caller's own company: a code is unique per company, not globally (book_gift_cards_company_code, migration 0162), exactly because redemption always happens inside a company's own dashboard.",
  "tags": [
    "Shared"
  ],
  "operationId": "lookupGiftCard",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "code",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "e.g. \"K7H4-QX9M\". Uppercased and trimmed before matching."
    }
  ],
  "responses": {
    "200": {
      "description": "Found.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "code",
              "balanceCents",
              "status",
              "currency"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "code": {
                "type": "string"
              },
              "balanceCents": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "redeemed",
                  "void",
                  "expired"
                ]
              },
              "currency": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`code` missing/empty, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No gift card with that code for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/marketing/promotions

Create a promotion: an automatic sale or a promo code (admin, or manage_org_settings)

Session cookie

One route for both kinds (migration 0163): an automatic "sale" discounts the public appointments booking page with no code, a "code" is typed by the guest at checkout. Appointments only: reservations (hospitality) carry no priced total to discount. A "code" with no explicit code field generates one from name (src/lib/marketing/promotions/codes.ts), retrying up to 3 times on the rare per-company collision; an EXPLICIT code that collides is refused (409) rather than silently regenerated, since that collision is the venue's own typo to fix. Revalidates the public booking page cache (bookingConfigTag) on a 'sale' so the struck-through price appears without waiting the full 5-minute window.

Request body application/json

FieldTypeNotes
kind*"sale" | "code"
name*string(max length 120)
codestring(max length 24)

"code" kind only. Omit to auto-generate from name.

discountType*"percent" | "fixed"
discountPercentageinteger(min 1, max 100)
discountAmountCentsinteger(min 1)
serviceIdsstring (uuid)[]

Empty or omitted = every service.

minSpendCentsinteger(min 1)
startsAtstring (date-time)
endsAtstring (date-time)
usageLimitinteger(min 1)

"code" kind only.

oncePerCustomerboolean

"code" kind only.

newCustomersOnlyboolean

"code" kind only.

Responses

200

Created.

FieldType
promotion*object
400

Missing/invalid kind, name, discount fields, a service id not belonging to this company, endsAt before startsAt, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

409

An explicitly-supplied code is already in use by this company.

500

Could not generate a unique code after 3 attempts.

Raw OpenAPI operation
{
  "summary": "Create a promotion: an automatic sale or a promo code (admin, or manage_org_settings)",
  "description": "One route for both kinds (migration 0163): an automatic \"sale\" discounts the public appointments booking page with no code, a \"code\" is typed by the guest at checkout. Appointments only: reservations (hospitality) carry no priced total to discount. A \"code\" with no explicit `code` field generates one from `name` (src/lib/marketing/promotions/codes.ts), retrying up to 3 times on the rare per-company collision; an EXPLICIT code that collides is refused (409) rather than silently regenerated, since that collision is the venue's own typo to fix. Revalidates the public booking page cache (bookingConfigTag) on a 'sale' so the struck-through price appears without waiting the full 5-minute window.",
  "tags": [
    "Shared"
  ],
  "operationId": "createPromotion",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "kind",
            "name",
            "discountType"
          ],
          "properties": {
            "kind": {
              "type": "string",
              "enum": [
                "sale",
                "code"
              ]
            },
            "name": {
              "type": "string",
              "maxLength": 120
            },
            "code": {
              "type": "string",
              "maxLength": 24,
              "description": "\"code\" kind only. Omit to auto-generate from name."
            },
            "discountType": {
              "type": "string",
              "enum": [
                "percent",
                "fixed"
              ]
            },
            "discountPercentage": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "discountAmountCents": {
              "type": "integer",
              "minimum": 1
            },
            "serviceIds": {
              "type": "array",
              "items": {
                "type": "string",
                "format": "uuid"
              },
              "description": "Empty or omitted = every service."
            },
            "minSpendCents": {
              "type": "integer",
              "minimum": 1
            },
            "startsAt": {
              "type": "string",
              "format": "date-time"
            },
            "endsAt": {
              "type": "string",
              "format": "date-time"
            },
            "usageLimit": {
              "type": "integer",
              "minimum": 1,
              "description": "\"code\" kind only."
            },
            "oncePerCustomer": {
              "type": "boolean",
              "description": "\"code\" kind only."
            },
            "newCustomersOnly": {
              "type": "boolean",
              "description": "\"code\" kind only."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Created.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "promotion"
            ],
            "properties": {
              "promotion": {
                "type": "object"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Missing/invalid kind, name, discount fields, a service id not belonging to this company, endsAt before startsAt, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "An explicitly-supplied code is already in use by this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "Could not generate a unique code after 3 attempts.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/marketing/promotions/{id}

Enable or disable a promotion (admin, or manage_org_settings)

Session cookie

The one lifecycle action this v1 dashboard exposes: no delete, no field edits after creation. Revalidates the public booking page cache the same way POST does, so a disabled sale stops showing immediately rather than lagging the 5-minute window.

Parameters

  • id*pathstring

    Promotion id.

Request body application/json

FieldType
status*"active" | "disabled"

Responses

200

Updated.

FieldType
ok*true
status*"active" | "disabled"
400

status missing/invalid, or a Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

402

marketing is not held by this company's current plan.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

404

Promotion not found for this company, or malformed uuid (PostgREST 22P02).

Raw OpenAPI operation
{
  "summary": "Enable or disable a promotion (admin, or manage_org_settings)",
  "description": "The one lifecycle action this v1 dashboard exposes: no delete, no field edits after creation. Revalidates the public booking page cache the same way POST does, so a disabled sale stops showing immediately rather than lagging the 5-minute window.",
  "tags": [
    "Shared"
  ],
  "operationId": "togglePromotion",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "Promotion id."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "status"
          ],
          "properties": {
            "status": {
              "type": "string",
              "enum": [
                "active",
                "disabled"
              ]
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Updated.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "status"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "disabled"
                ]
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "status missing/invalid, 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"
          }
        }
      }
    },
    "402": {
      "description": "`marketing` is not held by this company's current plan.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "Promotion not found for this company, or malformed uuid (PostgREST 22P02).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/special-hours

Dates that do not follow the recurring week

Session cookie

Special hours (book_special_hours, migration 0109): what a business does on ONE date, instead of the weekly hours or service periods that otherwise repeat forever.

Both engines, unlike /api/organization/hours. "Closed on Christmas Day" and "open 10:00 to 14:00 on Boxing Day" are sentences a salon and a restaurant both need, and each engine composes them with its own maths: for appointments the windows replace book_company_hours for that date, for hospitality they clamp that date's book_service_periods (which keep their own name, turn time and covers cap) and extend them where the windows reach past anything the venue has defined.

Returns dates from the business's own today onward, in date order, capped at 400. from overrides the start. today comes back alongside, computed in book_companies.timezone rather than the server's, so a caller does not have to guess which day the venue is on. Readable by any member; only the writes are gated.

Parameters

  • fromquerystring (date)optional

    YYYY-MM-DD. Defaults to the business's own today.

Responses

200

The dates, earliest first.

FieldTypeNotes
today*string (date)

Today in the company's timezone.

dates*object[]
dates[].special_date*string (date)
dates[].windows*object[](max items 6)

Up to 6 windows. An empty array means CLOSED, which is the opposite of what an empty book_company_hours means; see the operation description.

dates[].windows[].startTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM.

dates[].windows[].endTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM. Earlier than startTime means the window closes the next day (migration 0042).

dates[].labelstring
dates[].holiday_namestring
400

from is not a YYYY-MM-DD date, or a Postgres error reading the rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Dates that do not follow the recurring week",
  "description": "Special hours (`book_special_hours`, migration 0109): what a business does on ONE date, instead of the weekly hours or service periods that otherwise repeat forever.\n\n**Both engines**, unlike `/api/organization/hours`. \"Closed on Christmas Day\" and \"open 10:00 to 14:00 on Boxing Day\" are sentences a salon and a restaurant both need, and each engine composes them with its own maths: for appointments the windows replace `book_company_hours` for that date, for hospitality they clamp that date's `book_service_periods` (which keep their own name, turn time and covers cap) and extend them where the windows reach past anything the venue has defined.\n\nReturns dates from the business's own today onward, in date order, capped at 400. `from` overrides the start. `today` comes back alongside, computed in `book_companies.timezone` rather than the server's, so a caller does not have to guess which day the venue is on. Readable by any member; only the writes are gated.",
  "tags": [
    "Shared"
  ],
  "operationId": "listSpecialHours",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "from",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string",
        "format": "date"
      },
      "description": "YYYY-MM-DD. Defaults to the business's own today."
    }
  ],
  "responses": {
    "200": {
      "description": "The dates, earliest first.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "today",
              "dates"
            ],
            "properties": {
              "today": {
                "type": "string",
                "format": "date",
                "description": "Today in the company's timezone."
              },
              "dates": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "special_date",
                    "windows"
                  ],
                  "properties": {
                    "special_date": {
                      "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. Earlier than `startTime` means the window closes the next day (migration 0042)."
                          }
                        }
                      },
                      "description": "Up to 6 windows. **An empty array means CLOSED**, which is the opposite of what an empty `book_company_hours` means; see the operation description."
                    },
                    "label": {
                      "type": "string",
                      "nullable": true
                    },
                    "holiday_name": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`from` is not a YYYY-MM-DD date, or 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": "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/special-hours

Set what happens on one date (admin, or manage_org_settings)

Session cookie

An upsert keyed on (company_id, special_date), so sending the same body twice is the same as sending it once.

An empty `windows` array means CLOSED, and it is the most common write this route takes. That is the opposite of what an empty book_company_hours means, and the two live inches apart: zero rows there means UNCLAMPED (every org is in that state until it saves hours), while here the row EXISTS, so [] is somebody deliberately saying "nothing". Closing a date is therefore NOT the same as DELETEing it, which returns the date to the ordinary week.

An endTime earlier than startTime runs past midnight (migration 0042) and is accepted: an 18:00 to 02:00 New Year's Eve is the single most likely row anyone writes here. Only a zero-length window is refused.

No replace-the-set function stands behind this, unlike the hours and service-period routes: one date is one row, so an upsert is already one statement and therefore one transaction. Gated to admin, or a staff login granted manage_org_settings (migration 0082).

Conflicts against existing, non-cancelled bookings on the date are checked live, unconditionally, the same findConflictingAppointments predicate POST /api/providers/{id}/availability-overrides already uses: does the booking fall inside one of the proposed windows. Reads book_reservations for a hospitality company and book_appointments for an appointments one, company-wide rather than per-provider, since this table has no provider column. A conflict refuses with 409 and nothing is written; resend with conflictAcknowledged: true to proceed anyway. Unlike the override route this never hard-blocks by role: every caller who can reach this route already holds manage_org_settings, so acknowledgment alone is the gate. The route never touches the conflicting bookings themselves: no auto-move, no auto-cancel, no auto-refund. Resolving each one is a separate, deliberate action from Bookings.

Request body application/json

FieldTypeNotes
date*string (date)

YYYY-MM-DD. Must be a real calendar date.

windowsobject[](max items 6)

Up to 6 windows. An empty array means CLOSED, which is the opposite of what an empty book_company_hours means; see the operation description.

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. Earlier than startTime means the window closes the next day (migration 0042).

labelstring(max length 120)

What the dashboard row is called. Staff-facing only.

holidayNamestring(max length 120)

Set when the row came from the public-holiday list, holding the holiday's name as the source gave it. Its only job is to let that list tell "you decided about this holiday" from "you added a date that happens to fall on one".

conflictAcknowledgedboolean

Proceed despite a previously-reported 409. Ignored (harmlessly) when there is nothing to acknowledge.

Responses

200

Saved. Echoes the stored row back.

FieldTypeNotes
ok*true
date*object
date.special_date*string (date)
date.windows*object[](max items 6)

Up to 6 windows. An empty array means CLOSED, which is the opposite of what an empty book_company_hours means; see the operation description.

date.windows[].startTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM.

date.windows[].endTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM. Earlier than startTime means the window closes the next day (migration 0042).

date.labelstring
date.holiday_namestring
400

Not a real calendar date, or a validation message from parseSpecialHoursWindows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

409

The proposed windows conflict with existing, non-cancelled bookings on that date. Body includes conflicts (each {id, starts_at, ends_at}). Resend with conflictAcknowledged: true to proceed.

500

The company's business_type could not be resolved, so this route refuses to guess whether the conflict check above ran against the right table.

Raw OpenAPI operation
{
  "summary": "Set what happens on one date (admin, or manage_org_settings)",
  "description": "An upsert keyed on `(company_id, special_date)`, so sending the same body twice is the same as sending it once.\n\n**An empty `windows` array means CLOSED, and it is the most common write this route takes.** That is the opposite of what an empty `book_company_hours` means, and the two live inches apart: zero rows there means UNCLAMPED (every org is in that state until it saves hours), while here the row EXISTS, so `[]` is somebody deliberately saying \"nothing\". Closing a date is therefore NOT the same as `DELETE`ing it, which returns the date to the ordinary week.\n\nAn `endTime` earlier than `startTime` runs past midnight (migration 0042) and is accepted: an 18:00 to 02:00 New Year's Eve is the single most likely row anyone writes here. Only a zero-length window is refused.\n\nNo replace-the-set function stands behind this, unlike the hours and service-period routes: one date is one row, so an upsert is already one statement and therefore one transaction. Gated to admin, or a staff login granted `manage_org_settings` (migration 0082).\n\n**Conflicts** against existing, non-cancelled bookings on the date are checked live, unconditionally, the same `findConflictingAppointments` predicate `POST /api/providers/{id}/availability-overrides` already uses: does the booking fall inside one of the proposed windows. Reads `book_reservations` for a hospitality company and `book_appointments` for an appointments one, company-wide rather than per-provider, since this table has no provider column. A conflict refuses with 409 and nothing is written; resend with `conflictAcknowledged: true` to proceed anyway. Unlike the override route this never hard-blocks by role: every caller who can reach this route already holds `manage_org_settings`, so acknowledgment alone is the gate. The route never touches the conflicting bookings themselves: no auto-move, no auto-cancel, no auto-refund. Resolving each one is a separate, deliberate action from Bookings.",
  "tags": [
    "Shared"
  ],
  "operationId": "setSpecialHours",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "date"
          ],
          "additionalProperties": false,
          "properties": {
            "date": {
              "type": "string",
              "format": "date",
              "description": "YYYY-MM-DD. Must be a real calendar 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. Earlier than `startTime` means the window closes the next day (migration 0042)."
                  }
                }
              },
              "description": "Up to 6 windows. **An empty array means CLOSED**, which is the opposite of what an empty `book_company_hours` means; see the operation description."
            },
            "label": {
              "type": "string",
              "maxLength": 120,
              "nullable": true,
              "description": "What the dashboard row is called. Staff-facing only."
            },
            "holidayName": {
              "type": "string",
              "maxLength": 120,
              "nullable": true,
              "description": "Set when the row came from the public-holiday list, holding the holiday's name as the source gave it. Its only job is to let that list tell \"you decided about this holiday\" from \"you added a date that happens to fall on one\"."
            },
            "conflictAcknowledged": {
              "type": "boolean",
              "description": "Proceed despite a previously-reported 409. Ignored (harmlessly) when there is nothing to acknowledge."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved. Echoes the stored row back.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "date"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "date": {
                "type": "object",
                "required": [
                  "special_date",
                  "windows"
                ],
                "properties": {
                  "special_date": {
                    "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. Earlier than `startTime` means the window closes the next day (migration 0042)."
                        }
                      }
                    },
                    "description": "Up to 6 windows. **An empty array means CLOSED**, which is the opposite of what an empty `book_company_hours` means; see the operation description."
                  },
                  "label": {
                    "type": "string",
                    "nullable": true
                  },
                  "holiday_name": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Not a real calendar date, or a validation message from `parseSpecialHoursWindows`.",
      "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "The proposed windows conflict with existing, non-cancelled bookings on that date. Body includes `conflicts` (each `{id, starts_at, ends_at}`). Resend with `conflictAcknowledged: true` to proceed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The company's business_type could not be resolved, so this route refuses to guess whether the conflict check above ran against the right table.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/organization/special-hours

Return a date to the recurring week (admin, or manage_org_settings)

Session cookie

Removes the row, so the date follows the ordinary weekly hours again. Emphatically not the same as saving it with no windows, which is how a business says it is shut. Deleting a date that was never set is not an error: the caller asked for this date to follow the ordinary week, and it now does.

Parameters

  • date*querystring (date)

    YYYY-MM-DD.

Responses

200

Removed, or already absent.

FieldType
ok*true
400

date is missing or is not a real calendar date.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Return a date to the recurring week (admin, or manage_org_settings)",
  "description": "Removes the row, so the date follows the ordinary weekly hours again. **Emphatically not the same as saving it with no windows**, which is how a business says it is shut. Deleting a date that was never set is not an error: the caller asked for this date to follow the ordinary week, and it now does.",
  "tags": [
    "Shared"
  ],
  "operationId": "clearSpecialHours",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "date",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "format": "date"
      },
      "description": "YYYY-MM-DD."
    }
  ],
  "responses": {
    "200": {
      "description": "Removed, or already absent.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`date` is missing or is not a real calendar 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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/no-show-fee-tiers

List this company's tiered no-show fees

Session cookie

Every row of book_no_show_fee_tiers (migration 0137) this company has configured, in threshold_hours order: what DepositsCard's tier editor renders and what GET /api/payments/no-show-fee resolves a suggestion from. Readable by any member; only the writes below are gated.

Responses

200

The tiers, threshold order.

FieldType
tiers*object[]
tiers[].id*string
tiers[].threshold_hours*integer
tiers[].fee_type*"fixed" | "percentage"
tiers[].fee_amount_centsinteger | null
tiers[].fee_percentageinteger | null
401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "List this company's tiered no-show fees",
  "description": "Every row of `book_no_show_fee_tiers` (migration 0137) this company has configured, in `threshold_hours` order: what DepositsCard's tier editor renders and what `GET /api/payments/no-show-fee` resolves a suggestion from. Readable by any member; only the writes below are gated.",
  "tags": [
    "Shared"
  ],
  "operationId": "listNoShowFeeTiers",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The tiers, threshold order.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "tiers"
            ],
            "properties": {
              "tiers": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "threshold_hours",
                    "fee_type"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "threshold_hours": {
                      "type": "integer"
                    },
                    "fee_type": {
                      "type": "string",
                      "enum": [
                        "fixed",
                        "percentage"
                      ]
                    },
                    "fee_amount_cents": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "fee_percentage": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/no-show-fee-tiers

Add a tier (admin, or deposit_staff_editable)

Session cookie

Same gate PATCH /api/companies already applies to noShowFeeType/noShowFeeAmountCents/noShowFeePercentage: admin, or a staff member once this org's admin has turned on depositStaffEditable (migration 0075). A tier is part of the same money-policy surface those three columns are, not an ordinary tenant setting.

Capped at 6 tiers per company (a soft, also server-checked ceiling: same "data-entry accident, not a business" reasoning book_special_hours' own 6-window cap gives). thresholdHours must be unique per company (book_no_show_fee_tiers_one_per_threshold); a duplicate is a 409.

Request body application/json

FieldTypeNotes
thresholdHours*integer(min 0, max 720)

A cancellation with less notice than this, in hours, qualifies for this tier. The strictest (smallest) qualifying tier wins when several apply.

feeType*"fixed" | "percentage"
feeAmountCentsinteger | null(min 1)

Required when feeType is fixed, must be omitted/null under percentage. Smallest currency unit.

feePercentageinteger | null(min 1, max 100)

Required when feeType is percentage, must be omitted/null under fixed. Of the booking's deposit_amount_cents, capped at it either way.

Responses

200

Saved.

FieldType
ok*true
tier*object
tier.id*string
tier.threshold_hours*integer
tier.fee_type*"fixed" | "percentage"
tier.fee_amount_centsinteger | null
tier.fee_percentageinteger | null
400

A validation message: thresholdHours outside 0-720, an unknown feeType, a missing/non-positive feeAmountCents for fixed, or a feePercentage outside 1-100 for percentage.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 with depositStaffEditable off for this org (Deposit settings are admin-only for this organization).

409

A tier for that many hours already exists on this company.

Raw OpenAPI operation
{
  "summary": "Add a tier (admin, or deposit_staff_editable)",
  "description": "Same gate `PATCH /api/companies` already applies to `noShowFeeType`/`noShowFeeAmountCents`/`noShowFeePercentage`: admin, or a staff member once this org's admin has turned on `depositStaffEditable` (migration 0075). A tier is part of the same money-policy surface those three columns are, not an ordinary tenant setting.\n\nCapped at 6 tiers per company (a soft, also server-checked ceiling: same \"data-entry accident, not a business\" reasoning `book_special_hours`' own 6-window cap gives). `thresholdHours` must be unique per company (`book_no_show_fee_tiers_one_per_threshold`); a duplicate is a 409.",
  "tags": [
    "Shared"
  ],
  "operationId": "createNoShowFeeTier",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "thresholdHours",
            "feeType"
          ],
          "additionalProperties": false,
          "properties": {
            "thresholdHours": {
              "type": "integer",
              "minimum": 0,
              "maximum": 720,
              "description": "A cancellation with less notice than this, in hours, qualifies for this tier. The strictest (smallest) qualifying tier wins when several apply."
            },
            "feeType": {
              "type": "string",
              "enum": [
                "fixed",
                "percentage"
              ]
            },
            "feeAmountCents": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "description": "Required when feeType is fixed, must be omitted/null under percentage. Smallest currency unit."
            },
            "feePercentage": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100,
              "description": "Required when feeType is percentage, must be omitted/null under fixed. Of the booking's deposit_amount_cents, capped at it either way."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "tier"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "tier": {
                "type": "object",
                "required": [
                  "id",
                  "threshold_hours",
                  "fee_type"
                ],
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "threshold_hours": {
                    "type": "integer"
                  },
                  "fee_type": {
                    "type": "string",
                    "enum": [
                      "fixed",
                      "percentage"
                    ]
                  },
                  "fee_amount_cents": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "fee_percentage": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message: `thresholdHours` outside 0-720, an unknown `feeType`, a missing/non-positive `feeAmountCents` for `fixed`, or a `feePercentage` outside 1-100 for `percentage`.",
      "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 with `depositStaffEditable` off for this org (`Deposit settings are admin-only for this organization`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "A tier for that many hours already exists on this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/organization/no-show-fee-tiers

Edit a tier (admin, or deposit_staff_editable)

Session cookie

Same gate as POST above. Replaces the named tier's threshold and fee entirely; there is no partial field-level update.

Request body application/json

FieldTypeNotes
id*string

book_no_show_fee_tiers row id.

thresholdHours*integer(min 0, max 720)

A cancellation with less notice than this, in hours, qualifies for this tier. The strictest (smallest) qualifying tier wins when several apply.

feeType*"fixed" | "percentage"
feeAmountCentsinteger | null(min 1)

Required when feeType is fixed, must be omitted/null under percentage. Smallest currency unit.

feePercentageinteger | null(min 1, max 100)

Required when feeType is percentage, must be omitted/null under fixed. Of the booking's deposit_amount_cents, capped at it either way.

Responses

200

Saved.

FieldType
ok*true
tier*object
tier.id*string
tier.threshold_hours*integer
tier.fee_type*"fixed" | "percentage"
tier.fee_amount_centsinteger | null
tier.fee_percentageinteger | null
400

Same validation as POST, or a missing id.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 with depositStaffEditable off for this org (Deposit settings are admin-only for this organization).

404

No tier with that id on this company.

409

Another tier on this company already claims that many hours.

Raw OpenAPI operation
{
  "summary": "Edit a tier (admin, or deposit_staff_editable)",
  "description": "Same gate as `POST` above. Replaces the named tier's threshold and fee entirely; there is no partial field-level update.",
  "tags": [
    "Shared"
  ],
  "operationId": "updateNoShowFeeTier",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "id",
            "thresholdHours",
            "feeType"
          ],
          "properties": {
            "id": {
              "type": "string",
              "description": "book_no_show_fee_tiers row id."
            },
            "thresholdHours": {
              "type": "integer",
              "minimum": 0,
              "maximum": 720,
              "description": "A cancellation with less notice than this, in hours, qualifies for this tier. The strictest (smallest) qualifying tier wins when several apply."
            },
            "feeType": {
              "type": "string",
              "enum": [
                "fixed",
                "percentage"
              ]
            },
            "feeAmountCents": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "description": "Required when feeType is fixed, must be omitted/null under percentage. Smallest currency unit."
            },
            "feePercentage": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 1,
              "maximum": 100,
              "description": "Required when feeType is percentage, must be omitted/null under fixed. Of the booking's deposit_amount_cents, capped at it either way."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "tier"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "tier": {
                "type": "object",
                "required": [
                  "id",
                  "threshold_hours",
                  "fee_type"
                ],
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "threshold_hours": {
                    "type": "integer"
                  },
                  "fee_type": {
                    "type": "string",
                    "enum": [
                      "fixed",
                      "percentage"
                    ]
                  },
                  "fee_amount_cents": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "fee_percentage": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Same validation as `POST`, or a missing `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": "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 with `depositStaffEditable` off for this org (`Deposit settings are admin-only for this organization`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "No tier with that id on this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Another tier on this company already claims that many hours.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/organization/no-show-fee-tiers

Remove a tier (admin, or deposit_staff_editable)

Session cookie

Same gate as POST above. Removing a tier that does not exist is not surfaced as an error the caller needs to react to differently from success.

Parameters

  • id*querystring

    book_no_show_fee_tiers row id.

Responses

200

Removed, or already absent.

FieldType
ok*true
400

id missing.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 with depositStaffEditable off for this org (Deposit settings are admin-only for this organization).

Raw OpenAPI operation
{
  "summary": "Remove a tier (admin, or deposit_staff_editable)",
  "description": "Same gate as `POST` above. Removing a tier that does not exist is not surfaced as an error the caller needs to react to differently from success.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteNoShowFeeTier",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "id",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "book_no_show_fee_tiers row id."
    }
  ],
  "responses": {
    "200": {
      "description": "Removed, or already absent.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`id` missing.",
      "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 with `depositStaffEditable` off for this org (`Deposit settings are admin-only for this organization`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/holidays

Public holidays for this business's country and state

Session cookie

Suggestions for the Special hours screen, proxied from [date.nager.at](https://date.nager.at) and narrowed to the company's own country and state.

Read-only, and it writes nothing. It answers "which dates does your country and state observe"; what the business then does on those dates is a separate, deliberate POST /api/organization/special-hours. Keeping the two apart is what stops a third-party dataset from ever changing a tenant's availability on its own.

Regional filtering is not cosmetic. In 2026 the source returns Anzac Day twice, on 25 April for SA/TAS/VIC and 27 April for NSW/ACT/WA. Unfiltered, a Sydney venue is shown two Anzac Days and one of them is a day it is open. When the company's state cannot be resolved to an ISO 3166-2 code, everything is returned and regionUnknown says so, because hiding a holiday we could not verify is the worse failure of the two.

Only Public holidays are offered; the source also reports Bank, School, Optional, Observance and Authorities days, and a list that offers to close a salon for Groundhog Day is a list nobody trusts again.

Never 5xx on a source failure. A country the source does not cover, a missing country setting and an upstream outage all answer 200 with supported: false and a reason, because the Special hours screen works perfectly well without suggestions and rendering an error over a working feature teaches operators to distrust it. Cached for a week per country-year. Readable by any member.

Parameters

  • yearqueryintegeroptional

    Defaults to the current year. Bounded to last year through three years ahead: the value is interpolated into a URL on a third-party host, so an unbounded one would make this an open proxy.

Responses

200

The holidays, or an explained absence.

FieldTypeNotes
supported*boolean
reason"no_country" | "country_not_covered" | "unavailable"

Present only when supported is false.

countrystring
statestring
regionUnknownboolean

The country has regional holidays but this tenant could not be placed in one, so every region is being shown.

year*integer
yearRangeobject
yearRange.mininteger
yearRange.maxinteger
holidays*object[]
holidays[].date*string (date)
holidays[].name*string

The name in the country's own language.

holidays[].englishNamestring

Present only when it differs, and only for a country whose local names are not already English.

holidays[].regional*boolean
holidays[].regions*string[]

ISO 3166-2 codes; empty when nationwide.

400

year is outside the permitted range.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Public holidays for this business's country and state",
  "description": "Suggestions for the Special hours screen, proxied from [date.nager.at](https://date.nager.at) and narrowed to the company's own `country` and `state`.\n\n**Read-only, and it writes nothing.** It answers \"which dates does your country and state observe\"; what the business then does on those dates is a separate, deliberate `POST /api/organization/special-hours`. Keeping the two apart is what stops a third-party dataset from ever changing a tenant's availability on its own.\n\n**Regional filtering is not cosmetic.** In 2026 the source returns Anzac Day twice, on 25 April for SA/TAS/VIC and 27 April for NSW/ACT/WA. Unfiltered, a Sydney venue is shown two Anzac Days and one of them is a day it is open. When the company's state cannot be resolved to an ISO 3166-2 code, everything is returned and `regionUnknown` says so, because hiding a holiday we could not verify is the worse failure of the two.\n\nOnly `Public` holidays are offered; the source also reports Bank, School, Optional, Observance and Authorities days, and a list that offers to close a salon for Groundhog Day is a list nobody trusts again.\n\n**Never 5xx on a source failure.** A country the source does not cover, a missing country setting and an upstream outage all answer 200 with `supported: false` and a `reason`, because the Special hours screen works perfectly well without suggestions and rendering an error over a working feature teaches operators to distrust it. Cached for a week per country-year. Readable by any member.",
  "tags": [
    "Shared"
  ],
  "operationId": "listPublicHolidays",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "year",
      "in": "query",
      "required": false,
      "schema": {
        "type": "integer"
      },
      "description": "Defaults to the current year. Bounded to last year through three years ahead: the value is interpolated into a URL on a third-party host, so an unbounded one would make this an open proxy."
    }
  ],
  "responses": {
    "200": {
      "description": "The holidays, or an explained absence.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "supported",
              "year",
              "holidays"
            ],
            "properties": {
              "supported": {
                "type": "boolean"
              },
              "reason": {
                "type": "string",
                "enum": [
                  "no_country",
                  "country_not_covered",
                  "unavailable"
                ],
                "description": "Present only when `supported` is false."
              },
              "country": {
                "type": "string",
                "nullable": true
              },
              "state": {
                "type": "string",
                "nullable": true
              },
              "regionUnknown": {
                "type": "boolean",
                "description": "The country has regional holidays but this tenant could not be placed in one, so every region is being shown."
              },
              "year": {
                "type": "integer"
              },
              "yearRange": {
                "type": "object",
                "properties": {
                  "min": {
                    "type": "integer"
                  },
                  "max": {
                    "type": "integer"
                  }
                }
              },
              "holidays": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "date",
                    "name",
                    "regional",
                    "regions"
                  ],
                  "properties": {
                    "date": {
                      "type": "string",
                      "format": "date"
                    },
                    "name": {
                      "type": "string",
                      "description": "The name in the country's own language."
                    },
                    "englishName": {
                      "type": "string",
                      "nullable": true,
                      "description": "Present only when it differs, and only for a country whose local names are not already English."
                    },
                    "regional": {
                      "type": "boolean"
                    },
                    "regions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "ISO 3166-2 codes; empty when nationwide."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`year` is outside the permitted range.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/website/subdomain-preference

Switch between the subdomain and the classic /{slug} link as the asserted address (admin)

Session cookieadmin only

Flips book_companies.site_subdomain_preferred. Refuses with 400 rather than a silent no-op if the company has no subdomain provisioned yet: there is nothing this preference could mean before then, and the dashboard control itself is never shown in that state. Not vertical-gated, unlike most of the Website screen's own routes: hospitality can hold a subdomain too (ensureTenantSubdomainIfEntitled, tenant-subdomain.ts, fires from the Stripe webhook regardless of business_type), so this has to be reachable from both engines. Admin-only, same posture the sibling offline route takes: this changes what search engines and the dashboard itself treat as this business's real address, a bigger call than the general settings a staff member with manage_org_settings can already make. No cache to invalidate, unlike offline's own route: tenantCanonicalUrl() reads this column with a plain, uncached read on every call, by design, so there is nothing stale to revalidate.

Request body application/json

FieldTypeNotes
preferred*boolean

true asserts the auto-provisioned {slug}.gaplessly.com subdomain as canonical; false keeps (or reverts to) the classic gaplessly.com/{slug} form. Never touches provisioning or DNS: the subdomain keeps existing and keeps resolving either way.

Responses

200

The preference was updated.

FieldType
ok*true
400

preferred was missing or not a boolean, or the company has no subdomain provisioned yet (Your site has no subdomain yet, so there is nothing to switch.), or the update itself failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Switch between the subdomain and the classic /{slug} link as the asserted address (admin)",
  "description": "Flips `book_companies.site_subdomain_preferred`. Refuses with 400 rather than a silent no-op if the company has no subdomain provisioned yet: there is nothing this preference could mean before then, and the dashboard control itself is never shown in that state. Not vertical-gated, unlike most of the Website screen's own routes: hospitality can hold a subdomain too (`ensureTenantSubdomainIfEntitled`, `tenant-subdomain.ts`, fires from the Stripe webhook regardless of `business_type`), so this has to be reachable from both engines. Admin-only, same posture the sibling `offline` route takes: this changes what search engines and the dashboard itself treat as this business's real address, a bigger call than the general settings a staff member with `manage_org_settings` can already make. No cache to invalidate, unlike `offline`'s own route: `tenantCanonicalUrl()` reads this column with a plain, uncached read on every call, by design, so there is nothing stale to revalidate.",
  "tags": [
    "Shared"
  ],
  "operationId": "setSubdomainPreference",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "preferred"
          ],
          "properties": {
            "preferred": {
              "type": "boolean",
              "description": "true asserts the auto-provisioned {slug}.gaplessly.com subdomain as canonical; false keeps (or reverts to) the classic gaplessly.com/{slug} form. Never touches provisioning or DNS: the subdomain keeps existing and keeps resolving either way."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The preference was updated.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`preferred` was missing or not a boolean, or the company has no subdomain provisioned yet (`Your site has no subdomain yet, so there is nothing to switch.`), or the update 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": "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"
          }
        }
      }
    }
  }
}
get/api/organization/businesses

Every business this account can act in

Session cookie

The switcher's list. Returns one entry per book_org_members row the caller holds, so an account with a single business gets a list of one and the switcher renders nothing.

The roster IS the list, deliberately. POST /api/organization/switch-business authorizes against the same table, so what is offered here and what is permitted there cannot drift apart. An organization owner appears on each of their businesses because they hold an ordinary admin row on each: this route does not know organizations exist.

Not gated with requireMember(), and read with service-role, for the reasons switch-business sets out: requireMember() answers "is the caller in the company they are currently in", which is the wrong question for a list that spans all of them, and both book_companies and book_org_members are scoped by the company_id claim, so an RLS-scoped read would return a list of length 1 for everybody. The query is pinned to the caller's own verified user.id.

Responses

200

The caller's businesses, sorted by name.

FieldTypeNotes
canAddBusiness*boolean

Whether the caller may create ANOTHER business under the organization their active one belongs to; i.e. whether POST /api/onboarding/complete would accept them in its add mode rather than answering 409. True only for that organization's owner; a plain admin on every business in the list still gets false. The switcher uses it to decide whether to offer an "Add business" row.

atLocationCap*boolean

Whether that organization has NO qualifying venue yet; i.e. whether POST /api/onboarding/complete would answer 402. A SECOND flag rather than folding into canAddBusiness, because the two produce different UI: a non-owner sees nothing (no upgrade would help them), while an owner with no qualifying venue sees the row turned into an upgrade prompt. Hiding it would tell an owner their account cannot do this when the truth is that their plan cannot.

Pay-per-venue (2026-08-02): every additional venue needs its own independent Pro-or-above subscription, so this is no longer a numeric seat count; computed by the same organizationHasQualifyingVenue() the 402 is, so the offer and the permission cannot drift. Always false for a non-owner: the answer is unused there.

businesses*object[]
businesses[].id*string (uuid)
businesses[].name*string
businesses[].slug*string
businesses[].businessType*"appointments" | "hospitality"

Which engine this business runs. The switcher uses it to land on a dashboard home the business actually has.

businesses[].role*"admin" | "staff"

The caller's role IN THAT BUSINESS. An org owner holds admin on each of theirs.

businesses[].active*boolean

True for the one the company_id claim currently points at.

400

A Postgres error reading the roster.

401

No valid session cookie. {"error":"Not signed in"}.

Raw OpenAPI operation
{
  "summary": "Every business this account can act in",
  "description": "The switcher's list. Returns one entry per `book_org_members` row the caller holds, so an account with a single business gets a list of one and the switcher renders nothing.\n\n**The roster IS the list, deliberately.** `POST /api/organization/switch-business` authorizes against the same table, so what is offered here and what is permitted there cannot drift apart. An organization owner appears on each of their businesses because they hold an ordinary `admin` row on each: this route does not know organizations exist.\n\nNot gated with `requireMember()`, and read with service-role, for the reasons switch-business sets out: `requireMember()` answers \"is the caller in the company they are currently in\", which is the wrong question for a list that spans all of them, and both `book_companies` and `book_org_members` are scoped by the `company_id` claim, so an RLS-scoped read would return a list of length 1 for everybody. The query is pinned to the caller's own verified `user.id`.",
  "tags": [
    "Shared"
  ],
  "operationId": "listMyBusinesses",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "The caller's businesses, sorted by name.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "businesses",
              "canAddBusiness",
              "atLocationCap"
            ],
            "properties": {
              "canAddBusiness": {
                "type": "boolean",
                "description": "Whether the caller may create ANOTHER business under the organization their active one belongs to; i.e. whether `POST /api/onboarding/complete` would accept them in its add mode rather than answering 409. True only for that organization's owner; a plain `admin` on every business in the list still gets false. The switcher uses it to decide whether to offer an \"Add business\" row."
              },
              "atLocationCap": {
                "type": "boolean",
                "description": "Whether that organization has NO qualifying venue yet; i.e. whether `POST /api/onboarding/complete` would answer 402. A SECOND flag rather than folding into `canAddBusiness`, because the two produce different UI: a non-owner sees nothing (no upgrade would help them), while an owner with no qualifying venue sees the row turned into an upgrade prompt. Hiding it would tell an owner their account cannot do this when the truth is that their plan cannot.\n\nPay-per-venue (2026-08-02): every additional venue needs its own independent Pro-or-above subscription, so this is no longer a numeric seat count; computed by the same `organizationHasQualifyingVenue()` the 402 is, so the offer and the permission cannot drift. Always false for a non-owner: the answer is unused there."
              },
              "businesses": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "slug",
                    "businessType",
                    "role",
                    "active"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "slug": {
                      "type": "string"
                    },
                    "businessType": {
                      "type": "string",
                      "enum": [
                        "appointments",
                        "hospitality"
                      ],
                      "description": "Which engine this business runs. The switcher uses it to land on a dashboard home the business actually has."
                    },
                    "role": {
                      "type": "string",
                      "enum": [
                        "admin",
                        "staff"
                      ],
                      "description": "The caller's role IN THAT BUSINESS. An org owner holds `admin` on each of theirs."
                    },
                    "active": {
                      "type": "boolean",
                      "description": "True for the one the `company_id` claim currently points at."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A Postgres error reading the 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"
          }
        }
      }
    }
  }
}
post/api/organization/switch-business

Point this account at another business it belongs to

Session cookie

Re-mints app_metadata.company_id so the caller's next JWT is scoped to a different business under the same organization (migration 0031).

Not gated with requireMember(). That helper asks "is the caller a member of the company they are currently in", and the caller is about to leave exactly that company; it would check the wrong tenant. Authorization is the roster lookup against the TARGET company, done with service-role because book_org_members' own policy is company_id = the claim with no auth.uid() branch, so a session-scoped client cannot see the roster of a company it is not already in. The lookup is still pinned to the caller's verified user.id, so it can only ever find memberships that are genuinely theirs.

The caller's existing token still carries the OLD claim when this returns; the client must call supabase.auth.refreshSession() before trusting any tenant read.

Request body application/json

FieldTypeNotes
companyId*string (uuid)

A business the caller is already on the roster of.

Responses

200

The claim was rewritten. Refresh the session to pick it up.

FieldType
ok*true
400

companyId is missing or not a UUID.

401

No valid session cookie. {"error":"Not signed in"}.

403

The caller is not on that business's roster. One message for "no such business" and "not yours" deliberately; telling them apart would make this an oracle for which company ids exist.

500

The claim write failed.

Raw OpenAPI operation
{
  "summary": "Point this account at another business it belongs to",
  "description": "Re-mints `app_metadata.company_id` so the caller's next JWT is scoped to a different business under the same organization (migration 0031).\n\n**Not gated with requireMember().** That helper asks \"is the caller a member of the company they are currently in\", and the caller is about to leave exactly that company; it would check the wrong tenant. Authorization is the roster lookup against the TARGET company, done with service-role because `book_org_members`' own policy is `company_id = the claim` with no `auth.uid()` branch, so a session-scoped client cannot see the roster of a company it is not already in. The lookup is still pinned to the caller's verified `user.id`, so it can only ever find memberships that are genuinely theirs.\n\nThe caller's existing token still carries the OLD claim when this returns; the client must call `supabase.auth.refreshSession()` before trusting any tenant read.",
  "tags": [
    "Shared"
  ],
  "operationId": "switchBusiness",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "companyId"
          ],
          "additionalProperties": false,
          "properties": {
            "companyId": {
              "type": "string",
              "format": "uuid",
              "description": "A business the caller is already on the roster of."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The claim was rewritten. Refresh the session to pick it up.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`companyId` is missing or not a 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": "The caller is not on that business's roster. One message for \"no such business\" and \"not yours\" deliberately; telling them apart would make this an oracle for which company ids exist.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The claim write failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/addons

Read the paid add-on catalogue and what this org has (admin)

Session cookieadmin only

The add-ons that can be bought, and this org's state for each. Three states, not two, and collapsing any two of them is a billing bug: allowed && !active means the TIER already includes it and there is nothing to sell; active means they pay the add-on fee; neither means it can be bought. Offering an upgrade in the first case charges a Pro customer twice for one feature.

Deposits are a paid add-on on Solo/Basic and Basic Table, included from Pro/Venue Pro upward: priced per vertical (2026-08-30: +$15/month on the appointments ladder, +$25/month on the hospitality ladder, reflecting the bigger loss a no-show table represents), not a flat figure (docs/pricing-strategy.md, src/lib/billing/config.ts). The gate itself lives in resolveDeposit(), not here; this route only describes it.

Every add-on trials free for `trialDays` days except SMS (Bruno, 2026-08-02); trialDays is 0 there, and the UI must not claim a trial that does not exist. status/trialEnd/cancelAt come from book_addon_subscriptions (migration 0063), the per-add-on detail table: null for every field when the org has never bought that add-on, populated once a Stripe subscription exists for it regardless of whether it is still live.

`usable` (2026-08-07) can disagree with `allowed`. allowed says the plan covers the slug; usable says the grant actually works right now. The one case they differ today: sms on a Pro/Enterprise tier subscription that is still TRIALING, where allowed is true (the tier includes it) but usable is false (a real per-segment cost must never be free during a trial, see planAllows's own sms gate in src/lib/plan.ts). Falls back to allowed for comms, a bundle with no single feature to ask planAllows about.

Reads book_billing, which has RLS with no authenticated policy, so it goes through the service-role client. There is deliberately no tenant-scoped path to these columns: it is what stops a member PATCHing themselves an entitlement through PostgREST.

Responses

200

The catalogue.

FieldTypeNotes
addons*object[]
addons[].slug*string

Stable id, e.g. deposits.

addons[].label*string
addons[].priceAud*number

Monthly price, display only; Stripe holds the price that is actually charged.

addons[].trialDays*number

Free trial length passed to Stripe as trial_period_days. 0 means no trial (today: sms).

addons[].active*boolean

The org is PAYING for this add-on (includes a still-running free trial).

addons[].allowed*boolean

The org may use the feature, whether by tier or by add-on.

addons[].usable*boolean

Whether the grant actually works right now, not just whether the plan covers it. See this route's own description for the sms-during-trial case where this differs from allowed.

addons[].status*string | null

Stripe's own subscription.status, e.g. trialing/active/canceled. Null: never bought.

addons[].trialEnd*string | null (date-time)

When the free trial ends, if it has one and is still in it. Null once converted or if never bought.

addons[].cancelAt*string | null (date-time)

A deferred cancellation is scheduled for this time (see cancel on POST below). Null when nothing is scheduled.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Read the paid add-on catalogue and what this org has (admin)",
  "description": "The add-ons that can be bought, and this org's state for each. **Three states, not two**, and collapsing any two of them is a billing bug: `allowed && !active` means the TIER already includes it and there is nothing to sell; `active` means they pay the add-on fee; neither means it can be bought. Offering an upgrade in the first case charges a Pro customer twice for one feature.\n\nDeposits are a paid add-on on Solo/Basic and Basic Table, included from Pro/Venue Pro upward: **priced per vertical** (2026-08-30: +$15/month on the appointments ladder, +$25/month on the hospitality ladder, reflecting the bigger loss a no-show table represents), not a flat figure (docs/pricing-strategy.md, src/lib/billing/config.ts). The gate itself lives in `resolveDeposit()`, not here; this route only describes it.\n\n**Every add-on trials free for `trialDays` days except SMS** (Bruno, 2026-08-02); `trialDays` is 0 there, and the UI must not claim a trial that does not exist. `status`/`trialEnd`/`cancelAt` come from `book_addon_subscriptions` (migration 0063), the per-add-on detail table: null for every field when the org has never bought that add-on, populated once a Stripe subscription exists for it regardless of whether it is still live.\n\n**`usable` (2026-08-07) can disagree with `allowed`.** `allowed` says the plan covers the slug; `usable` says the grant actually works right now. The one case they differ today: `sms` on a Pro/Enterprise tier subscription that is still TRIALING, where `allowed` is true (the tier includes it) but `usable` is false (a real per-segment cost must never be free during a trial, see `planAllows`'s own `sms` gate in `src/lib/plan.ts`). Falls back to `allowed` for `comms`, a bundle with no single feature to ask `planAllows` about.\n\nReads `book_billing`, which has RLS with no `authenticated` policy, so it goes through the service-role client. There is deliberately no tenant-scoped path to these columns: it is what stops a member PATCHing themselves an entitlement through PostgREST.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationAddons",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "The catalogue.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "addons"
            ],
            "properties": {
              "addons": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "slug",
                    "label",
                    "priceAud",
                    "trialDays",
                    "active",
                    "allowed",
                    "usable",
                    "status",
                    "trialEnd",
                    "cancelAt"
                  ],
                  "properties": {
                    "slug": {
                      "type": "string",
                      "description": "Stable id, e.g. `deposits`."
                    },
                    "label": {
                      "type": "string"
                    },
                    "priceAud": {
                      "type": "number",
                      "description": "Monthly price, display only; Stripe holds the price that is actually charged."
                    },
                    "trialDays": {
                      "type": "number",
                      "description": "Free trial length passed to Stripe as `trial_period_days`. 0 means no trial (today: `sms`)."
                    },
                    "active": {
                      "type": "boolean",
                      "description": "The org is PAYING for this add-on (includes a still-running free trial)."
                    },
                    "allowed": {
                      "type": "boolean",
                      "description": "The org may use the feature, whether by tier or by add-on."
                    },
                    "usable": {
                      "type": "boolean",
                      "description": "Whether the grant actually works right now, not just whether the plan covers it. See this route's own description for the sms-during-trial case where this differs from `allowed`."
                    },
                    "status": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Stripe's own subscription.status, e.g. `trialing`/`active`/`canceled`. Null: never bought."
                    },
                    "trialEnd": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time",
                      "description": "When the free trial ends, if it has one and is still in it. Null once converted or if never bought."
                    },
                    "cancelAt": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time",
                      "description": "A deferred cancellation is scheduled for this time (see `cancel` on POST below). Null when nothing is scheduled."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    }
  }
}
post/api/organization/addons

Start add-on checkout, open the billing portal, or cancel an add-on (admin)

Session cookieadmin only

Three shapes, dispatched on the body rather than three routes: { addon } starts Checkout for a monthly subscription (with a free trial when the add-on's trialDays is above 0); { action: "manage" } opens the Billing Portal to change a card, read invoices, or manage a TIER subscription; { action: "cancel", addon } cancels ONE add-on.

The buy and manage shapes never grant or end an entitlement themselves. They return a URL and stop; book_billing.addons is written only by the Stripe webhook, because the browser arriving at a success URL is the party that benefits from lying about having paid.

`cancel` is the one exception, and it is add-on-only. A TIER subscription still has no cancel route of its own; Stripe's portal handles that, emits customer.subscription.deleted, and the webhook withdraws it. An add-on needs behaviour the portal's single static cancellation mode cannot express: cancelling during its free trial ends access immediately (nothing was ever charged), while cancelling an already-converted, paying add-on is deferred to the end of the period already paid for: book_addon_subscriptions.status (migration 0063) is what this route reads to decide which. See cancelAddon in src/lib/billing/subscription.ts.

Admin-only on every verb: an add-on changes what the organization is billed, which is the same class of act as a tier change. Not engine-gated; deposits are sold to both verticals.

409 on the buy shape when the add-on is already active, rather than selling a second subscription for the same feature and billing $30 for one thing.

Request body application/json

FieldTypeNotes
addonstring

Add-on slug. Required to buy one, or alongside action: "cancel" to say which one.

action"manage" | "cancel"

manage opens the billing portal instead of buying. cancel ends one add-on (requires addon); see the immediate-vs-deferred rule above.

Responses

200

For the buy and manage shapes, a Stripe URL to navigate to. For action: "cancel", ok: true plus whether the cancellation was immediate.

FieldTypeNotes
urlstring

Stripe Checkout or Billing Portal. Present for the buy and manage shapes only.

oktrue

Present for action: "cancel" only.

immediateboolean

Present for action: "cancel" only. True: the add-on was still trialling and access ended right away, nothing was charged. False: it had already converted to paid, so it keeps working until the period already paid for ends.

400

Unknown add-on, nothing to manage, no subscription found for that add-on to cancel, or Stripe refused (including a missing price for the add-on's lookup_key).

401

No valid session cookie. {"error":"Not signed in"}.

403

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

409

That add-on is already active. Sold once, not twice.

503

The app URL is not configured, so Stripe has no absolute address to return the operator to.

Raw OpenAPI operation
{
  "summary": "Start add-on checkout, open the billing portal, or cancel an add-on (admin)",
  "description": "Three shapes, dispatched on the body rather than three routes: `{ addon }` starts Checkout for a monthly subscription (with a free trial when the add-on's `trialDays` is above 0); `{ action: \"manage\" }` opens the Billing Portal to change a card, read invoices, or manage a TIER subscription; `{ action: \"cancel\", addon }` cancels ONE add-on.\n\n**The buy and manage shapes never grant or end an entitlement themselves.** They return a URL and stop; `book_billing.addons` is written only by the Stripe webhook, because the browser arriving at a success URL is the party that benefits from lying about having paid.\n\n**`cancel` is the one exception, and it is add-on-only.** A TIER subscription still has no cancel route of its own; Stripe's portal handles that, emits `customer.subscription.deleted`, and the webhook withdraws it. An add-on needs behaviour the portal's single static cancellation mode cannot express: cancelling during its free trial ends access **immediately** (nothing was ever charged), while cancelling an already-converted, paying add-on is **deferred to the end of the period already paid for**: `book_addon_subscriptions.status` (migration 0063) is what this route reads to decide which. See `cancelAddon` in `src/lib/billing/subscription.ts`.\n\nAdmin-only on every verb: an add-on changes what the organization is billed, which is the same class of act as a tier change. Not engine-gated; deposits are sold to both verticals.\n\n409 on the buy shape when the add-on is already active, rather than selling a second subscription for the same feature and billing $30 for one thing.",
  "tags": [
    "Shared"
  ],
  "operationId": "startOrganizationAddonCheckout",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "addon": {
              "type": "string",
              "description": "Add-on slug. Required to buy one, or alongside `action: \"cancel\"` to say which one."
            },
            "action": {
              "type": "string",
              "enum": [
                "manage",
                "cancel"
              ],
              "description": "`manage` opens the billing portal instead of buying. `cancel` ends one add-on (requires `addon`); see the immediate-vs-deferred rule above."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "For the buy and manage shapes, a Stripe URL to navigate to. For `action: \"cancel\"`, `ok: true` plus whether the cancellation was immediate.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "properties": {
              "url": {
                "type": "string",
                "description": "Stripe Checkout or Billing Portal. Present for the buy and manage shapes only."
              },
              "ok": {
                "const": true,
                "description": "Present for `action: \"cancel\"` only."
              },
              "immediate": {
                "type": "boolean",
                "description": "Present for `action: \"cancel\"` only. True: the add-on was still trialling and access ended right away, nothing was charged. False: it had already converted to paid, so it keeps working until the period already paid for ends."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Unknown add-on, nothing to manage, no subscription found for that add-on to cancel, or Stripe refused (including a missing price for the add-on's lookup_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": "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"
          }
        }
      }
    },
    "409": {
      "description": "That add-on is already active. Sold once, not twice.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "The app URL is not configured, so Stripe has no absolute address to return the operator to.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/tier

Read this business's tier and any self-serve upgrades (admin)

Session cookieadmin only

The venue paying US for the base plan itself; sibling to /api/organization/addons, which buys something on top of it. Not engine-gated: every business type has a ladder.

currentTier follows the same fail-closed rule as planTier()/planLimit() in src/lib/plan.ts, no billing row, an inactive subscription_status, or an unrecognised tier string all read as free.

options is the list of tiers this business could self-serve upgrade to right now: always empty once hasTierSubscription is true (this route does not support buying a second tier on top of a first; see the 409 on POST), otherwise only tiers ABOVE the current one that have a real Stripe price (scripts/setup-tier-prices.ts never prices free or enterprise; the top tier stays sales-assisted, sold as a Custom plan on both ladders). A manually-set tier (every business today, since book_billing.tier had no purchase path before this) is therefore never offered a downgrade.

`?feature=x` is optional and additive (Bruno, 2026-08-02): PlanGate (src/components/PlanGate.tsx) sends it after a 402 on a tier-gated feature, asking "which tier would unlock this". When present and a real PlanFeature slug, the three required* fields below are also present, computed by minimumTierFor() in src/lib/plan.ts and priced on THIS business's own ladder, never duplicated client-side, since a client component cannot import plan.ts at all (it opens a service-role Supabase client at module scope). Every other caller (TierCard) never sends the param and sees no change.

Reads book_billing, which has RLS with no authenticated policy, so this goes through the service-role client, the same reason /api/organization/addons does.

Parameters

  • featurequerystringoptional

    A PlanFeature slug (e.g. table_combining) to ask "which tier includes this". Ignored (and adds nothing to the response) if it is not a real slug.

Responses

200

The current tier and any upgrade options.

FieldTypeNotes
currentTier*"free" | "solo" | "basic" | "pro" | "enterprise"

The entitlement slug, not the marketing plan id.

currentTierLabel*string

Display name from TIER_LABELS, e.g. "Pro".

hasTierSubscription*boolean

A real Stripe tier subscription exists (migration 0058). When true, options is empty and the UI should offer the billing portal instead.

options*object[]
options[].tier*"solo" | "basic" | "pro"

Never free (nothing to sell) or enterprise (sales-assisted, no self-serve price).

options[].name*string

The marketing plan name for this business's own ladder, e.g. "Venue Pro".

options[].priceAud*number

Monthly price, display only; Stripe holds the price actually charged.

requiredTier"free" | "solo" | "basic" | "pro" | "enterprise" | null

Present only when ?feature= was sent. The cheapest tier that includes it.

requiredTierLabelstring | null

Present only when ?feature= was sent. Display name for requiredTier.

requiredTierPriceAudnumber | null

Present only when ?feature= was sent. Null for Enterprise (no self-serve price) or when the business already holds a live tier subscription; see hasTierSubscription.

401

No valid session cookie. {"error":"Not signed in"}.

403

Not an admin of this organization, or the organization could not be found.

Raw OpenAPI operation
{
  "summary": "Read this business's tier and any self-serve upgrades (admin)",
  "description": "The venue paying US for the base plan itself; sibling to `/api/organization/addons`, which buys something on top of it. Not engine-gated: every business type has a ladder.\n\n`currentTier` follows the same fail-closed rule as `planTier()`/`planLimit()` in `src/lib/plan.ts`, no billing row, an inactive `subscription_status`, or an unrecognised tier string all read as `free`.\n\n`options` is the list of tiers this business could self-serve upgrade to right now: always empty once `hasTierSubscription` is true (this route does not support buying a second tier on top of a first; see the 409 on POST), otherwise only tiers ABOVE the current one that have a real Stripe price (`scripts/setup-tier-prices.ts` never prices `free` or `enterprise`; the top tier stays sales-assisted, sold as a Custom plan on both ladders). A manually-set tier (every business today, since `book_billing.tier` had no purchase path before this) is therefore never offered a downgrade.\n\n**`?feature=x` is optional and additive** (Bruno, 2026-08-02): `PlanGate` (src/components/PlanGate.tsx) sends it after a 402 on a tier-gated feature, asking \"which tier would unlock this\". When present and a real `PlanFeature` slug, the three `required*` fields below are also present, computed by `minimumTierFor()` in `src/lib/plan.ts` and priced on THIS business's own ladder, never duplicated client-side, since a client component cannot import `plan.ts` at all (it opens a service-role Supabase client at module scope). Every other caller (TierCard) never sends the param and sees no change.\n\nReads `book_billing`, which has RLS with no `authenticated` policy, so this goes through the service-role client, the same reason `/api/organization/addons` does.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationTier",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "feature",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "A PlanFeature slug (e.g. `table_combining`) to ask \"which tier includes this\". Ignored (and adds nothing to the response) if it is not a real slug."
    }
  ],
  "responses": {
    "200": {
      "description": "The current tier and any upgrade options.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "currentTier",
              "currentTierLabel",
              "hasTierSubscription",
              "options"
            ],
            "properties": {
              "currentTier": {
                "type": "string",
                "enum": [
                  "free",
                  "solo",
                  "basic",
                  "pro",
                  "enterprise"
                ],
                "description": "The entitlement slug, not the marketing plan id."
              },
              "currentTierLabel": {
                "type": "string",
                "description": "Display name from `TIER_LABELS`, e.g. \"Pro\"."
              },
              "hasTierSubscription": {
                "type": "boolean",
                "description": "A real Stripe tier subscription exists (migration 0058). When true, `options` is empty and the UI should offer the billing portal instead."
              },
              "options": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "tier",
                    "name",
                    "priceAud"
                  ],
                  "properties": {
                    "tier": {
                      "type": "string",
                      "enum": [
                        "solo",
                        "basic",
                        "pro"
                      ],
                      "description": "Never `free` (nothing to sell) or `enterprise` (sales-assisted, no self-serve price)."
                    },
                    "name": {
                      "type": "string",
                      "description": "The marketing plan name for this business's own ladder, e.g. \"Venue Pro\"."
                    },
                    "priceAud": {
                      "type": "number",
                      "description": "Monthly price, display only; Stripe holds the price actually charged."
                    }
                  }
                }
              },
              "requiredTier": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "free",
                  "solo",
                  "basic",
                  "pro",
                  "enterprise",
                  null
                ],
                "description": "Present only when `?feature=` was sent. The cheapest tier that includes it."
              },
              "requiredTierLabel": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Present only when `?feature=` was sent. Display name for `requiredTier`."
              },
              "requiredTierPriceAud": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Present only when `?feature=` was sent. Null for Enterprise (no self-serve price) or when the business already holds a live tier subscription; see `hasTierSubscription`."
              }
            }
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie. `{\"error\":\"Not signed in\"}`.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "403": {
      "description": "Not an admin of this organization, or the organization could not be found.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/tier

Start tier checkout (admin)

Session cookieadmin only

Returns a Stripe-hosted Checkout URL for the browser to navigate to, subscribing this business to the given tier. Sibling to POST /api/organization/addons, and the same rule applies: this route never grants an entitlement, it returns a URL and stops; book_billing.tier/subscription_status are written only by the Stripe webhook (handleTierBought), because the browser arriving at a success URL is the party that benefits from lying about having paid.

409 when a tier subscription already exists (migration 0058's tier_subscription_id), regardless of which tier; this route does not support an in-place plan change. Stripe's own Billing Portal (opened via POST /api/organization/addons with { action: "manage" }, which shows every subscription on the account) is where that belongs; duplicating it here would risk stacking a second subscription on one business.

Admin-only: changing what the organization is billed is not a staff decision. Not engine-gated; every business type has a ladder.

Request body application/json

FieldTypeNotes
tier*"solo" | "basic" | "pro"

The tier to subscribe to. free and enterprise are always refused; nothing to buy, or sales-assisted.

Responses

200

A Stripe Checkout URL to navigate to.

FieldTypeNotes
url*string

Stripe Checkout.

400

Unknown or unbuyable tier, or Stripe refused (including a missing price for that tier's lookup_key; Enterprise, or scripts/setup-tier-prices.ts was never run).

401

No valid session cookie. {"error":"Not signed in"}.

403

Not an admin of this organization, or the organization could not be found.

409

This business already has a paid tier. Sold once, not twice; manage it from the billing portal instead.

503

The app URL is not configured, so Stripe has no absolute address to return the operator to.

Raw OpenAPI operation
{
  "summary": "Start tier checkout (admin)",
  "description": "Returns a **Stripe-hosted Checkout URL** for the browser to navigate to, subscribing this business to the given tier. Sibling to `POST /api/organization/addons`, and the same rule applies: this route never grants an entitlement, it returns a URL and stops; `book_billing.tier`/`subscription_status` are written only by the Stripe webhook (`handleTierBought`), because the browser arriving at a success URL is the party that benefits from lying about having paid.\n\n**409 when a tier subscription already exists** (migration 0058's `tier_subscription_id`), regardless of which tier; this route does not support an in-place plan change. Stripe's own Billing Portal (opened via `POST /api/organization/addons` with `{ action: \"manage\" }`, which shows every subscription on the account) is where that belongs; duplicating it here would risk stacking a second subscription on one business.\n\nAdmin-only: changing what the organization is billed is not a staff decision. Not engine-gated; every business type has a ladder.",
  "tags": [
    "Shared"
  ],
  "operationId": "startOrganizationTierCheckout",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "tier"
          ],
          "properties": {
            "tier": {
              "type": "string",
              "enum": [
                "solo",
                "basic",
                "pro"
              ],
              "description": "The tier to subscribe to. `free` and `enterprise` are always refused; nothing to buy, or sales-assisted."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "A Stripe Checkout URL to navigate to.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "url"
            ],
            "properties": {
              "url": {
                "type": "string",
                "description": "Stripe Checkout."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Unknown or unbuyable tier, or Stripe refused (including a missing price for that tier's lookup_key; Enterprise, or scripts/setup-tier-prices.ts was never run).",
      "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 of this organization, or the organization could not be found.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "This business already has a paid tier. Sold once, not twice; manage it from the billing portal instead.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "The app URL is not configured, so Stripe has no absolute address to return the operator to.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/billing

Read Gaplessly's own billing details for this org (admin)

Session cookieadmin only

Migration 0128, the scoped-down native-billing build (docs/backlog.md): a SECOND, distinct ABN and a receipts-email override for GAPLESSLY'S OWN invoices to this venue, kept on book_billing rather than book_companies. Not the same field as receiptAbn on PATCH /api/companies (migration 0126), which is the venue's ABN on ITS OWN guest receipts; the two answer opposite questions and live on opposite tables on purpose.

Reads book_billing, which has RLS with no authenticated policy, so this goes through the service-role client, the same reason /api/organization/addons and /api/organization/tier do. Both fields default to null when the org has never set them, or has no book_billing row at all yet.

Responses

200

The saved ABN and receipts-email override, or null for either.

FieldTypeNotes
abn*string | null

11 digits, no spaces. Null means print no ABN on Gaplessly's invoices to this venue.

receiptsEmail*string | null

Null falls back to the venue's normal contact email wherever a Gaplessly billing email is sent.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Read Gaplessly's own billing details for this org (admin)",
  "description": "Migration 0128, the scoped-down native-billing build (docs/backlog.md): a SECOND, distinct ABN and a receipts-email override for GAPLESSLY'S OWN invoices to this venue, kept on `book_billing` rather than `book_companies`. Not the same field as `receiptAbn` on `PATCH /api/companies` (migration 0126), which is the venue's ABN on ITS OWN guest receipts; the two answer opposite questions and live on opposite tables on purpose.\n\nReads `book_billing`, which has RLS with no `authenticated` policy, so this goes through the service-role client, the same reason `/api/organization/addons` and `/api/organization/tier` do. Both fields default to null when the org has never set them, or has no `book_billing` row at all yet.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationBilling",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "The saved ABN and receipts-email override, or null for either.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "abn",
              "receiptsEmail"
            ],
            "properties": {
              "abn": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "11 digits, no spaces. Null means print no ABN on Gaplessly's invoices to this venue."
              },
              "receiptsEmail": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Null falls back to the venue's normal contact email wherever a Gaplessly billing email is sent."
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    }
  }
}
patch/api/organization/billing

Save Gaplessly's own billing details for this org (admin)

Session cookieadmin only

Only the keys actually sent are touched: either field, or both, in one call. abn is normalised the same way receiptAbn is on PATCH /api/companies (normalizeAbn, lib/billing/receipt-format.ts): spaces and hyphens stripped, then must be exactly 11 digits or empty. receiptsEmail follows the same email-shape check every other admin-settings route in this file uses. An empty string on either field CLEARS it to null rather than being ignored.

Upserts into book_billing without ever naming tier/subscription_status/stripe_customer_id in the payload, the same trap ensureCustomer's own comment (lib/billing/subscription.ts) names for every other upsert into this table: naming those columns in an upsert would reset a paying org.

Admin-only: this is a fact about what the organization is billed, the same class of act as a tier or add-on change.

Request body application/json

FieldTypeNotes
abnstring

11 digits once spaces/hyphens are stripped, or empty to clear it. Anything else is a 400.

receiptsEmailstring

A valid email address, or empty to clear it. Anything else is a 400.

Responses

200

Saved.

FieldType
ok*true
400

Invalid body, an ABN that is not 11 digits, a malformed email, or a body that touched neither field.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

500

The database write failed.

Raw OpenAPI operation
{
  "summary": "Save Gaplessly's own billing details for this org (admin)",
  "description": "Only the keys actually sent are touched: either field, or both, in one call. `abn` is normalised the same way `receiptAbn` is on `PATCH /api/companies` (`normalizeAbn`, `lib/billing/receipt-format.ts`): spaces and hyphens stripped, then must be exactly 11 digits or empty. `receiptsEmail` follows the same email-shape check every other admin-settings route in this file uses. An empty string on either field CLEARS it to null rather than being ignored.\n\nUpserts into `book_billing` without ever naming `tier`/`subscription_status`/`stripe_customer_id` in the payload, the same trap `ensureCustomer`'s own comment (`lib/billing/subscription.ts`) names for every other upsert into this table: naming those columns in an upsert would reset a paying org.\n\nAdmin-only: this is a fact about what the organization is billed, the same class of act as a tier or add-on change.",
  "tags": [
    "Shared"
  ],
  "operationId": "updateOrganizationBilling",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "abn": {
              "type": "string",
              "description": "11 digits once spaces/hyphens are stripped, or empty to clear it. Anything else is a 400."
            },
            "receiptsEmail": {
              "type": "string",
              "description": "A valid email address, or empty to clear it. Anything else is a 400."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Saved.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Invalid body, an ABN that is not 11 digits, a malformed email, or a body that touched neither field.",
      "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"
          }
        }
      }
    },
    "500": {
      "description": "The database write failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/billing/invoices

Read Gaplessly's own invoices to this org, newest first (admin)

Session cookieadmin only

Native "Billing history" (migration 0128): what listBillingInvoices (lib/billing/subscription.ts) reads straight off Stripe's Invoices API for this org's platform-billing customer. Deliberately read-only: there is no write path here that could drift from what Stripe's own list already says, unlike a first-party saved-card manager would. Up to 36 invoices (three years of monthly billing), newest first; an org that has never bought a tier or add-on gets an empty list rather than an error.

Responses

200

Up to 36 invoices, newest first.

FieldTypeNotes
invoices*object[]
invoices[].id*string

Stripe invoice id.

invoices[].number*string | null

Stripe's own human invoice number. Null for a draft that has not been finalized yet.

invoices[].createdAt*string (date-time)
invoices[].amountCents*integer

The invoice total, not amount_paid/amount_due: what the invoice is FOR, regardless of payment state; status says whether it has been paid.

invoices[].currency*string

Uppercased (Stripe returns lowercase), matching every other currency string in this codebase.

invoices[].status*"draft" | "open" | "paid" | "uncollectible" | "void"

Stripe's own invoice status. A null status from Stripe is reported as draft.

invoices[].hostedInvoiceUrl*string | null

Stripe-hosted invoice page. Null until finalized.

invoices[].invoicePdf*string | null

Direct PDF link. Null until finalized.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

502

Stripe refused or was unreachable.

Raw OpenAPI operation
{
  "summary": "Read Gaplessly's own invoices to this org, newest first (admin)",
  "description": "Native \"Billing history\" (migration 0128): what `listBillingInvoices` (`lib/billing/subscription.ts`) reads straight off Stripe's Invoices API for this org's platform-billing customer. Deliberately read-only: there is no write path here that could drift from what Stripe's own list already says, unlike a first-party saved-card manager would. Up to 36 invoices (three years of monthly billing), newest first; an org that has never bought a tier or add-on gets an empty list rather than an error.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationBillingInvoices",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "Up to 36 invoices, newest first.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "invoices"
            ],
            "properties": {
              "invoices": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "number",
                    "createdAt",
                    "amountCents",
                    "currency",
                    "status",
                    "hostedInvoiceUrl",
                    "invoicePdf"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Stripe invoice id."
                    },
                    "number": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Stripe's own human invoice number. Null for a draft that has not been finalized yet."
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "amountCents": {
                      "type": "integer",
                      "description": "The invoice `total`, not `amount_paid`/`amount_due`: what the invoice is FOR, regardless of payment state; `status` says whether it has been paid."
                    },
                    "currency": {
                      "type": "string",
                      "description": "Uppercased (Stripe returns lowercase), matching every other currency string in this codebase."
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "draft",
                        "open",
                        "paid",
                        "uncollectible",
                        "void"
                      ],
                      "description": "Stripe's own invoice status. A null status from Stripe is reported as `draft`."
                    },
                    "hostedInvoiceUrl": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Stripe-hosted invoice page. Null until finalized."
                    },
                    "invoicePdf": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Direct PDF link. Null until finalized."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    },
    "502": {
      "description": "Stripe refused or was unreachable.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/usage

Read this business's metered usage for the current billing period (admin)

Session cookieadmin only

What UsageCard renders on /billing. A read of the append-only book_usage_events ledger via lib/usage.ts.

Two metered resources since pass 2 (2026-08-08), and they are not symmetric. sms is gated and re-billed: every sendSms() call site goes through meteredSendSms, and each segment is charged pay-as-you-go. email is neither: every meteredSendEmail send is counted, but email is included on every plan, is not re-billed, and cannot be capped (see that function's own header). Bookings remain the one documented chokepoint still uninstrumented.

No cap or block lives here or anywhere else (2026-08-07): sms is either off entirely (never activated, or a trialing tier hasn't converted to paid, per planAllows's own trial gate) or fully on with no allowance and no ceiling. This route only ever reports what was sent, never a reason a send was refused for running out of anything.

allowed: false zeroes usedThisPeriod without reading the ledger for SMS, because there is nothing to meter for a business that cannot use the feature. It no longer short-circuits the whole response: pass 1 did, which would now hide the email count from every company that has not bought SMS, i.e. most of them.

The four top-level fields keep their pass-1 shape and meaning exactly and describe SMS alone; email was added as a nested object rather than a reshuffle, so this stayed an additive change. included is always 0 (no tier or add-on bundles a free allowance any more, see TIER_LIMITS's own comment), kept in the response rather than dropped so UsageCard never has to special-case its absence. One billing-period boundary serves both resources, derived from book_billing.current_period_end via periodStartFor().

Responses

200

This period's usage for each metered resource.

FieldTypeNotes
resource*"sms"

Names what the four top-level fields describe. Not a query parameter; a second resource arrived as its own object rather than by making this switchable.

allowed*boolean

planAllows(companyId, 'sms'); included by tier or bought as an add-on, and not merely a trialing tier subscription. False zeroes the SMS fields; it does not zero email.

usedThisPeriod*integer(min 0)

SMS segments. SUM(amount) from book_usage_events for this company/resource/period_start, computed on read, no cached running counter.

included*integer(min 0)

Always 0 as of 2026-08-07, because no tier or add-on bundles a free allowance any more. Every segment allowed: true implies is billed pay-as-you-go instead.

email*object

Reported for every company unconditionally: email is included on every plan, so there is no entitlement flag to pair with it and no per-company ceiling to report against. The real limit is the platform-wide Resend account quota, which is not a tenant number.

email.usedThisPeriod*integer(min 0)

Emails accepted by Resend for this company this period, one per recipient. Counted, never charged.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Read this business's metered usage for the current billing period (admin)",
  "description": "What `UsageCard` renders on `/billing`. A read of the append-only `book_usage_events` ledger via `lib/usage.ts`.\n\n**Two metered resources since pass 2 (2026-08-08), and they are not symmetric.** `sms` is gated and re-billed: every `sendSms()` call site goes through `meteredSendSms`, and each segment is charged pay-as-you-go. `email` is neither: every `meteredSendEmail` send is counted, but email is included on every plan, is not re-billed, and cannot be capped (see that function's own header). Bookings remain the one documented chokepoint still uninstrumented.\n\n**No cap or block lives here or anywhere else** (2026-08-07): `sms` is either off entirely (never activated, or a trialing tier hasn't converted to paid, per `planAllows`'s own trial gate) or fully on with no allowance and no ceiling. This route only ever reports what was sent, never a reason a send was refused for running out of anything.\n\n`allowed: false` zeroes `usedThisPeriod` without reading the ledger for SMS, because there is nothing to meter for a business that cannot use the feature. **It no longer short-circuits the whole response**: pass 1 did, which would now hide the email count from every company that has not bought SMS, i.e. most of them.\n\nThe four top-level fields keep their pass-1 shape and meaning exactly and describe SMS alone; `email` was added as a nested object rather than a reshuffle, so this stayed an additive change. `included` is always `0` (no tier or add-on bundles a free allowance any more, see `TIER_LIMITS`'s own comment), kept in the response rather than dropped so `UsageCard` never has to special-case its absence. One billing-period boundary serves both resources, derived from `book_billing.current_period_end` via `periodStartFor()`.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationUsage",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "This period's usage for each metered resource.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "resource",
              "allowed",
              "usedThisPeriod",
              "included",
              "email"
            ],
            "properties": {
              "resource": {
                "const": "sms",
                "description": "Names what the four top-level fields describe. Not a query parameter; a second resource arrived as its own object rather than by making this switchable."
              },
              "allowed": {
                "type": "boolean",
                "description": "planAllows(companyId, 'sms'); included by tier or bought as an add-on, and not merely a trialing tier subscription. False zeroes the SMS fields; it does not zero `email`."
              },
              "usedThisPeriod": {
                "type": "integer",
                "minimum": 0,
                "description": "SMS segments. SUM(amount) from book_usage_events for this company/resource/period_start, computed on read, no cached running counter."
              },
              "included": {
                "type": "integer",
                "minimum": 0,
                "description": "Always 0 as of 2026-08-07, because no tier or add-on bundles a free allowance any more. Every segment `allowed: true` implies is billed pay-as-you-go instead."
              },
              "email": {
                "type": "object",
                "description": "Reported for every company unconditionally: email is included on every plan, so there is no entitlement flag to pair with it and no per-company ceiling to report against. The real limit is the platform-wide Resend account quota, which is not a tenant number.",
                "required": [
                  "usedThisPeriod"
                ],
                "properties": {
                  "usedThisPeriod": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Emails accepted by Resend for this company this period, one per recipient. Counted, never charged."
                  }
                }
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    }
  }
}
get/api/organization/stripe

Read this org's Stripe Connect status (admin)

Session cookieadmin only

Whether the org can take guest deposits (migration 0012), in three states rather than two, and the distinction is the point. connected means a Connect account exists; chargesEnabled means Stripe has actually cleared it to take card payments. They are not the same, and assuming they are is the failure 0012's own header warns about: a PaymentIntent is created successfully on a restricted account and only fails when the guest tries to pay it, which would strand a booking half-made.

Calls Stripe on every request to re-read the capability status, because it flips when a verification completes; hours or days after the operator left onboarding, and emits no event this app subscribes to. A Stripe outage fails soft: the stored value is returned instead, since a settings page that errors because Stripe is slow is worse than one showing a stale answer.

configured: false means the deployment has no Stripe keys at all (every preview branch until someone sets them). That is answered 200, not as an error; there is nothing wrong with the request.

Reads book_billing, which has RLS with no authenticated policy, so this goes through the service-role client. There is no tenant-scoped path to these columns by design: it is what stops a member PATCHing themselves a payout destination through PostgREST.

Responses

200

The connection status.

FieldTypeNotes
configured*boolean

The deployment has Stripe keys. False disables the whole feature.

connected*boolean

A connected account id is stored for this org.

chargesEnabled*boolean

Stripe has cleared the account to take card payments (card_payments.status === "active"). Never infer this from `connected`.

accountId*string | null

The acct_... id, or null when not connected.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Read this org's Stripe Connect status (admin)",
  "description": "Whether the org can take guest deposits (migration 0012), in three states rather than two, and the distinction is the point. `connected` means a Connect account exists; `chargesEnabled` means Stripe has actually cleared it to take card payments. **They are not the same, and assuming they are is the failure 0012's own header warns about**: a PaymentIntent is created successfully on a restricted account and only fails when the guest tries to pay it, which would strand a booking half-made.\n\nCalls Stripe on every request to re-read the capability status, because it flips when a verification completes; hours or days after the operator left onboarding, and emits no event this app subscribes to. A Stripe outage fails soft: the stored value is returned instead, since a settings page that errors because Stripe is slow is worse than one showing a stale answer.\n\n`configured: false` means the deployment has no Stripe keys at all (every preview branch until someone sets them). That is answered 200, not as an error; there is nothing wrong with the request.\n\nReads `book_billing`, which has RLS with no `authenticated` policy, so this goes through the service-role client. There is no tenant-scoped path to these columns by design: it is what stops a member PATCHing themselves a payout destination through PostgREST.",
  "tags": [
    "Shared"
  ],
  "operationId": "getOrganizationStripe",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "The connection status.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "configured",
              "connected",
              "chargesEnabled",
              "accountId"
            ],
            "properties": {
              "configured": {
                "type": "boolean",
                "description": "The deployment has Stripe keys. False disables the whole feature."
              },
              "connected": {
                "type": "boolean",
                "description": "A connected account id is stored for this org."
              },
              "chargesEnabled": {
                "type": "boolean",
                "description": "Stripe has cleared the account to take card payments (`card_payments.status === \"active\"`). **Never infer this from `connected`.**"
              },
              "accountId": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The `acct_...` id, or null when not connected."
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    }
  }
}
post/api/organization/stripe

Start or resume Stripe Connect onboarding (admin)

Session cookieadmin only

Returns a single-use hosted onboarding URL to redirect the operator to. Minted fresh on every call rather than stored, because a cached link 404s the second time it is clicked.

Idempotent in the way that matters: an org that already has an account gets a link to that account, and country is not read at all on that path. Two connected accounts for one venue is not a state this app could later untangle; the second would hold real charges.

Account creation uses the Stripe v2 API (stripe.v2.core.accounts). The v1 accounts.create({ type: "express" }) path is hard blocked on this platform account and fails in a way that reads like a permissions problem; v2 is also Stripe's current recommendation for new integrations. Accounts are created with dashboard: "full" and fees/losses collected by Stripe, which makes each venue the merchant of record for its own bookings; guest money settles in their balance and never passes through the platform, and no application fee is taken.

country is asked for rather than derived. A connected account's country is permanent and decides which payout rails and legal terms apply, and book_companies.country holds a display name written by the onboarding wizard, not an ISO code; guessing it from the currency would put a Dublin restaurant charging EUR into France.

Request body application/json

FieldTypeNotes
countrystring

ISO 3166-1 alpha-2, and must be one of the countries in lib/booking/countries.ts. Required when creating the account; ignored entirely when one already exists, since it cannot be changed afterwards.

Responses

200

Redirect the operator here.

FieldTypeNotes
url*string (uri)

Single-use. Expires.

400

country is missing or not one of the supported countries. Only reachable when no account exists yet.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

The caller's company row could not be read.

500

The account was created at Stripe but book_billing could not be written. The next attempt takes the resume path rather than creating a second account; the id is stored before the link is minted for exactly this reason.

502

Stripe refused or could not be reached. Its own message is passed through, because the useful half of a Connect failure is always in it.

503

This deployment has no STRIPE_SECRET_KEY.

Raw OpenAPI operation
{
  "summary": "Start or resume Stripe Connect onboarding (admin)",
  "description": "Returns a single-use hosted onboarding URL to redirect the operator to. Minted fresh on every call rather than stored, because a cached link 404s the second time it is clicked.\n\nIdempotent in the way that matters: an org that already has an account gets a link to **that** account, and `country` is not read at all on that path. Two connected accounts for one venue is not a state this app could later untangle; the second would hold real charges.\n\nAccount creation uses the Stripe **v2** API (`stripe.v2.core.accounts`). The v1 `accounts.create({ type: \"express\" })` path is hard blocked on this platform account and fails in a way that reads like a permissions problem; v2 is also Stripe's current recommendation for new integrations. Accounts are created with `dashboard: \"full\"` and fees/losses collected by Stripe, which makes each venue the merchant of record for its own bookings; guest money settles in their balance and never passes through the platform, and no application fee is taken.\n\n`country` is asked for rather than derived. A connected account's country is permanent and decides which payout rails and legal terms apply, and `book_companies.country` holds a display name written by the onboarding wizard, not an ISO code; guessing it from the currency would put a Dublin restaurant charging EUR into France.",
  "tags": [
    "Shared"
  ],
  "operationId": "startOrganizationStripeOnboarding",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "country": {
              "type": "string",
              "description": "ISO 3166-1 alpha-2, and must be one of the countries in `lib/booking/countries.ts`. Required when creating the account; ignored entirely when one already exists, since it cannot be changed afterwards."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Redirect the operator here.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "url"
            ],
            "properties": {
              "url": {
                "type": "string",
                "format": "uri",
                "description": "Single-use. Expires."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`country` is missing or not one of the supported countries. Only reachable when no account exists yet.",
      "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": "The caller's company row could not be read.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The account was created at Stripe but `book_billing` could not be written. The next attempt takes the resume path rather than creating a second account; the id is stored before the link is minted for exactly this reason.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "Stripe refused or could not be reached. Its own message is passed through, because the useful half of a Connect failure is always in it.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "This deployment has no `STRIPE_SECRET_KEY`.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/organization/stripe/success

Re-sync billing from Stripe, then redirect to Organization settings

Session cookieadmin only

Where a tier checkout, an add-on checkout, AND the Stripe Billing Portal all send an admin back, before they ever reach /organization. Never grants anything itself: syncCompanyBilling() (src/lib/billing/sync.ts) only re-reads what the webhook already decided, a beat earlier than the webhook might otherwise get to it, so an admin who just paid (or just cancelled in the portal) does not land on a page still showing the state from before they clicked. The webhook (handleTierBought/handleAddonBought/etc.) remains the only thing that actually writes an entitlement.

Fails open on every edge on purpose: no session, a company with no Stripe customer yet, or the sync call itself throwing all fall through to the same redirect rather than stranding an admin on an error page after they have already paid. TierCard/AddonBar's own delayed re-read after the redirect is the second safety net if even this proves too early; the webhook can genuinely still be in flight.

Parameters

  • tierquerystringoptional

    Echoed back onto the redirect as ?tier=&result=active when a tier checkout is what sent the browser here.

  • addonquerystringoptional

    Echoed back onto the redirect as ?addon=&result=active when an add-on checkout (or the billing portal) is what sent the browser here.

Responses

200

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

The real response, always. Fails open, deliberately: the handler never returns the admin gate's own failure response; an expired session, a non-admin caller, a company with no Stripe customer yet, or the sync call itself throwing all silently skip the re-sync and still redirect here, exactly as /organization would render before the click. See the handler's own header comment on why: an admin who just paid should never be stranded on an auth error page. Redirects to /organization, carrying tier/addon and result=active through when present.

401

Structurally reachable (the handler calls requireMember('admin')) but never actually returned; documented only because this repo's build-contract checker requires it whenever that gate is called. See the 307: an unauthenticated caller gets the same redirect as everyone else, just without a re-sync.

403

Structurally reachable (the handler calls requireMember('admin')) but never actually returned; same note as 401. A non-admin caller gets the same redirect as everyone else, just without a re-sync.

Raw OpenAPI operation
{
  "summary": "Re-sync billing from Stripe, then redirect to Organization settings",
  "description": "Where a tier checkout, an add-on checkout, AND the Stripe Billing Portal all send an admin back, before they ever reach `/organization`. **Never grants anything itself**: `syncCompanyBilling()` (src/lib/billing/sync.ts) only re-reads what the webhook already decided, a beat earlier than the webhook might otherwise get to it, so an admin who just paid (or just cancelled in the portal) does not land on a page still showing the state from before they clicked. The webhook (`handleTierBought`/`handleAddonBought`/etc.) remains the only thing that actually writes an entitlement.\n\n**Fails open on every edge on purpose**: no session, a company with no Stripe customer yet, or the sync call itself throwing all fall through to the same redirect rather than stranding an admin on an error page after they have already paid. `TierCard`/`AddonBar`'s own delayed re-read after the redirect is the second safety net if even this proves too early; the webhook can genuinely still be in flight.",
  "tags": [
    "Shared"
  ],
  "operationId": "organizationStripeSuccess",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "tier",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "Echoed back onto the redirect as `?tier=&result=active` when a tier checkout is what sent the browser here."
    },
    {
      "name": "addon",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "Echoed back onto the redirect as `?addon=&result=active` when an add-on checkout (or the billing portal) is what sent the browser here."
    }
  ],
  "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. **Fails open, deliberately**: the handler never returns the admin gate's own failure response; an expired session, a non-admin caller, a company with no Stripe customer yet, or the sync call itself throwing all silently skip the re-sync and still redirect here, exactly as `/organization` would render before the click. See the handler's own header comment on why: an admin who just paid should never be stranded on an auth error page. Redirects to `/organization`, carrying `tier`/`addon` and `result=active` through when present."
    },
    "401": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned; documented only because this repo's build-contract checker requires it whenever that gate is called. See the 307: an unauthenticated caller gets the same redirect as everyone else, just without a re-sync."
    },
    "403": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned; same note as 401. A non-admin caller gets the same redirect as everyone else, just without a re-sync."
    }
  }
}
post/api/payments/refunds

Refund a guest deposit or a card-on-file no-show fee, in full or in part (admin)

Session cookieadmin only

What the Refund button on /payments calls. Until this existed a refund only ever happened as a side effect of cancelling a booking (resolveDepositOnCancel), so a business returning part of a deposit on a booking it was still honouring had to do it from its own Stripe dashboard. This is a separate route from the cancel paths deliberately: those refund because a booking went away, this one refunds because a human decided an amount.

Two refundable tracks share one payment intent (migration 0129). A real deposit marks deposit_status = 'paid'; a card-on-file no-show fee never touches that column and marks card_on_file_status = 'charged' instead. A booking is never both, so exactly one of the two response fields below reflects a state this call actually changed and the other simply reports the untouched value.

A direct refund on the connected account. Either kind of payment is a direct charge, so the money leaves the business's own Stripe balance, never the platform's, and no application fee is unwound because none was ever taken.

amountCents omitted means the whole remaining balance, which is not the same as sending the amount the ledger thinks is outstanding: Stripe holds the real running total, so "everything left" is always right, while a computed figure can be short by any refund the Connect webhook never mirrored back. A partial refund is bounded locally by what the ledger's own succeeded row for this exact payment intent recorded (not the booking's frozen deposit snapshot, which a tiered no-show fee can charge less than); Stripe is the authority on what is actually left, and its own message comes back verbatim at 400 when the amount exceeds it.

An `Idempotency-Key` header is REQUIRED, unlike the two guest checkout routes where an absent header means "no idempotency requested" so an older client bundle keeps working. There is no older client here, and a refund with no guard is not worth being permissive about. Three layers hang off that one key: withIdempotency runs the route at most once per key and replays the first response (the double-clicked Confirm); the same key goes to Stripe as its own idempotency option, covering the window our table cannot, since it deletes its key row on a thrown error so a transient failure stays retryable (a crash after Stripe refunded and before the response was sent); and the ledger row carries the refund id in stripe_event_id, whose unique index (migration 0062) stops that replay writing a second timeline entry for one refund. Every other writer puts an evt_... there and this one puts re_..., which cannot collide.

The winning column moves only on a FULL refund, only from its charged value, exactly the rule the charge.refunded webhook handler already applies to both tracks. A partial leaves it at its charged value, which is what admits a second partial later. The update is a compare-and-set that counts the rows it changed, because a zero-row PostgREST update answers 204 rather than an error: charge.refunded can arrive from Stripe while this request is in flight, and the loser must not report itself the winner.

The ledger row is written here rather than left to that webhook, which writes one too. The webhook is the mirror for a refund taken in the business's own Stripe dashboard, and relying on it alone would mean a refund issued from this screen might not show up on the screen that issued it until that delivery lands; the idempotency layer above is what keeps the two from double-counting when both do write.

Not engine-gated: 0012 put the deposit policy on book_companies for a salon's colour service as much as for a Saturday night, and both booking tables can carry a paid deposit or a charged no-show fee. Admin-only, and that gate is the whole authorization: the button is hidden from a staff login, but hiding a button is cosmetic (see src/lib/auth/require-member.ts).

Request body application/json

FieldTypeNotes
paymentIntentId*string

The Stripe PaymentIntent the deposit was charged on, as pi_.... This is the join key back to the booking, the same one the webhook uses and the one 0012 built unique indexes for. Shape-checked before it is sent to Stripe: a charge or customer id in this field would refund something adjacent to what was meant.

amountCentsinteger(min 1)

Smallest currency unit, so 2500 is A$25.00 and 2500 is also Y2,500 (see isZeroDecimal in src/lib/billing/deposits.ts). Omit for a full refund. Must be a positive integer no larger than the original charge; 0 is a typo and is refused rather than treated as "all of it".

Responses

200

The refund was taken at Stripe.

FieldTypeNotes
ok*true
refundId*string | null

Stripe's refund id. Null only when `amountCents` was omitted (a full-refund request) and the charge was already fully refunded elsewhere: charge_already_refunded answers that case as success, since the payment ends up fully refunded either way. A request for a SPECIFIC amountCents that hits the same already-refunded charge answers 400 instead, since none of that specific amount could be honoured.

amountCents*integer

What actually went back, as Stripe reported it. 0 alongside a null refundId.

fullyRefunded*boolean

Whether this refund emptied the charge. Read off the expanded charge's amount_refunded, not derived locally, because two partial refunds that add up to the whole are a full refund and only Stripe holds that running total.

depositStatus*string | null

The booking's deposit state after this call, re-read rather than assumed when the compare-and-set changed no rows. refunded after a full refund of a real deposit; still paid after a partial. Unchanged (and never paid) when this call refunded the OTHER track instead: a card-on-file no-show fee.

cardOnFileStatus*string | null

The booking's card-on-file state after this call, same re-read discipline as depositStatus. refunded after a full refund of a no-show fee; still charged after a partial. Unchanged (and never charged) when this call refunded the OTHER track instead: a real deposit.

400

A malformed body, a missing or non-UUID Idempotency-Key, a paymentIntentId that is not a pi_... string, a non-integer or non-positive amountCents, an amount larger than the original charge, or Stripe refusing the refund (realistically: larger than what is left after an earlier one, where Stripe's own wording comes back verbatim).

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No booking in this organization holds that payment intent. Covers "no such payment" and "not your payment" with one answer on purpose: distinguishing them would confirm that some other business holds it.

409

Nothing to refund here. Neither track is in a refundable state (a real deposit's refunded/forfeited are settled, pending was never charged, none never owed anything; a card-on-file no-show fee's refunded/failed/saved/charging/none are each either settled or not yet charged), the booking has no payment intent at all, or the business has disconnected Stripe since taking the payment, in which case nothing can move the money and marking the row refunded anyway would be a lie in the one place a guest checks.

503

The platform has no Stripe keys, so no refund can be taken. Same posture as the rest of the deposit path.

Raw OpenAPI operation
{
  "summary": "Refund a guest deposit or a card-on-file no-show fee, in full or in part (admin)",
  "description": "What the Refund button on `/payments` calls. Until this existed a refund only ever happened as a **side effect of cancelling a booking** (`resolveDepositOnCancel`), so a business returning part of a deposit on a booking it was still honouring had to do it from its own Stripe dashboard. This is a separate route from the cancel paths deliberately: those refund because a booking went away, this one refunds because a human decided an amount.\n\n**Two refundable tracks share one payment intent (migration 0129).** A real deposit marks `deposit_status = 'paid'`; a card-on-file no-show fee never touches that column and marks `card_on_file_status = 'charged'` instead. A booking is never both, so exactly one of the two response fields below reflects a state this call actually changed and the other simply reports the untouched value.\n\n**A direct refund on the connected account.** Either kind of payment is a direct charge, so the money leaves the business's own Stripe balance, never the platform's, and no application fee is unwound because none was ever taken.\n\n`amountCents` omitted means the whole remaining balance, which is not the same as sending the amount the ledger thinks is outstanding: **Stripe holds the real running total**, so \"everything left\" is always right, while a computed figure can be short by any refund the Connect webhook never mirrored back. A partial refund is bounded locally by what the ledger's own `succeeded` row for this exact payment intent recorded (not the booking's frozen deposit snapshot, which a tiered no-show fee can charge less than); Stripe is the authority on what is actually left, and its own message comes back verbatim at 400 when the amount exceeds it.\n\n**An `Idempotency-Key` header is REQUIRED**, unlike the two guest checkout routes where an absent header means \"no idempotency requested\" so an older client bundle keeps working. There is no older client here, and a refund with no guard is not worth being permissive about. Three layers hang off that one key: `withIdempotency` runs the route at most once per key and replays the first response (the double-clicked Confirm); the same key goes to Stripe as its own idempotency option, covering the window our table cannot, since it deletes its key row on a thrown error so a transient failure stays retryable (a crash after Stripe refunded and before the response was sent); and the ledger row carries the **refund** id in `stripe_event_id`, whose unique index (migration 0062) stops that replay writing a second timeline entry for one refund. Every other writer puts an `evt_...` there and this one puts `re_...`, which cannot collide.\n\n**The winning column moves only on a FULL refund, only from its charged value**, exactly the rule the `charge.refunded` webhook handler already applies to both tracks. A partial leaves it at its charged value, which is what admits a second partial later. The update is a compare-and-set that counts the rows it changed, because a zero-row PostgREST update answers 204 rather than an error: `charge.refunded` can arrive from Stripe while this request is in flight, and the loser must not report itself the winner.\n\nThe ledger row is written here rather than left to that webhook, which writes one too. The webhook is the mirror for a refund taken in the business's own Stripe dashboard, and relying on it alone would mean a refund issued from this screen might not show up on the screen that issued it until that delivery lands; the idempotency layer above is what keeps the two from double-counting when both do write.\n\nNot engine-gated: 0012 put the deposit policy on `book_companies` for a salon's colour service as much as for a Saturday night, and both booking tables can carry a paid deposit or a charged no-show fee. Admin-only, and that gate is the whole authorization: the button is hidden from a `staff` login, but hiding a button is cosmetic (see `src/lib/auth/require-member.ts`).",
  "tags": [
    "Shared"
  ],
  "operationId": "createPaymentRefund",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "paymentIntentId"
          ],
          "properties": {
            "paymentIntentId": {
              "type": "string",
              "description": "The Stripe PaymentIntent the deposit was charged on, as `pi_...`. This is the join key back to the booking, the same one the webhook uses and the one 0012 built unique indexes for. Shape-checked before it is sent to Stripe: a charge or customer id in this field would refund something adjacent to what was meant."
            },
            "amountCents": {
              "type": "integer",
              "minimum": 1,
              "description": "Smallest currency unit, so 2500 is A$25.00 and 2500 is also Y2,500 (see `isZeroDecimal` in `src/lib/billing/deposits.ts`). **Omit for a full refund.** Must be a positive integer no larger than the original charge; 0 is a typo and is refused rather than treated as \"all of it\"."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The refund was taken at Stripe.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "refundId",
              "amountCents",
              "fullyRefunded",
              "depositStatus",
              "cardOnFileStatus"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "refundId": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Stripe's refund id. **Null only when `amountCents` was omitted** (a full-refund request) **and the charge was already fully refunded elsewhere**: `charge_already_refunded` answers that case as success, since the payment ends up fully refunded either way. A request for a SPECIFIC amountCents that hits the same already-refunded charge answers 400 instead, since none of that specific amount could be honoured."
              },
              "amountCents": {
                "type": "integer",
                "description": "What actually went back, as Stripe reported it. 0 alongside a null `refundId`."
              },
              "fullyRefunded": {
                "type": "boolean",
                "description": "Whether this refund emptied the charge. Read off the expanded charge's `amount_refunded`, not derived locally, because two partial refunds that add up to the whole are a full refund and only Stripe holds that running total."
              },
              "depositStatus": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The booking's deposit state after this call, re-read rather than assumed when the compare-and-set changed no rows. `refunded` after a full refund of a real deposit; still `paid` after a partial. Unchanged (and never `paid`) when this call refunded the OTHER track instead: a card-on-file no-show fee."
              },
              "cardOnFileStatus": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The booking's card-on-file state after this call, same re-read discipline as `depositStatus`. `refunded` after a full refund of a no-show fee; still `charged` after a partial. Unchanged (and never `charged`) when this call refunded the OTHER track instead: a real deposit."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A malformed body, a missing or non-UUID `Idempotency-Key`, a `paymentIntentId` that is not a `pi_...` string, a non-integer or non-positive `amountCents`, an amount larger than the original charge, **or Stripe refusing the refund** (realistically: larger than what is left after an earlier one, where Stripe's own wording comes back 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": "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 booking in this organization holds that payment intent. Covers \"no such payment\" and \"not your payment\" with one answer on purpose: distinguishing them would confirm that some other business holds it.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Nothing to refund here. Neither track is in a refundable state (a real deposit's `refunded`/`forfeited` are settled, `pending` was never charged, `none` never owed anything; a card-on-file no-show fee's `refunded`/`failed`/`saved`/`charging`/`none` are each either settled or not yet charged), the booking has no payment intent at all, or the business has disconnected Stripe since taking the payment, in which case nothing can move the money and marking the row refunded anyway would be a lie in the one place a guest checks.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "The platform has no Stripe keys, so no refund can be taken. Same posture as the rest of the deposit path.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/payments/takings

The Takings report: gross, refunded and net Stripe-collected money over a date range

Session cookie

Sums book_payment_events (0062) for this company between from and to, inclusive, UTC calendar dates. Stripe-collected money only: deposits and no-show fees, both of which land here the instant they succeed or get refunded. A close-out sale paid in cash, on the venue's own card machine or by bank transfer moves no Stripe money and writes no row here, so none of that is in this total (see lib/billing/takings.ts). Readable by any member, the same gate the Transactions tab this report summarises already uses: it is an aggregate of data that tab already shows in full to anyone who can reach /payments. format=csv returns the same range as a downloadable, line-per-event file instead of the JSON summary.

Parameters

  • from*querystring (date)

    UTC calendar date, YYYY-MM-DD, inclusive.

  • to*querystring (date)

    UTC calendar date, YYYY-MM-DD, inclusive. Must not be before from, and the range cannot exceed five years.

  • formatquery"json" | "csv"optional

    Defaults to json. csv returns Content-Type: text/csv with Content-Disposition: attachment instead of the summary below.

Responses

200

The summary, or a CSV attachment when format=csv.

FieldTypeNotes
summary*object
summary.grossCents*integer

Sum of every succeeded event in range, before any refund.

summary.refundedCents*integer

Sum of every refunded/partially_refunded event in range.

summary.netCents*integer

grossCents minus refundedCents: what the venue actually kept.

summary.gstCents*integer | null

The GST already inside netCents, at the venue's registered rate. Null when not GST-registered, or this product has no rate for the venue's country (see gstRateFor, lib/billing/tax.ts): never 0, which would falsely claim a registered venue owed no GST this period.

summary.paymentCount*integer

How many succeeded events made up grossCents.

currency*string

The organization's own current currency. Rows recorded in any other currency are excluded from the totals, never guessed into this one.

rows*object[]

Every matching event, oldest first, for a caller building its own breakdown.

rows[].id*string
rows[].createdAt*string (date-time)
rows[].type*"initiated" | "succeeded" | "failed" | "refunded" | "partially_refunded" | "disputed" | "dispute_closed"
rows[].amountCents*integer | null
rows[].reason*string | null
400

from/to missing, not YYYY-MM-DD, from after to, or the range exceeds five years.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "The Takings report: gross, refunded and net Stripe-collected money over a date range",
  "description": "Sums book_payment_events (0062) for this company between `from` and `to`, inclusive, UTC calendar dates. **Stripe-collected money only**: deposits and no-show fees, both of which land here the instant they succeed or get refunded. A close-out sale paid in cash, on the venue's own card machine or by bank transfer moves no Stripe money and writes no row here, so none of that is in this total (see `lib/billing/takings.ts`). Readable by any member, the same gate the Transactions tab this report summarises already uses: it is an aggregate of data that tab already shows in full to anyone who can reach `/payments`. `format=csv` returns the same range as a downloadable, line-per-event file instead of the JSON summary.",
  "tags": [
    "Shared"
  ],
  "operationId": "getTakingsReport",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "parameters": [
    {
      "name": "from",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "format": "date"
      },
      "description": "UTC calendar date, YYYY-MM-DD, inclusive."
    },
    {
      "name": "to",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "format": "date"
      },
      "description": "UTC calendar date, YYYY-MM-DD, inclusive. Must not be before `from`, and the range cannot exceed five years."
    },
    {
      "name": "format",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string",
        "enum": [
          "json",
          "csv"
        ]
      },
      "description": "Defaults to `json`. `csv` returns `Content-Type: text/csv` with `Content-Disposition: attachment` instead of the summary below."
    }
  ],
  "responses": {
    "200": {
      "description": "The summary, or a CSV attachment when `format=csv`.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "summary",
              "currency",
              "rows"
            ],
            "properties": {
              "summary": {
                "type": "object",
                "required": [
                  "grossCents",
                  "refundedCents",
                  "netCents",
                  "gstCents",
                  "paymentCount"
                ],
                "properties": {
                  "grossCents": {
                    "type": "integer",
                    "description": "Sum of every `succeeded` event in range, before any refund."
                  },
                  "refundedCents": {
                    "type": "integer",
                    "description": "Sum of every `refunded`/`partially_refunded` event in range."
                  },
                  "netCents": {
                    "type": "integer",
                    "description": "grossCents minus refundedCents: what the venue actually kept."
                  },
                  "gstCents": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "The GST already inside netCents, at the venue's registered rate. Null when not GST-registered, or this product has no rate for the venue's country (see `gstRateFor`, `lib/billing/tax.ts`): never 0, which would falsely claim a registered venue owed no GST this period."
                  },
                  "paymentCount": {
                    "type": "integer",
                    "description": "How many `succeeded` events made up grossCents."
                  }
                }
              },
              "currency": {
                "type": "string",
                "description": "The organization's own current currency. Rows recorded in any other currency are excluded from the totals, never guessed into this one."
              },
              "rows": {
                "type": "array",
                "description": "Every matching event, oldest first, for a caller building its own breakdown.",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "createdAt",
                    "type",
                    "amountCents",
                    "reason"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "initiated",
                        "succeeded",
                        "failed",
                        "refunded",
                        "partially_refunded",
                        "disputed",
                        "dispute_closed"
                      ]
                    },
                    "amountCents": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "reason": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`from`/`to` missing, not YYYY-MM-DD, `from` after `to`, or the range exceeds five years.",
      "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`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/payments/no-show-fee

Preview the suggested tier amount for a booking (admin)

Session cookieadmin only

Read-only preview the no-show fee dialog fetches before it ever opens, so its "Full amount" tab can pre-fill with the tier this booking's own cancellation notice actually earned (migration 0137, book_no_show_fee_tiers) rather than always the frozen deposit. No charge, no idempotency key, nothing written.

suggestedTierCents resolves the strictest configured tier whose window this booking's notice (starts_at minus cancelled_at) falls inside; among several qualifying tiers the tightest (smallest thresholdHours) wins. A booking with no cancelled_at recorded (a genuine no-show, or a legacy pre-0137 cancellation) or an explicit no_show status instead falls back to the single strictest tier configured, so a no-show can never suggest less than an actual late cancellation would. Null when the company has configured no tiers, or the booking has no positive deposit_amount_cents to suggest a share of: in both cases the dialog falls back to today's exact behaviour, the full frozen deposit.

Parameters

  • bookingTable*query"book_appointments" | "book_reservations"
  • bookingId*querystring

Responses

200

The suggestion, or null.

FieldTypeNotes
suggestedTierCents*integer | null

What the matching tier suggests, capped at deposit_amount_cents either way. Null when no tier applies or none are configured.

400

bookingTable/bookingId missing or bookingTable not one of the two known tables.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No booking in this organization matches that id/table pair.

Raw OpenAPI operation
{
  "summary": "Preview the suggested tier amount for a booking (admin)",
  "description": "Read-only preview the no-show fee dialog fetches before it ever opens, so its \"Full amount\" tab can pre-fill with the tier this booking's own cancellation notice actually earned (migration 0137, `book_no_show_fee_tiers`) rather than always the frozen deposit. No charge, no idempotency key, nothing written.\n\n`suggestedTierCents` resolves the strictest configured tier whose window this booking's notice (`starts_at` minus `cancelled_at`) falls inside; among several qualifying tiers the tightest (smallest `thresholdHours`) wins. A booking with no `cancelled_at` recorded (a genuine no-show, or a legacy pre-0137 cancellation) or an explicit `no_show` status instead falls back to the single strictest tier configured, so a no-show can never suggest less than an actual late cancellation would. Null when the company has configured no tiers, or the booking has no positive `deposit_amount_cents` to suggest a share of: in both cases the dialog falls back to today's exact behaviour, the full frozen deposit.",
  "tags": [
    "Shared"
  ],
  "operationId": "previewNoShowFeeTier",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "bookingTable",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string",
        "enum": [
          "book_appointments",
          "book_reservations"
        ]
      }
    },
    {
      "name": "bookingId",
      "in": "query",
      "required": true,
      "schema": {
        "type": "string"
      }
    }
  ],
  "responses": {
    "200": {
      "description": "The suggestion, or null.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "suggestedTierCents"
            ],
            "properties": {
              "suggestedTierCents": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "What the matching tier suggests, capped at deposit_amount_cents either way. Null when no tier applies or none are configured."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`bookingTable`/`bookingId` missing or `bookingTable` not one of the two known tables.",
      "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 booking in this organization matches that id/table pair.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/payments/no-show-fee

Charge a card-on-file no-show fee (admin)

Session cookieadmin only

What the "Charge fee" action on /payments, a booking's own detail dialog, and the reservations/bookings lists all call. The one MANUAL step in the whole card-on-file mechanism (migration 0129): every route upstream of this one only ever SAVES a card at zero charge (createSetupIntent, the guest checkout routes), and nothing is ever charged until a human clicks the button that POSTs here, on a no-show or a late cancellation staff decided is chargeable. There is no cron, no trigger, nothing on a timer.

A direct charge on the connected account, off-session against the card saved at booking. The fee amount snapshotted into deposit_amount_cents at booking time (the same column a real deposit uses) is the CEILING; a later policy change never moves it.

`amountCents` (migration 0137) is optional and, when sent, is clamped server-side against that same deposit_amount_cents before it is trusted for anything: strictly greater than 0 and no larger than the deposit. Omitted or null keeps the pre-0137 behaviour exactly: the full frozen deposit, charged unconditionally. This is what lets the dialog charge less than the disclosed ceiling for a booking whose own cancellation notice earned a lighter tier (see GET above), while a suggested figure or any custom amount a staff member types is never trusted on the client's word alone.

An `Idempotency-Key` header is REQUIRED, identical in shape to /api/payments/refunds: withIdempotency runs the route at most once per key and replays the first response, the same key goes to Stripe as its own idempotency option, and the ledger row carries the charged PaymentIntent id in stripe_event_id (this charge's own webhook delivery is a deliberate no-op, so there is no separate evt_... to dedupe against). A fourth layer this route needs that refunds does not: a compare-and-set claim (card_on_file_status'charging') before Stripe is ever called. A refund is safe from two racing keys because Stripe itself is the ceiling (a second refund attempt fails outright); a charge has no such ceiling: two off-session PaymentIntents against the same saved card, carrying two different keys, would both succeed at Stripe and charge the guest twice. The claim closes that window; a losing request never reaches Stripe at all.

Admin-only, and that gate is the whole authorization: the button is hidden from a staff login, but hiding a button is cosmetic (see src/lib/auth/require-member.ts). Not engine-gated: card_on_file_enabled lives on book_companies for either vertical, and both booking tables carry the 0129 columns this route reads and writes.

Request body application/json

FieldTypeNotes
bookingTable*"book_appointments" | "book_reservations"

Which engine's table the booking lives in. Scoped by id AND company_id through the RLS-scoped client, so naming another organization's booking id here cannot reach it.

bookingId*string

The booking to charge. Must currently be eligible: a saved or previously-failed card on file, with a fee amount locked in.

amountCentsinteger(min 1)

Migration 0137. Optional. Clamped server-side to 0 < amountCents <= deposit_amount_cents; a value outside that range is a 400. Omit for the pre-0137 behaviour: the full frozen deposit, unconditionally.

Responses

200

The fee was charged at Stripe.

FieldTypeNotes
ok*true
paymentIntentId*string

The Stripe PaymentIntent this charge created, as pi_.... Written to stripe_payment_intent_id on the booking, the same column a real deposit charge uses.

amountCents*integer

What was actually charged: either the clamped amountCents sent, or the full frozen deposit when it was omitted.

suggestedTierCentsinteger | null

The same tier suggestion GET returns, recomputed fresh after the charge rather than threaded through from an earlier call. Migration 0137.

400

A missing or non-UUID Idempotency-Key, an invalid body, a bookingTable/bookingId not naming a real booking, or an amountCents that is not a positive integer, or is larger than this booking's own deposit_amount_cents.

401

No valid session cookie. {"error":"Not signed in"}.

402

Stripe declined the charge (a real decline, an expired card, or similar). card_on_file_status is set back to failed, which keeps the retry button live.

403

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

404

No booking in this organization matches that id/table pair.

409

Not eligible to charge right now: the booking has no card on file, no fee amount was set, a charge is already in progress or already succeeded, this fee was already refunded, or this business has disconnected Stripe since the card was saved.

503

The platform has no Stripe keys, so no charge can be taken. Same posture as the rest of the deposit path.

Raw OpenAPI operation
{
  "summary": "Charge a card-on-file no-show fee (admin)",
  "description": "What the \"Charge fee\" action on `/payments`, a booking's own detail dialog, and the reservations/bookings lists all call. The one MANUAL step in the whole card-on-file mechanism (migration 0129): every route upstream of this one only ever SAVES a card at zero charge (`createSetupIntent`, the guest checkout routes), and nothing is ever charged until a human clicks the button that POSTs here, on a no-show or a late cancellation staff decided is chargeable. There is no cron, no trigger, nothing on a timer.\n\n**A direct charge on the connected account**, off-session against the card saved at booking. The fee amount snapshotted into `deposit_amount_cents` at booking time (the same column a real deposit uses) is the CEILING; a later policy change never moves it.\n\n**`amountCents` (migration 0137) is optional and, when sent, is clamped server-side** against that same `deposit_amount_cents` before it is trusted for anything: strictly greater than 0 and no larger than the deposit. Omitted or null keeps the pre-0137 behaviour exactly: the full frozen deposit, charged unconditionally. This is what lets the dialog charge less than the disclosed ceiling for a booking whose own cancellation notice earned a lighter tier (see `GET` above), while a suggested figure or any custom amount a staff member types is never trusted on the client's word alone.\n\n**An `Idempotency-Key` header is REQUIRED**, identical in shape to `/api/payments/refunds`: `withIdempotency` runs the route at most once per key and replays the first response, the same key goes to Stripe as its own idempotency option, and the ledger row carries the charged PaymentIntent id in `stripe_event_id` (this charge's own webhook delivery is a deliberate no-op, so there is no separate `evt_...` to dedupe against). **A fourth layer this route needs that refunds does not**: a compare-and-set claim (`card_on_file_status` → `'charging'`) *before* Stripe is ever called. A refund is safe from two racing keys because Stripe itself is the ceiling (a second refund attempt fails outright); a charge has no such ceiling: two off-session PaymentIntents against the same saved card, carrying two different keys, would both succeed at Stripe and charge the guest twice. The claim closes that window; a losing request never reaches Stripe at all.\n\nAdmin-only, and that gate is the whole authorization: the button is hidden from a `staff` login, but hiding a button is cosmetic (see `src/lib/auth/require-member.ts`). Not engine-gated: `card_on_file_enabled` lives on `book_companies` for either vertical, and both booking tables carry the 0129 columns this route reads and writes.",
  "tags": [
    "Shared"
  ],
  "operationId": "chargeNoShowFee",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "bookingTable",
            "bookingId"
          ],
          "properties": {
            "bookingTable": {
              "type": "string",
              "enum": [
                "book_appointments",
                "book_reservations"
              ],
              "description": "Which engine's table the booking lives in. Scoped by id AND company_id through the RLS-scoped client, so naming another organization's booking id here cannot reach it."
            },
            "bookingId": {
              "type": "string",
              "description": "The booking to charge. Must currently be eligible: a saved or previously-failed card on file, with a fee amount locked in."
            },
            "amountCents": {
              "type": "integer",
              "minimum": 1,
              "description": "Migration 0137. Optional. Clamped server-side to `0 < amountCents <= deposit_amount_cents`; a value outside that range is a 400. Omit for the pre-0137 behaviour: the full frozen deposit, unconditionally."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The fee was charged at Stripe.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "paymentIntentId",
              "amountCents"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "paymentIntentId": {
                "type": "string",
                "description": "The Stripe PaymentIntent this charge created, as `pi_...`. Written to `stripe_payment_intent_id` on the booking, the same column a real deposit charge uses."
              },
              "amountCents": {
                "type": "integer",
                "description": "What was actually charged: either the clamped `amountCents` sent, or the full frozen deposit when it was omitted."
              },
              "suggestedTierCents": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "The same tier suggestion `GET` returns, recomputed fresh after the charge rather than threaded through from an earlier call. Migration 0137."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A missing or non-UUID `Idempotency-Key`, an invalid body, a `bookingTable`/`bookingId` not naming a real booking, or an `amountCents` that is not a positive integer, or is larger than this booking's own `deposit_amount_cents`.",
      "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": "Stripe declined the charge (a real decline, an expired card, or similar). `card_on_file_status` is set back to `failed`, which keeps the retry button live.",
      "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 booking in this organization matches that id/table pair.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Not eligible to charge right now: the booking has no card on file, no fee amount was set, a charge is already in progress or already succeeded, this fee was already refunded, or this business has disconnected Stripe since the card was saved.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "The platform has no Stripe keys, so no charge can be taken. Same posture as the rest of the deposit path.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/payments/receipts/{id}/resend

Resend a receipt or tax invoice (admin)

Session cookieadmin only

What the Resend button on the Transactions tab calls. A receipt is minted once, automatically, the moment a deposit is confirmed paid (the Stripe webhook's issueReceipt, migration 0126); this route re-sends the SAME numbered document rather than generating a new one, so the receipt number never changes and the email never gets a second entry in the venue's own sequence.

Synchronous, not queued. The automatic send at issue time goes through the background worker (receipt_send, off the webhook's response path), but a caller who clicks this button wants an answer now, the same distinction POST /api/payments/refunds already draws between the two.

Always actually re-sends, even if one already went out: the idempotency key used here is a fresh one per call rather than the stable per-receipt key the automatic path uses, because reusing that key would make Resend's own request-level dedupe silently swallow the second send. Clicking "Resend" and mailing nobody is the one failure mode this route cannot have.

Admin-only. No Idempotency-Key header requirement, unlike refunds: a duplicate resend costs a nuisance email, not a second charge out of the business's balance, so there is nothing here worth making a caller retry a whole request over.

Parameters

  • id*pathstring

    book_receipts row id.

Responses

200

Sent.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No receipt with that id in this organization.

409

This receipt has no customer email on file, so there is nothing to send it to.

502

The email could not be sent (Resend is unreachable, misconfigured, or rejected it).

Raw OpenAPI operation
{
  "summary": "Resend a receipt or tax invoice (admin)",
  "description": "What the Resend button on the Transactions tab calls. A receipt is minted once, automatically, the moment a deposit is confirmed paid (the Stripe webhook's `issueReceipt`, migration 0126); this route re-sends the SAME numbered document rather than generating a new one, so the receipt number never changes and the email never gets a second entry in the venue's own sequence.\n\n**Synchronous, not queued.** The automatic send at issue time goes through the background worker (`receipt_send`, off the webhook's response path), but a caller who clicks this button wants an answer now, the same distinction `POST /api/payments/refunds` already draws between the two.\n\n**Always actually re-sends**, even if one already went out: the idempotency key used here is a fresh one per call rather than the stable per-receipt key the automatic path uses, because reusing that key would make Resend's own request-level dedupe silently swallow the second send. Clicking \"Resend\" and mailing nobody is the one failure mode this route cannot have.\n\nAdmin-only. No `Idempotency-Key` header requirement, unlike refunds: a duplicate resend costs a nuisance email, not a second charge out of the business's balance, so there is nothing here worth making a caller retry a whole request over.",
  "tags": [
    "Shared"
  ],
  "operationId": "resendReceipt",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "book_receipts row id."
    }
  ],
  "responses": {
    "200": {
      "description": "Sent.",
      "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": "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 receipt with that id in this organization.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "This receipt has no customer email on file, so there is nothing to send it to.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "The email could not be sent (Resend is unreachable, misconfigured, or rejected it).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/transfer

Begin transferring ownership of the organization

Session cookieadmin only

Moves nothing. Creates an awaiting_confirmation row and emails a one-time link to the current owner's own address: that link is the second factor, so an attacker holding a hijacked session can reach this route but cannot read the owner's inbox.

Why an emailed link and not a password re-entry: the product ships Google sign-in, and a Google account has no password to re-enter (the primary owner account on this deployment is one), so a password step would permanently lock out exactly the people most likely to own something. It would also be hazardous to implement; verifying a password on the cookie-bound server client mints a fresh session and writes new auth cookies onto the response, silently rotating the caller's session.

Owner only, not any admin. The target must ALREADY be an admin of the organization; "no such account" and "not an admin here" return the same message deliberately, so this cannot be used to probe which addresses have Gaplessly accounts.

Request body application/json

FieldTypeNotes
email*string(max length 254)

An existing admin of this organization.

Responses

200

Confirmation email sent to the owner. Nothing has changed yet.

FieldType
ok*true
400

Invalid email, transferring to yourself, or the target is not an eligible admin.

401

No valid session cookie. {"error":"Not signed in"}.

402

Ownership transfer is Custom-plan only (src/lib/plan.ts, org_admin, the same flag and marketing bullet as staff-login linking on POST/PATCH /api/providers).

403

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 an admin who does not own the organization (Only the organization owner can transfer ownership).

409

A transfer is already in progress. Enforced by a partial unique index, so two simultaneous clicks cannot both win.

502

The account lookup against GoTrue failed.

503

No absolute base URL configured, or the confirmation email could not be sent; the row is deleted, because a transfer nobody can confirm would also hold the one-live-transfer lock forever.

Raw OpenAPI operation
{
  "summary": "Begin transferring ownership of the organization",
  "description": "Moves nothing. Creates an `awaiting_confirmation` row and emails a one-time link to the **current owner's own address**: that link is the second factor, so an attacker holding a hijacked session can reach this route but cannot read the owner's inbox.\n\n**Why an emailed link and not a password re-entry:** the product ships Google sign-in, and a Google account has no password to re-enter (the primary owner account on this deployment is one), so a password step would permanently lock out exactly the people most likely to own something. It would also be hazardous to implement; verifying a password on the cookie-bound server client mints a fresh session and writes new auth cookies onto the response, silently rotating the caller's session.\n\nOwner only, not any admin. The target must ALREADY be an admin of the organization; \"no such account\" and \"not an admin here\" return the same message deliberately, so this cannot be used to probe which addresses have Gaplessly accounts.",
  "tags": [
    "Shared"
  ],
  "operationId": "startOwnershipTransfer",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "email"
          ],
          "additionalProperties": false,
          "properties": {
            "email": {
              "type": "string",
              "maxLength": 254,
              "description": "An existing admin of this organization."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Confirmation email sent to the owner. Nothing has changed yet.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Invalid email, transferring to yourself, or the target is not an eligible admin.",
      "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": "Ownership transfer is Custom-plan only (src/lib/plan.ts, `org_admin`, the same flag and marketing bullet as staff-login linking on POST/PATCH /api/providers).",
      "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 an admin who does not own the organization (`Only the organization owner can transfer ownership`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "A transfer is already in progress. Enforced by a partial unique index, so two simultaneous clicks cannot both win.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "The account lookup against GoTrue failed.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "503": {
      "description": "No absolute base URL configured, or the confirmation email could not be sent; the row is deleted, because a transfer nobody can confirm would also hold the one-live-transfer lock forever.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/transfer/confirm

Confirm a pending ownership transfer (owner, via emailed link)

Session cookie

POST, never GET, and that is a security decision rather than REST manners. This URL arrives in an inbox, and inboxes are full of things that fetch URLs without a human: Outlook Safe Links, mail scanners, Slack unfurls, antivirus proxies. A mutating GET would let a scanner start the cooling-off clock AND burn the single-use token, so the real owner's click would report "already used" and they could not tell a scanner from an attacker. The emailed link lands on a page, which posts here on a real click.

Two factors are required: the token AND the owner's own session. A link that leaked; forwarded, logged by a mail gateway, in a screenshot; is inert on its own.

Single use is structural: confirming nulls the whole token triple, so there is no used flag anyone can forget to check. Comparison is constant-time against a real digest even on a miss, and every failure returns one identical message so a leaked link cannot be used to map out why it failed.

Request body application/json

FieldTypeNotes
token*string

The oct_-prefixed token from the email. Parsed by fixed offset, never by splitting on _ (base64url contains _).

Responses

200

Confirmed. The cooling-off window has started.

FieldType
ok*true
effectiveAt*string (date-time)
400

One message for every failure: unknown, malformed, expired, already used, not yours, or not awaiting confirmation.

401

No session. The token alone is deliberately not enough.

Raw OpenAPI operation
{
  "summary": "Confirm a pending ownership transfer (owner, via emailed link)",
  "description": "**POST, never GET, and that is a security decision rather than REST manners.** This URL arrives in an inbox, and inboxes are full of things that fetch URLs without a human: Outlook Safe Links, mail scanners, Slack unfurls, antivirus proxies. A mutating GET would let a scanner start the cooling-off clock AND burn the single-use token, so the real owner's click would report \"already used\" and they could not tell a scanner from an attacker. The emailed link lands on a page, which posts here on a real click.\n\n**Two factors are required: the token AND the owner's own session.** A link that leaked; forwarded, logged by a mail gateway, in a screenshot; is inert on its own.\n\nSingle use is structural: confirming nulls the whole token triple, so there is no `used` flag anyone can forget to check. Comparison is constant-time against a real digest even on a miss, and every failure returns one identical message so a leaked link cannot be used to map out why it failed.",
  "tags": [
    "Shared"
  ],
  "operationId": "confirmOwnershipTransfer",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "token"
          ],
          "additionalProperties": false,
          "properties": {
            "token": {
              "type": "string",
              "description": "The `oct_`-prefixed token from the email. Parsed by fixed offset, never by splitting on `_` (base64url contains `_`)."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Confirmed. The cooling-off window has started.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "effectiveAt"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "effectiveAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "One message for every failure: unknown, malformed, expired, already used, not yours, or not awaiting confirmation.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "401": {
      "description": "No session. The token alone is deliberately not enough.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/transfer/cancel

Cancel a transfer in progress (owner only)

Session cookieadmin only

The escape hatch the cooling-off window exists to provide: the real owner reads the notification, sees a transfer they did not start, and kills it. Works in both live states.

Owner only, not any admin. An admin who could unilaterally veto could make transfer permanently un-completable in any organization with one disgruntled admin; admins are notified instead and can escalate. Compare-and-set on status, so this cannot race the cron into cancelling a transfer that already completed.

Responses

200

Cancelled. Ownership is unchanged.

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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 an admin who does not own the organization.

404

There is no transfer in progress.

Raw OpenAPI operation
{
  "summary": "Cancel a transfer in progress (owner only)",
  "description": "The escape hatch the cooling-off window exists to provide: the real owner reads the notification, sees a transfer they did not start, and kills it. Works in both live states.\n\n**Owner only, not any admin.** An admin who could unilaterally veto could make transfer permanently un-completable in any organization with one disgruntled admin; admins are notified instead and can escalate. Compare-and-set on status, so this cannot race the cron into cancelling a transfer that already completed.",
  "tags": [
    "Shared"
  ],
  "operationId": "cancelOwnershipTransfer",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "Cancelled. Ownership is unchanged.",
      "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": "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 an admin who does not own the organization.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "There is no transfer in progress.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/onboarding/complete

Create a business: a new organization, or another one in the caller's own (onboarding)

Session cookie

Where a business is born, now that a tenant is two rows deep (migration 0031). The only creation path: POST /api/companies was a second one and was deleted on 2026-07-29, having never had a caller and having created companies with no organization above them.

Two modes, decided by the caller's `company_id` claim, over one identical write set.

No claim: a brand new tenant. Creates book_organizations -> book_companies (with the structured address) -> book_org_members (admin) -> book_organization_members (owner) -> the company_id claim. organizationName is required.

A claim, and the caller OWNS the organization their active business belongs to: adds a business to that organization. Same writes minus the two that belong to an organization's own creation: no book_organizations row, and no second book_organization_members row (it exists, and unique (organization_id, user_id) would reject it). organizationName is ignored: the organization is resolved from the caller's verified ownership, never from the body.

A claim, and the caller does NOT own it: 409, exactly as before. A plain admin or staff member cannot create a business.

The target organization is the one the caller's ACTIVE business belongs to, not "any organization this user owns". Those coincide today; the active-business rule is the one that stays unambiguous if they ever stop coinciding, and it matches what the operator is looking at.

requireMember() is deliberately not used, for the reason POST /api/organization/switch-business sets out: it answers whether the caller is a member of the business they are currently IN, which is not the permission being asked for. The book_organization_members owner row is the authorization, read with service-role and pinned to the caller's own verified user.id.

Service-role throughout, because every write either happens BEFORE the caller's JWT carries a claim (new tenant) or against a DIFFERENT company than the one it names (added business). RLS would reject both. Each step rolls back everything before it, but only the organization it created, never one that was already there.

The claim moves to the new business in both modes, which in the second is a switch: the operator has just created a venue with no services, staff or tables, and every one of those screens is scoped by the claim.

Hours and periods are best-effort and are NOT rolled back. A business with no hours is an ordinary supported state that both screens render; discarding a successfully created one over a schedule insert would trade a small self-correcting gap for the loss of all the operator's work. The atomic replace RPCs are structurally unusable here; both read company_id off the caller's JWT, which at that instant names no company or the previous one, so they would write the schedule onto the wrong venue.

Request body application/json

FieldTypeNotes
businessType*"appointments" | "hospitality"

Which engine this business runs. Settable here and nowhere else; immutable afterwards. NOT defaulted; silently picking a vertical would decide the whole dashboard subtree on the operator's behalf. A second business is free to differ from the first: an organization can own a salon and a restaurant.

organizationNamestring | null(max length 120)

The parent that can own several businesses. Required when creating a new organization, ignored when adding to an existing one: so it is not in required here, because whether it is depends on the mode. Sending it in the add mode is harmless and is what the wizard does, rather than changing the body shape between the two.

businessName*string(min length 1, max length 120)

This location. Equal to organizationName for a single-site operator; the thing that distinguishes them once a second one exists.

slug*string(pattern ^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$)

2-48 lowercase letters, numbers or hyphens. Reserved words are refused.

industrystring | null(max length 80)

Must not contradict businessType.

addressstring | null(max length 240)

One free-text line, as a human reads it.

phone*string(min length 1, max length 40)

REQUIRED, and the only one of these location fields that is. The public number, prefilled from Places in the country's national format. Capped, not validated, no regex here is worth rejecting a real number over. It is what the booking page renders as the way to reach the venue, and what a party too big to book online is told to ring, so a business created without one is unreachable from day one.

countrystring | null(max length 80)
statestring | null(max length 80)
citystring | null(max length 80)
postalCodestring | null(max length 24)
googlePlaceIdstring | null(max length 300)

Set only when the operator accepted a Google Places match. Length-capped rather than pattern-checked; Google publishes no grammar for these.

latnumber | null(min -90, max 90)
lngnumber | null(min -180, max 180)
hoursobject[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

periodDaysobject[](max items 21)

Hospitality only, same DayWindow shape as hours. Folded into ONE service period named "Service" with simple-mode defaults (90 minute turn, 15 minute interval, no cover cap), so the Periods screen opens in simple mode rather than dropping the operator into the advanced editor over values they never chose.

periodDays[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

periodDays[].startTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM.

periodDays[].endTime*string(pattern ^([01]\d|2[0-3]):[0-5]\d$)

HH:MM, strictly after startTime.

codestring | null

The beta-access code (migration 0188). Required, and checked, ONLY when creating a brand new organization: ignored entirely in add-a-business mode, same as organizationName above. Absent or invalid is 400/404/410/403 below rather than silently treated as "no code needed".

Responses

200

Created. The caller must call refreshSession() before navigating, or every dashboard query returns zero rows.

FieldType
ok*true
company*object
company.idstring (uuid)
company.namestring
company.slugstring
organization*object
organization.idstring (uuid)
organization.namestring
400

A validation message, a missing organizationName when creating a new organization, a missing beta code when creating a new organization (reason: "beta_code_required"), or a Postgres error creating the organization or business.

401

No valid session cookie. A plain signed-in check, not requireMember(); in one mode the caller has no organization yet by definition, and in the other the check would be against the wrong business.

402

The organization has no qualifying venue yet. Add-a-business mode only; creating a FIRST organization is never capped, because a tenant that cannot exist cannot pay for anything.

Raised AFTER the ownership 409 on purpose: a caller who may not create a business at all is not told what the org pays for. Pay-per-venue (2026-08-02): every additional venue needs its own independent Pro-or-above subscription, so this is no longer a numeric seat count; read through the same organizationHasQualifyingVenue() that produces atLocationCap on GET /api/organization/businesses, so what the switcher offers and what this route permits cannot drift.

Also the fail-closed answer: an org whose billing rows are missing, whose tier strings are unrecognised, or whose subscriptions are not active/trialing anywhere is treated as not qualifying, and a read that errors is treated the same way.

FieldTypeNotes
error*string

A plain message explaining that an existing business needs to be on Pro or above first.

reason*"no_qualifying_venue"
403

New-organization mode only: the beta code is bound to a different email than the signed-in account (reason: "beta_code_wrong_email").

404

New-organization mode only: code does not parse as a beta code, or does not match any minted one (reason: "beta_code_invalid"). Same anti-enumeration posture as POST /api/invites/accept's identical-shaped 404: a malformed code and a nonexistent one answer identically.

409

The slug is taken, or the account already has an organization AND does not own it; a plain admin or staff member cannot create a business. An owner adding one to their own organization gets 200. In the slug case any organization row created moments earlier has already been rolled back; one that already existed is untouched.

410

New-organization mode only: the beta code has been revoked (reason: "beta_code_revoked"); already redeemed, either earlier or by a concurrent request to this same code that won the atomic redeem (reason: "beta_code_redeemed"); or has passed its expires_at (reason: "beta_code_expired").

500

A membership or claim write failed; everything created before it was rolled back.

Raw OpenAPI operation
{
  "summary": "Create a business: a new organization, or another one in the caller's own (onboarding)",
  "description": "Where a business is born, now that a tenant is two rows deep (migration 0031). **The only creation path**: `POST /api/companies` was a second one and was deleted on 2026-07-29, having never had a caller and having created companies with no organization above them.\n\n**Two modes, decided by the caller's `company_id` claim, over one identical write set.**\n\n*No claim*: a brand new tenant. Creates `book_organizations` -> `book_companies` (with the structured address) -> `book_org_members` (admin) -> `book_organization_members` (owner) -> the `company_id` claim. `organizationName` is required.\n\n*A claim, and the caller OWNS the organization their active business belongs to*: adds a business to that organization. Same writes minus the two that belong to an organization's own creation: no `book_organizations` row, and no second `book_organization_members` row (it exists, and `unique (organization_id, user_id)` would reject it). `organizationName` is ignored: the organization is resolved from the caller's verified ownership, never from the body.\n\n*A claim, and the caller does NOT own it*: 409, exactly as before. A plain admin or staff member cannot create a business.\n\nThe target organization is the one the caller's ACTIVE business belongs to, not \"any organization this user owns\". Those coincide today; the active-business rule is the one that stays unambiguous if they ever stop coinciding, and it matches what the operator is looking at.\n\n`requireMember()` is deliberately not used, for the reason `POST /api/organization/switch-business` sets out: it answers whether the caller is a member of the business they are currently IN, which is not the permission being asked for. The `book_organization_members` owner row is the authorization, read with service-role and pinned to the caller's own verified `user.id`.\n\nService-role throughout, because every write either happens BEFORE the caller's JWT carries a claim (new tenant) or against a DIFFERENT company than the one it names (added business). RLS would reject both. Each step rolls back everything before it, but only the organization it created, never one that was already there.\n\n**The claim moves to the new business in both modes**, which in the second is a switch: the operator has just created a venue with no services, staff or tables, and every one of those screens is scoped by the claim.\n\n**Hours and periods are best-effort and are NOT rolled back.** A business with no hours is an ordinary supported state that both screens render; discarding a successfully created one over a schedule insert would trade a small self-correcting gap for the loss of all the operator's work. The atomic replace RPCs are structurally unusable here; both read `company_id` off the caller's JWT, which at that instant names no company or the previous one, so they would write the schedule onto the wrong venue.",
  "tags": [
    "Shared"
  ],
  "operationId": "completeOnboarding",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "businessType",
            "businessName",
            "slug",
            "phone"
          ],
          "properties": {
            "businessType": {
              "type": "string",
              "enum": [
                "appointments",
                "hospitality"
              ],
              "description": "Which engine this business runs. Settable here and nowhere else; immutable afterwards. NOT defaulted; silently picking a vertical would decide the whole dashboard subtree on the operator's behalf. A second business is free to differ from the first: an organization can own a salon and a restaurant."
            },
            "organizationName": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 120,
              "description": "The parent that can own several businesses. **Required when creating a new organization, ignored when adding to an existing one**: so it is not in `required` here, because whether it is depends on the mode. Sending it in the add mode is harmless and is what the wizard does, rather than changing the body shape between the two."
            },
            "businessName": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120,
              "description": "This location. Equal to organizationName for a single-site operator; the thing that distinguishes them once a second one exists."
            },
            "slug": {
              "type": "string",
              "pattern": "^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$",
              "description": "2-48 lowercase letters, numbers or hyphens. Reserved words are refused."
            },
            "industry": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 80,
              "description": "Must not contradict `businessType`."
            },
            "address": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 240,
              "description": "One free-text line, as a human reads it."
            },
            "phone": {
              "type": "string",
              "minLength": 1,
              "maxLength": 40,
              "description": "REQUIRED, and the only one of these location fields that is. The public number, prefilled from Places in the country's national format. Capped, not validated, no regex here is worth rejecting a real number over. It is what the booking page renders as the way to reach the venue, and what a party too big to book online is told to ring, so a business created without one is unreachable from day one."
            },
            "country": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 80
            },
            "state": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 80
            },
            "city": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 80
            },
            "postalCode": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 24
            },
            "googlePlaceId": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 300,
              "description": "Set only when the operator accepted a Google Places match. Length-capped rather than pattern-checked; Google publishes no grammar for these."
            },
            "lat": {
              "type": [
                "number",
                "null"
              ],
              "minimum": -90,
              "maximum": 90
            },
            "lng": {
              "type": [
                "number",
                "null"
              ],
              "minimum": -180,
              "maximum": 180
            },
            "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."
            },
            "periodDays": {
              "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": "Hospitality only, same DayWindow shape as `hours`. Folded into ONE service period named \"Service\" with simple-mode defaults (90 minute turn, 15 minute interval, no cover cap), so the Periods screen opens in simple mode rather than dropping the operator into the advanced editor over values they never chose."
            },
            "code": {
              "type": [
                "string",
                "null"
              ],
              "description": "The beta-access code (migration 0188). Required, and checked, ONLY when creating a brand new organization: ignored entirely in add-a-business mode, same as `organizationName` above. Absent or invalid is `400`/`404`/`410`/`403` below rather than silently treated as \"no code needed\"."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Created. The caller must call `refreshSession()` before navigating, or every dashboard query returns zero rows.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "company",
              "organization"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "company": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "name": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string"
                  }
                }
              },
              "organization": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A validation message, a missing `organizationName` when creating a new organization, a missing beta `code` when creating a new organization (`reason: \"beta_code_required\"`), or a Postgres error creating the organization or business.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie. A plain signed-in check, not requireMember(); in one mode the caller has no organization yet by definition, and in the other the check would be against the wrong business.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "402": {
      "description": "The organization has no qualifying venue yet. Add-a-business mode only; creating a FIRST organization is never capped, because a tenant that cannot exist cannot pay for anything.\n\nRaised AFTER the ownership 409 on purpose: a caller who may not create a business at all is not told what the org pays for. Pay-per-venue (2026-08-02): every additional venue needs its own independent Pro-or-above subscription, so this is no longer a numeric seat count; read through the same `organizationHasQualifyingVenue()` that produces `atLocationCap` on `GET /api/organization/businesses`, so what the switcher offers and what this route permits cannot drift.\n\nAlso the fail-closed answer: an org whose billing rows are missing, whose tier strings are unrecognised, or whose subscriptions are not `active`/`trialing` anywhere is treated as not qualifying, and a read that errors is treated the same way.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "error",
              "reason"
            ],
            "properties": {
              "error": {
                "type": "string",
                "description": "A plain message explaining that an existing business needs to be on Pro or above first."
              },
              "reason": {
                "const": "no_qualifying_venue"
              }
            }
          }
        }
      }
    },
    "403": {
      "description": "New-organization mode only: the beta `code` is bound to a different email than the signed-in account (`reason: \"beta_code_wrong_email\"`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "404": {
      "description": "New-organization mode only: `code` does not parse as a beta code, or does not match any minted one (`reason: \"beta_code_invalid\"`). Same anti-enumeration posture as `POST /api/invites/accept`'s identical-shaped 404: a malformed code and a nonexistent one answer identically.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "The slug is taken, or the account already has an organization AND does not own it; a plain admin or staff member cannot create a business. An owner adding one to their own organization gets 200. In the slug case any organization row created moments earlier has already been rolled back; one that already existed is untouched.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "410": {
      "description": "New-organization mode only: the beta `code` has been revoked (`reason: \"beta_code_revoked\"`); already redeemed, either earlier or by a concurrent request to this same code that won the atomic redeem (`reason: \"beta_code_redeemed\"`); or has passed its `expires_at` (`reason: \"beta_code_expired\"`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "A membership or claim write failed; everything created before it was rolled back.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/onboarding/slug-check

Live booking-URL availability while typing, during onboarding

Session cookie

A DB read, not a metered third-party call like places/details next to it: the ceiling here is about typing speed and abuse, not a bill. Lets OnboardingWizard's confirm step show "Available" or "Already taken" as the operator types, instead of only finding out from the 23505 catch on submit (onboarding/complete still has that catch; this route is UX only and changes no enforcement).

validSlug() runs first and for free: an invalid format or a reserved word (api, book, dashboard, ...) never reaches the database, and is a real, displayable answer rather than a 400, since an operator mid-keystroke on a name passes through several too-short prefixes on the way to a valid one.

Request body application/json

FieldTypeNotes
slug*string

Lowercased and trimmed before checking. Usually pre-normalised by the client's own slugify(), but this route re-validates rather than trusting that.

Responses

200

Always answers, never a dead end mid-onboarding.

FieldTypeNotes
available*boolean
reason"taken" | "invalid" | null

invalid covers both a bad format and a reserved word; the client shows one message either way.

400

slug is missing or blank.

401

No valid session cookie. {"error":"Not signed in"}.

429

More than 30 checks in one minute from this account. Carries retry-after. A DB-only limiter (book_rate_limit_consume, 0026), same shape as places/details' own but a generous ceiling since nothing here costs a third party money: this exists against abuse and runaway client bugs, not a bill.

Raw OpenAPI operation
{
  "summary": "Live booking-URL availability while typing, during onboarding",
  "description": "A DB read, not a metered third-party call like places/details next to it: the ceiling here is about typing speed and abuse, not a bill. Lets OnboardingWizard's confirm step show \"Available\" or \"Already taken\" as the operator types, instead of only finding out from the 23505 catch on submit (onboarding/complete still has that catch; this route is UX only and changes no enforcement).\n\nvalidSlug() runs first and for free: an invalid format or a reserved word (`api`, `book`, `dashboard`, ...) never reaches the database, and is a real, displayable answer rather than a 400, since an operator mid-keystroke on a name passes through several too-short prefixes on the way to a valid one.",
  "tags": [
    "Shared"
  ],
  "operationId": "checkOnboardingSlug",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "slug"
          ],
          "additionalProperties": false,
          "properties": {
            "slug": {
              "type": "string",
              "description": "Lowercased and trimmed before checking. Usually pre-normalised by the client's own slugify(), but this route re-validates rather than trusting that."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Always answers, never a dead end mid-onboarding.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "available"
            ],
            "properties": {
              "available": {
                "type": "boolean"
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "taken",
                  "invalid",
                  null
                ],
                "description": "`invalid` covers both a bad format and a reserved word; the client shows one message either way."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`slug` is missing or blank.",
      "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"
          }
        }
      }
    },
    "429": {
      "description": "More than 30 checks in one minute from this account. Carries `retry-after`. A DB-only limiter (book_rate_limit_consume, 0026), same shape as places/details' own but a generous ceiling since nothing here costs a third party money: this exists against abuse and runaway client bugs, not a bill.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/places/details

Look up a Google Business Profile for onboarding prefill

Session cookie

Server-side proxy for Google Places Details (New). Exists so the credential with Details access never reaches a browser: the client-side Autocomplete widget holds a separate, referrer-restricted key that can only produce predictions.

Google's response shape is mapped to this app's own shapes here, so no Google type reaches the client; addressComponents become country/state/city/postalCode, regularOpeningHours.periods become the same DayWindow array WeekHoursEditor already edits.

Always 200, even when nothing is found. The wizard treats "no data" as "stay in manual entry", never as an error, so a miss and a Google-side failure collapse to the same {found:false} rather than becoming a dead end mid-onboarding.

Field selection here is a BILLING decision, not a performance one: Google charges Place Details at the tier of the MOST EXPENSIVE field requested. regularOpeningHours and businessStatus are Enterprise-tier and opening hours are the point of the feature, so this call is already billed at Enterprise, which is what makes nationalPhoneNumber (also Enterprise) and primaryType (Pro, below it) free at the margin. Check the per-field tier table in Google's Place Details docs before adding to the mask; one field a tier up raises the price of every lookup.

The phone number used to be excluded here on the grounds that step 3 showed no phone field, and nothing should be written that the operator cannot see and correct. That reasoning stands; the premise changed: step 3 now shows address and phone, so both are prefilled and both are correctable. websiteUri (Pro tier, also free at the margin) was added 2026-08-19; still not extracted: rating, reviews, photos or editorialSummary/description, all Enterprise+Atmosphere, a tier above what this call already pays. Reviews specifically are strictly worse here than the OAuth Google Business Profile connection (docs/google-business-profile.md) already gets: up to 5 Google-picked reviews with no reply capability, versus that connection's full history and reply-to-review. Not a fit for a call that fires on every signup regardless of whether the business ever uses reviews.

Request body application/json

FieldTypeNotes
placeId*string(max length 300)

From a prediction the operator selected in the Autocomplete widget.

sessionTokenstring | null(max length 128)

The autocomplete session this selection ends, forwarded to Google as ?sessionToken=. This is the billing mechanism, not a nicety: autocomplete requests carrying a token are free PROVIDED a Place Details call arrives with the same one, and are charged per keystroke if none ever does. Optional, and an absent, stale or malformed value is ignored rather than failing the lookup; the worst case is a normally-billed session, never a dead end mid-onboarding.

Responses

200

Either the mapped profile, or {found:false} when Google returned nothing usable.

FieldTypeNotes
found*boolean
namestring | null
addressstring | null
countrystring | null
statestring | null
citystring | null
postalCodestring | null
latnumber | null
lngnumber | null
businessStatusstring | null

Passed through for display only; nothing is gated on it yet.

phonestring | null

National format for the place's own country; the form its customers would dial. Prefills the editable phone field on step 3.

primaryTypestring | null

Raw Google taxonomy (cafe, dumpling_restaurant, barber_shop). Deliberately NOT translated server-side: mapping it onto an industry label depends on the vertical the operator chose, which is wizard state the server does not have. See industryForPlaceType.

websitestring | null

websiteUri from Google, Pro-tier and free at the margin since this call already pays Enterprise for hours/status. Not yet read by the wizard or written to any column; returned for whichever consumer (a website-builder URL import, a simple match-confirmation note) picks it up first.

hoursobject[](max items 21)

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.

hours[].dayOfWeek*integer(min 0, max 6)

0 = Sunday, matching Date#getUTCDay().

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.

400

placeId is missing or too long.

401

No valid session cookie. {"error":"Not signed in"}.

429

More than 20 lookups in one minute from this account. Carries retry-after.

The only cost-based rate limit in the app, and the reason it exists is specific to this route: the field mask above is billed by Google at ENTERPRISE tier per call, and this is the one authenticated endpoint with no company gate (the caller is mid-onboarding and has no company_id yet), so "signed in" is the entire barrier and signup is self-serve. A metered third-party API behind a free account is a bill somebody else can run up in a loop.

Keyed on the user id rather than the IP that /api/book/* uses: there is a real identity here, it is the thing being budgeted, and an account is more expensive to mint than an IP is to rotate. Counted through the same book_rate_limit_consume (0026) with a service-role client, because that function has no EXECUTE grant for authenticated and the limiter fails open.

500

The Places credential is not configured on the server.

Raw OpenAPI operation
{
  "summary": "Look up a Google Business Profile for onboarding prefill",
  "description": "Server-side proxy for Google Places Details (New). Exists so the credential with Details access never reaches a browser: the client-side Autocomplete widget holds a separate, referrer-restricted key that can only produce predictions.\n\nGoogle's response shape is mapped to this app's own shapes here, so no Google type reaches the client; `addressComponents` become country/state/city/postalCode, `regularOpeningHours.periods` become the same DayWindow array `WeekHoursEditor` already edits.\n\n**Always 200, even when nothing is found.** The wizard treats \"no data\" as \"stay in manual entry\", never as an error, so a miss and a Google-side failure collapse to the same `{found:false}` rather than becoming a dead end mid-onboarding.\n\nField selection here is a BILLING decision, not a performance one: Google charges Place Details at the tier of the MOST EXPENSIVE field requested. `regularOpeningHours` and `businessStatus` are Enterprise-tier and opening hours are the point of the feature, so this call is already billed at Enterprise, which is what makes `nationalPhoneNumber` (also Enterprise) and `primaryType` (Pro, below it) free at the margin. Check the per-field tier table in Google's Place Details docs before adding to the mask; one field a tier up raises the price of every lookup.\n\nThe phone number used to be excluded here on the grounds that step 3 showed no phone field, and nothing should be written that the operator cannot see and correct. That reasoning stands; the premise changed: step 3 now shows address and phone, so both are prefilled and both are correctable. `websiteUri` (Pro tier, also free at the margin) was added 2026-08-19; still not extracted: rating, reviews, photos or editorialSummary/description, all Enterprise+Atmosphere, a tier above what this call already pays. Reviews specifically are strictly worse here than the OAuth Google Business Profile connection (docs/google-business-profile.md) already gets: up to 5 Google-picked reviews with no reply capability, versus that connection's full history and reply-to-review. Not a fit for a call that fires on every signup regardless of whether the business ever uses reviews.",
  "tags": [
    "Shared"
  ],
  "operationId": "lookupPlaceDetails",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "placeId"
          ],
          "additionalProperties": false,
          "properties": {
            "placeId": {
              "type": "string",
              "maxLength": 300,
              "description": "From a prediction the operator selected in the Autocomplete widget."
            },
            "sessionToken": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 128,
              "description": "The autocomplete session this selection ends, forwarded to Google as `?sessionToken=`. This is the billing mechanism, not a nicety: autocomplete requests carrying a token are free PROVIDED a Place Details call arrives with the same one, and are charged per keystroke if none ever does. Optional, and an absent, stale or malformed value is ignored rather than failing the lookup; the worst case is a normally-billed session, never a dead end mid-onboarding."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Either the mapped profile, or `{found:false}` when Google returned nothing usable.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "found"
            ],
            "properties": {
              "found": {
                "type": "boolean"
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "address": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "country": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "state": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "city": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "postalCode": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "lat": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "lng": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "businessStatus": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Passed through for display only; nothing is gated on it yet."
              },
              "phone": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "National format for the place's own country; the form its customers would dial. Prefills the editable phone field on step 3."
              },
              "primaryType": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Raw Google taxonomy (cafe, dumpling_restaurant, barber_shop). Deliberately NOT translated server-side: mapping it onto an industry label depends on the vertical the operator chose, which is wizard state the server does not have. See industryForPlaceType."
              },
              "website": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "websiteUri from Google, Pro-tier and free at the margin since this call already pays Enterprise for hours/status. Not yet read by the wizard or written to any column; returned for whichever consumer (a website-builder URL import, a simple match-confirmation note) picks it up first."
              },
              "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": "`placeId` is missing or too long.",
      "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"
          }
        }
      }
    },
    "429": {
      "description": "More than 20 lookups in one minute from this account. Carries `retry-after`.\n\nThe only cost-based rate limit in the app, and the reason it exists is specific to this route: the field mask above is billed by Google at ENTERPRISE tier per call, and this is the one authenticated endpoint with no company gate (the caller is mid-onboarding and has no `company_id` yet), so \"signed in\" is the entire barrier and signup is self-serve. A metered third-party API behind a free account is a bill somebody else can run up in a loop.\n\nKeyed on the user id rather than the IP that `/api/book/*` uses: there is a real identity here, it is the thing being budgeted, and an account is more expensive to mint than an IP is to rotate. Counted through the same `book_rate_limit_consume` (0026) with a service-role client, because that function has no EXECUTE grant for `authenticated` and the limiter fails open.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The Places credential is not configured on the server.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/google-business/connect

Start the Google Business Profile OAuth flow (admin, redirect-only)

Session cookieadmin only

Full top-level navigation, not a fetch: clicking "Connect" in the Reviews module lands here, and this redirects straight to Google's own consent screen for the business.manage scope. access_type=offline + prompt=consent force a refresh token on every grant. Blocked until Google approves this app's own Business Profile API access AND verifies the OAuth consent screen for that scope (see docs/google-business-profile.md); not_configured below is what a deployment without both looks like.

Sets a short-lived, httpOnly gbp_oauth_state cookie carrying a random nonce, echoed back to /api/google-business/callback as state. This is the CSRF defence for this flow: without it, an attacker who starts their OWN consent grant could trick a victim admin into completing it, linking the attacker's Google account to the victim's company.

Every failure redirects back to /reviews?tab=google&gbp_error=<code> rather than returning JSON: there is no client here to react to a JSON body, only a browser mid-navigation.

Responses

200

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

The real response, always. Redirects to Google's consent screen on success. On failure redirects to /reviews?tab=google&gbp_error=<code> instead of erroring: not_admin (no session, or signed in but not an admin) and not_configured (the four GOOGLE_BUSINESS_* env vars are not all set) are the two codes this route itself produces.

401

Structurally reachable (the handler calls requireMember('admin')) but never actually returned as JSON, documented only because this repo's build-contract checker requires it whenever that gate is called. See the 307: an unauthenticated caller is redirected to gbp_error=not_admin instead.

403

Structurally reachable (the handler calls requireMember('admin')) but never actually returned as JSON, same note as 401. A signed-in non-admin is redirected to gbp_error=not_admin instead.

Raw OpenAPI operation
{
  "summary": "Start the Google Business Profile OAuth flow (admin, redirect-only)",
  "description": "Full top-level navigation, not a fetch: clicking \"Connect\" in the Reviews module lands here, and this redirects straight to Google's own consent screen for the `business.manage` scope. `access_type=offline` + `prompt=consent` force a refresh token on every grant. Blocked until Google approves this app's own Business Profile API access AND verifies the OAuth consent screen for that scope (see docs/google-business-profile.md); `not_configured` below is what a deployment without both looks like.\n\nSets a short-lived, httpOnly `gbp_oauth_state` cookie carrying a random nonce, echoed back to `/api/google-business/callback` as `state`. This is the CSRF defence for this flow: without it, an attacker who starts their OWN consent grant could trick a victim admin into completing it, linking the attacker's Google account to the victim's company.\n\nEvery failure redirects back to `/reviews?tab=google&gbp_error=<code>` rather than returning JSON: there is no client here to react to a JSON body, only a browser mid-navigation.",
  "tags": [
    "Shared"
  ],
  "operationId": "connectGoogleBusiness",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "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 Google's consent screen on success. On failure redirects to `/reviews?tab=google&gbp_error=<code>` instead of erroring: `not_admin` (no session, or signed in but not an admin) and `not_configured` (the four `GOOGLE_BUSINESS_*` env vars are not all set) are the two codes this route itself produces."
    },
    "401": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned as JSON, documented only because this repo's build-contract checker requires it whenever that gate is called. See the 307: an unauthenticated caller is redirected to `gbp_error=not_admin` instead."
    },
    "403": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned as JSON, same note as 401. A signed-in non-admin is redirected to `gbp_error=not_admin` instead."
    }
  }
}
get/api/google-business/callback

Google OAuth callback: exchange the code, resolve the location, store the connection

Session cookieadmin only

Where Google sends the browser back to after the owner grants or denies consent: same browser, same session as /connect, which is why this reuses requireMember('admin') rather than a separate scheme.

Validates state against the gbp_oauth_state cookie /connect set; a mismatch (or missing cookie) is gbp_error=state_mismatch rather than proceeding. Exchanges code for tokens, requires a refresh token to be present (a fresh grant with prompt=consent should always include one; a response without one is refused rather than silently stored access-token-only, which would look connected and stop working the moment that token expires).

v1 assumes one Google Business Profile location per connection, matching this app's own one-book_companies-row-per-physical-location model. Takes the caller's first Google account, then within it the location whose metadata.placeId matches this company's own google_place_id (0031, the same id already captured for free during onboarding's Places search), falling back to that account's first location when there is no match or the company never ran that search. A business with several GBP locations connecting the wrong one is a documented v1 limitation (docs/google-business-profile.md), not a silent bug.

Stores the connection via book_google_business_connections (0090): a refresh token AES-256-GCM encrypted at rest, an access token cached alongside it. The Google account's email is read from the signed-in admin's own session, not from Google's token response (which doesn't include it), since a fifth external call for a label shown for reassurance only was not worth it.

Parameters

  • codequerystringoptional

    Google's authorization code. Absent when error is present instead.

  • statequerystringoptional

    Echoed back from /connect; must match the gbp_oauth_state cookie.

  • errorquerystringoptional

    Google's own denial shape, e.g. access_denied when the owner clicks Cancel on the consent screen. Not a fault; they changed their mind.

Responses

200

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

The real response, always. Redirects to /reviews?tab=google&gbp_connected=1 on success. On any failure redirects to /reviews?tab=google&gbp_error=<code>: not_admin, not_configured, cancelled (Google's access_denied), state_mismatch, no_refresh_token, no_gbp_account, no_gbp_location, save_failed, exchange_failed, or google_error (any other value Google sent as ?error=).

401

Structurally reachable (the handler calls requireMember('admin')) but never actually returned as JSON, same note as /connect. Redirected to gbp_error=not_admin instead.

403

Structurally reachable (the handler calls requireMember('admin')) but never actually returned as JSON, same note as /connect. Redirected to gbp_error=not_admin instead.

Raw OpenAPI operation
{
  "summary": "Google OAuth callback: exchange the code, resolve the location, store the connection",
  "description": "Where Google sends the browser back to after the owner grants or denies consent: same browser, same session as `/connect`, which is why this reuses `requireMember('admin')` rather than a separate scheme.\n\nValidates `state` against the `gbp_oauth_state` cookie `/connect` set; a mismatch (or missing cookie) is `gbp_error=state_mismatch` rather than proceeding. Exchanges `code` for tokens, requires a refresh token to be present (a fresh grant with `prompt=consent` should always include one; a response without one is refused rather than silently stored access-token-only, which would look connected and stop working the moment that token expires).\n\n**v1 assumes one Google Business Profile location per connection**, matching this app's own one-`book_companies`-row-per-physical-location model. Takes the caller's first Google account, then within it the location whose `metadata.placeId` matches this company's own `google_place_id` (0031, the same id already captured for free during onboarding's Places search), falling back to that account's first location when there is no match or the company never ran that search. A business with several GBP locations connecting the wrong one is a documented v1 limitation (docs/google-business-profile.md), not a silent bug.\n\nStores the connection via `book_google_business_connections` (0090): a refresh token AES-256-GCM encrypted at rest, an access token cached alongside it. The Google account's email is read from the signed-in admin's own session, not from Google's token response (which doesn't include it), since a fifth external call for a label shown for reassurance only was not worth it.",
  "tags": [
    "Shared"
  ],
  "operationId": "googleBusinessOAuthCallback",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "code",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "Google's authorization code. Absent when `error` is present instead."
    },
    {
      "name": "state",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "Echoed back from `/connect`; must match the `gbp_oauth_state` cookie."
    },
    {
      "name": "error",
      "in": "query",
      "required": false,
      "schema": {
        "type": "string"
      },
      "description": "Google's own denial shape, e.g. `access_denied` when the owner clicks Cancel on the consent screen. Not a fault; they changed their mind."
    }
  ],
  "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 `/reviews?tab=google&gbp_connected=1` on success. On any failure redirects to `/reviews?tab=google&gbp_error=<code>`: `not_admin`, `not_configured`, `cancelled` (Google's `access_denied`), `state_mismatch`, `no_refresh_token`, `no_gbp_account`, `no_gbp_location`, `save_failed`, `exchange_failed`, or `google_error` (any other value Google sent as `?error=`)."
    },
    "401": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned as JSON, same note as `/connect`. Redirected to `gbp_error=not_admin` instead."
    },
    "403": {
      "description": "Structurally reachable (the handler calls `requireMember('admin')`) but never actually returned as JSON, same note as `/connect`. Redirected to `gbp_error=not_admin` instead."
    }
  }
}
post/api/google-business/disconnect

Disconnect this company's Google Business Profile

Session cookieadmin only

Admin-only. Best-effort revokes the stored refresh token against Google's own /revoke endpoint BEFORE deleting the book_google_business_connections row (0090): a revoke that fails (already expired, a network blip) must not block the disconnect, or an owner trying to leave would be stuck because of the very connection they're trying to leave. Cached reviews in book_google_reviews are left as historical record, not deleted; they simply stop refreshing once the connection is gone.

Responses

200

Disconnected (or was already not connected; idempotent).

FieldType
ok*true
401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Disconnect this company's Google Business Profile",
  "description": "Admin-only. Best-effort revokes the stored refresh token against Google's own `/revoke` endpoint BEFORE deleting the `book_google_business_connections` row (0090): a revoke that fails (already expired, a network blip) must not block the disconnect, or an owner trying to leave would be stuck because of the very connection they're trying to leave. Cached reviews in `book_google_reviews` are left as historical record, not deleted; they simply stop refreshing once the connection is gone.",
  "tags": [
    "Shared"
  ],
  "operationId": "disconnectGoogleBusiness",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "Disconnected (or was already not connected; idempotent).",
      "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": "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"
          }
        }
      }
    }
  }
}
post/api/google-business/reviews/{reviewId}/reply

Reply to a Google review from this app's own dashboard

Session cookieadmin only

Admin-only. reviewId is this app's own book_google_reviews.id (0090), not Google's resource name: the client never needs to learn Google's naming scheme. Scoped by company_id explicitly in the lookup, not by id alone: that table has no SELECT policy for authenticated at all, so this IS the tenant boundary, same shape reviews/page.tsx's own book_feedback read already relies on.

Calls Google's reviews.updateReply (v4; reviews were never migrated off that surface when the rest of the My Business API was decomposed into v1 services; see docs/google-business-profile.md), which creates a reply if none exists or replaces one that does; there is no separate edit endpoint. On success, best-effort mirrors the reply into the cached row so the dashboard shows "already replied" without a live call. A failure there is only stale UI, since the reply already succeeded on Google's side, and the next sync overwrites it either way.

Parameters

  • reviewId*pathstring

    book_google_reviews.id, not the Google resource name.

Request body application/json

FieldTypeNotes
comment*string(min length 1, max length 4096)

Google's own documented cap on a review reply.

Responses

200

Sent.

FieldType
ok*true
400

Empty, or over 4096 characters.

401

No valid session cookie. {"error":"Not signed in"}.

402

Google Business Reviews is a standalone paid add-on (src/lib/plan.ts, reviews); not included by any tier.

403

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

404

No such review for this company.

409

Google Business Profile is not connected for this company.

502

Google rejected the reply.

Raw OpenAPI operation
{
  "summary": "Reply to a Google review from this app's own dashboard",
  "description": "Admin-only. `reviewId` is this app's own `book_google_reviews.id` (0090), not Google's resource name: the client never needs to learn Google's naming scheme. Scoped by `company_id` explicitly in the lookup, not by id alone: that table has no SELECT policy for `authenticated` at all, so this IS the tenant boundary, same shape `reviews/page.tsx`'s own `book_feedback` read already relies on.\n\nCalls Google's `reviews.updateReply` (v4; reviews were never migrated off that surface when the rest of the My Business API was decomposed into v1 services; see docs/google-business-profile.md), which creates a reply if none exists or replaces one that does; there is no separate edit endpoint. On success, best-effort mirrors the reply into the cached row so the dashboard shows \"already replied\" without a live call. A failure there is only stale UI, since the reply already succeeded on Google's side, and the next sync overwrites it either way.",
  "tags": [
    "Shared"
  ],
  "operationId": "replyToGoogleReview",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "reviewId",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string"
      },
      "description": "book_google_reviews.id, not the Google resource name."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "comment"
          ],
          "properties": {
            "comment": {
              "type": "string",
              "minLength": 1,
              "maxLength": 4096,
              "description": "Google's own documented cap on a review reply."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Sent.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "Empty, or over 4096 characters.",
      "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": "Google Business Reviews is a standalone paid add-on (src/lib/plan.ts, `reviews`); not included by any tier.",
      "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 such review for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "409": {
      "description": "Google Business Profile is not connected for this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "Google rejected the reply.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/background

Upload the booking-page background (admin, or manage_org_settings)

Session cookie

Same shape as the logo route, with a 5 MB ceiling instead of 2 MB; a full-bleed background is a bigger picture than a mark. Same gate too: admin, or a staff login granted manage_org_settings (migration 0082).

Request body multipart/form-data

The booking-page background image. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
backgroundImageUrl*string (uri)
400

No file, wrong MIME type, over 5 MB, or a storage/row error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Upload the booking-page background (admin, or manage_org_settings)",
  "description": "Same shape as the logo route, with a 5 MB ceiling instead of 2 MB; a full-bleed background is a bigger picture than a mark. Same gate too: admin, or a staff login granted `manage_org_settings` (migration 0082).",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadBackground",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400."
            }
          }
        },
        "encoding": {
          "file": {
            "contentType": "image/png, image/jpeg, image/webp"
          }
        }
      }
    },
    "description": "The booking-page background image. Sent as multipart/form-data under the field name `file`."
  },
  "responses": {
    "200": {
      "description": "Uploaded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "backgroundImageUrl"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "backgroundImageUrl": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, wrong MIME type, over 5 MB, or a storage/row 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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/organization/background

Remove the booking-page background (admin, or manage_org_settings)

Session cookie

Deletes the object and nulls background_image_url. Takes no body.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Remove the booking-page background (admin, or manage_org_settings)",
  "description": "Deletes the object and nulls `background_image_url`. Takes no body.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteBackground",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/about-photo

Upload the About section photo (admin, or manage_org_settings)

Session cookie

A second, distinct photo for the About section (migration 0131), separate from the one hero image /api/organization/background uploads, which HomeBody/AboutBody used to reuse for About too. Same shape as that route otherwise: same 5 MB ceiling and bucket (booking-backgrounds, a distinct fixed path per company), same admin gate.

Request body multipart/form-data

The About section photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
aboutImageUrl*string (uri)
400

No file, wrong MIME type, over 5 MB, or a storage/row error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Upload the About section photo (admin, or manage_org_settings)",
  "description": "A second, distinct photo for the About section (migration 0131), separate from the one hero image /api/organization/background uploads, which HomeBody/AboutBody used to reuse for About too. Same shape as that route otherwise: same 5 MB ceiling and bucket (`booking-backgrounds`, a distinct fixed path per company), same admin gate.",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadAboutPhoto",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400."
            }
          }
        },
        "encoding": {
          "file": {
            "contentType": "image/png, image/jpeg, image/webp"
          }
        }
      }
    },
    "description": "The About section photo. Sent as multipart/form-data under the field name `file`."
  },
  "responses": {
    "200": {
      "description": "Uploaded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "aboutImageUrl"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "aboutImageUrl": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, wrong MIME type, over 5 MB, or a storage/row 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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/organization/about-photo

Remove the About section photo (admin, or manage_org_settings)

Session cookie

Deletes the object and nulls about_image_url. The About section falls back to its existing photo ladder (site-pages.ts's aboutPhoto()), not to the hero image. Takes no body.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Remove the About section photo (admin, or manage_org_settings)",
  "description": "Deletes the object and nulls `about_image_url`. The About section falls back to its existing photo ladder (site-pages.ts's aboutPhoto()), not to the hero image. Takes no body.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteAboutPhoto",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/organization/site-background

Upload the website's own hero photo (admin, or manage_org_settings)

Session cookie

The website's OWN hero photo (migration 0194), independent of /api/organization/background's widget hero (SiteShell used to always reuse that same photo). Same shape as that route otherwise: same 5 MB ceiling and bucket (booking-backgrounds, a distinct fixed path per company), same admin gate. Unset falls back to the widget's own photo, live.

Request body multipart/form-data

The website's hero photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
siteBackgroundImageUrl*string (uri)
400

No file, wrong MIME type, over 5 MB, or a storage/row error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Upload the website's own hero photo (admin, or manage_org_settings)",
  "description": "The website's OWN hero photo (migration 0194), independent of /api/organization/background's widget hero (SiteShell used to always reuse that same photo). Same shape as that route otherwise: same 5 MB ceiling and bucket (`booking-backgrounds`, a distinct fixed path per company), same admin gate. Unset falls back to the widget's own photo, live.",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadSiteBackground",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "PNG, JPEG or WebP, 5 MB or smaller. Anything else is a 400."
            }
          }
        },
        "encoding": {
          "file": {
            "contentType": "image/png, image/jpeg, image/webp"
          }
        }
      }
    },
    "description": "The website's hero photo. Sent as multipart/form-data under the field name `file`."
  },
  "responses": {
    "200": {
      "description": "Uploaded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "siteBackgroundImageUrl"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "siteBackgroundImageUrl": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, wrong MIME type, over 5 MB, or a storage/row 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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/organization/site-background

Remove the website's own hero photo (admin, or manage_org_settings)

Session cookie

Deletes the object and nulls site_background_image_url. The website falls back to the widget's own hero photo, live, not to no photo at all. Takes no body.

Responses

200

Removed.

FieldType
ok*true
400

A Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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 without the manage_org_settings permission (migration 0082, Admin access required).

Raw OpenAPI operation
{
  "summary": "Remove the website's own hero photo (admin, or manage_org_settings)",
  "description": "Deletes the object and nulls `site_background_image_url`. The website falls back to the widget's own hero photo, live, not to no photo at all. Takes no body.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteSiteBackground",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "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": "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 without the `manage_org_settings` permission (migration 0082, `Admin access required`).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/profile/avatar

Upload your own profile photo

Session cookie

Personal account data, not tenant data; a plain signed-in check, no organization scoping, no role. The storage policy pins writes to {userId}/avatar, which is what actually enforces "only your own folder". Stored on the auth user's metadata, not on any booking table.

Request body multipart/form-data

The profile photo. Sent as multipart/form-data under the field name file.

FieldTypeNotes
file*string (binary)

PNG, JPEG or WebP, 2 MB or smaller. Anything else is a 400.

Responses

200

Uploaded.

FieldType
ok*true
avatarUrl*string (uri)
400

No file, wrong MIME type, over 2 MB, or a storage/metadata error.

401

No valid session cookie. A plain signed-in check, no organization is required.

Raw OpenAPI operation
{
  "summary": "Upload your own profile photo",
  "description": "Personal account data, not tenant data; a plain signed-in check, no organization scoping, no role. The storage policy pins writes to `{userId}/avatar`, which is what actually enforces \"only your own folder\". Stored on the auth user's metadata, not on any booking table.",
  "tags": [
    "Shared"
  ],
  "operationId": "uploadOwnAvatar",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "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 profile 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/metadata error.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie. A plain signed-in check, no organization is required.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/profile/avatar

Remove your own profile photo

Session cookie

Deletes the object and nulls the metadata field.

Responses

200

Removed.

FieldType
ok*true
400

A metadata update error.

401

No valid session cookie.

Raw OpenAPI operation
{
  "summary": "Remove your own profile photo",
  "description": "Deletes the object and nulls the metadata field.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteOwnAvatar",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "responses": {
    "200": {
      "description": "Removed.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "A metadata update error.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
delete/api/account

Delete your own account

Session cookie

Danger zone. Unlike a one-account-per-company product, a Booking org can have several members, so deleting YOUR account does not always take the workspace with it:

  • Last member standing: nobody would be left who could ever sign in and manage or delete the org, so the whole workspace is deleted too.
  • Teammates remain: only this user's membership and personal data go; the workspace is shared, not owned.
  • Sole admin with staff remaining: refused with 400. Promote a teammate first. This mirrors the "cannot remove the last admin" rule on DELETE /api/members/{id}, so an admin cannot self-delete into an org nobody can administer.

When the workspace does go, its live Stripe subscriptions are cancelled immediately, before the database cascade, and that cancellation has to succeed for the deletion to proceed (see the 502). The Stripe customer and its invoices are deliberately NOT deleted: Australian tax law requires seven years of sales records, so erasure covers the account and retention covers the invoices.

This route is exempted from the onboarding redirect in middleware so a mid-onboarding account can still delete itself.

Request body application/json

FieldTypeNotes
confirm*"DELETE"

Must be exactly the string "DELETE", so a stray fetch can never delete anything by accident.

Responses

200

Deleted. The session is now invalid.

FieldType
ok*true
400

confirm was not "DELETE", or you are the only admin and staff remain.

401

No valid session cookie.

500

The workspace teardown or the auth-user delete failed. The account survives, so the operation can be retried.

502

Stripe refused to cancel the subscription, so nothing was deleted. Deliberately fatal rather than best-effort: the cascade would take stripe_customer_id with it, leaving a subscription that keeps charging a card with nothing left in the database able to name it. Retry.

Raw OpenAPI operation
{
  "summary": "Delete your own account",
  "description": "Danger zone. Unlike a one-account-per-company product, a Booking org can have several members, so deleting YOUR account does not always take the workspace with it:\n\n- **Last member standing**: nobody would be left who could ever sign in and manage or delete the org, so the whole workspace is deleted too.\n- **Teammates remain**: only this user's membership and personal data go; the workspace is shared, not owned.\n- **Sole admin with staff remaining**: refused with 400. Promote a teammate first. This mirrors the \"cannot remove the last admin\" rule on DELETE /api/members/{id}, so an admin cannot self-delete into an org nobody can administer.\n\nWhen the workspace does go, its live Stripe subscriptions are cancelled immediately, before the database cascade, and that cancellation has to succeed for the deletion to proceed (see the 502). The Stripe customer and its invoices are deliberately NOT deleted: Australian tax law requires seven years of sales records, so erasure covers the account and retention covers the invoices.\n\nThis route is exempted from the onboarding redirect in middleware so a mid-onboarding account can still delete itself.",
  "tags": [
    "Shared"
  ],
  "operationId": "deleteOwnAccount",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "confirm"
          ],
          "properties": {
            "confirm": {
              "const": "DELETE",
              "description": "Must be exactly the string \"DELETE\", so a stray fetch can never delete anything by accident."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Deleted. The session is now invalid.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`confirm` was not \"DELETE\", or you are the only admin and staff remain.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "401": {
      "description": "No valid session cookie.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The workspace teardown or the auth-user delete failed. The account survives, so the operation can be retried.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "502": {
      "description": "Stripe refused to cancel the subscription, so nothing was deleted. Deliberately fatal rather than best-effort: the cascade would take `stripe_customer_id` with it, leaving a subscription that keeps charging a card with nothing left in the database able to name it. Retry.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
get/api/data/jobs

List this org's recent imports and exports (admin)

Session cookieadmin only

The Import & export hub's history, newest first, capped at 25. Reads book_data_jobs (migration 0100) through the service-role client, scoped explicitly by company_id.

Every finished export carries a freshly minted downloadUrl: a signed URL into the PRIVATE data-exports bucket, valid for 15 minutes. It is generated per request and stored nowhere, because it is a bearer credential for a file containing the venue's entire client list.

Sweeps stalled jobs as a side effect (flagStalledJobs), which is how a worker that died mid-import becomes visible without a cron of its own.

Never 500s on a missing table: migrations here are applied by hand AFTER the deploy, so between the two this route answers 200 with unavailable: true rather than a stack trace nobody can act on.

Responses

200

The history, or an explained empty list.

FieldTypeNotes
jobs*object[]
jobs[].id*string (uuid)
jobs[].kind*"import" | "export"
jobs[].entity*"clients" | "bookings"
jobs[].format*"csv" | "tsv" | "json" | "xlsx"
jobs[].status*"queued" | "running" | "done" | "failed"
jobs[].sourceNamestring | null

The uploaded filename, on an import.

jobs[].summary*object

Counts. { total, valid, duplicates, invalid, headerCount, unmappedHeaders, issues } for an import; { rows } for an export. Free-form on purpose: it is display and diagnosis, never a gate.

jobs[].errorstring | null
jobs[].needsSupport*boolean

The loud-error flag: this run failed, more than 15% of its rows were unusable, more than half its columns went unrecognised, or it stalled.

jobs[].supportRequestedAtstring | null (date-time)
jobs[].createdAt*string (date-time)
jobs[].finishedAtstring | null (date-time)
jobs[].downloadUrlstring | null (uri)

Signed, 15-minute, private-bucket URL. Null for imports and for exports that have not finished.

unavailableboolean

Present and true when book_data_jobs could not be read at all, which in practice means migration 0100 has not been applied to this database yet.

errorstring
401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "List this org's recent imports and exports (admin)",
  "description": "The Import & export hub's history, newest first, capped at 25. Reads `book_data_jobs` (migration 0100) through the service-role client, scoped explicitly by `company_id`.\n\nEvery finished export carries a freshly minted `downloadUrl`: a signed URL into the PRIVATE `data-exports` bucket, valid for 15 minutes. It is generated per request and stored nowhere, because it is a bearer credential for a file containing the venue's entire client list.\n\nSweeps stalled jobs as a side effect (`flagStalledJobs`), which is how a worker that died mid-import becomes visible without a cron of its own.\n\nNever 500s on a missing table: migrations here are applied by hand AFTER the deploy, so between the two this route answers 200 with `unavailable: true` rather than a stack trace nobody can act on.",
  "tags": [
    "Shared"
  ],
  "operationId": "listDataJobs",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "responses": {
    "200": {
      "description": "The history, or an explained empty list.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "jobs"
            ],
            "properties": {
              "jobs": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "kind",
                    "entity",
                    "format",
                    "status",
                    "summary",
                    "needsSupport",
                    "createdAt"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "kind": {
                      "type": "string",
                      "enum": [
                        "import",
                        "export"
                      ]
                    },
                    "entity": {
                      "type": "string",
                      "enum": [
                        "clients",
                        "bookings"
                      ]
                    },
                    "format": {
                      "type": "string",
                      "enum": [
                        "csv",
                        "tsv",
                        "json",
                        "xlsx"
                      ]
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "queued",
                        "running",
                        "done",
                        "failed"
                      ]
                    },
                    "sourceName": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The uploaded filename, on an import."
                    },
                    "summary": {
                      "type": "object",
                      "description": "Counts. `{ total, valid, duplicates, invalid, headerCount, unmappedHeaders, issues }` for an import; `{ rows }` for an export. Free-form on purpose: it is display and diagnosis, never a gate.",
                      "additionalProperties": true
                    },
                    "error": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "needsSupport": {
                      "type": "boolean",
                      "description": "The loud-error flag: this run failed, more than 15% of its rows were unusable, more than half its columns went unrecognised, or it stalled."
                    },
                    "supportRequestedAt": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    },
                    "createdAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "finishedAt": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    },
                    "downloadUrl": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "uri",
                      "description": "Signed, 15-minute, private-bucket URL. Null for imports and for exports that have not finished."
                    }
                  }
                }
              },
              "unavailable": {
                "type": "boolean",
                "description": "Present and true when `book_data_jobs` could not be read at all, which in practice means migration 0100 has not been applied to this database yet."
              },
              "error": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "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"
          }
        }
      }
    }
  }
}
post/api/data/jobs

Start an export (admin, active subscription only)

Session cookieadmin only

Queues or runs an export of this org's clients or bookings.

THE PLAN BOUNDARY LIVES HERE, and it is a SUBSCRIPTION-STATUS gate rather than a PlanFeature one, which no other route in this API does. exportAccess() in src/lib/plan.ts reads book_billing.subscription_status raw: active passes, trialing is refused, and everything else (past_due, canceled, or no billing row at all) is refused with different copy. A trial deliberately fails even though it grants every other paid feature, because taking the data out is not what a trial is for. The hub disables its own buttons for the same three states, but that is cosmetic in the sense require-member.ts means it.

Small exports run inline and are downloadable the moment this returns (queued: false); anything over 50 rows becomes a data_job on book_job_queue for the worker to drain.

Request body application/json

FieldTypeNotes
entity*"clients" | "bookings"

Which surface. bookings resolves to book_appointments or book_reservations by the org's own business_type.

format"csv" | "json"(default "csv")

Anything other than json is treated as csv.

Responses

200

Started.

FieldTypeNotes
id*string (uuid)
status*"queued" | "running" | "done" | "failed"
queued*boolean

False when the export ran inline and is already downloadable.

400

entity was not clients or bookings, or the job row could not be inserted.

401

No valid session cookie. {"error":"Not signed in"}.

402

The subscription is not active. access is trial or locked and subscriptionStatus is Stripe's own word (or null with no billing row), so the client can tell "upgrade from your trial" apart from "reactivate your lapsed account" apart from "you have never had a plan".

403

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

503

The job row was created but book_job_queue refused the insert, so nothing is draining it yet. The row survives and is retryable.

Raw OpenAPI operation
{
  "summary": "Start an export (admin, active subscription only)",
  "description": "Queues or runs an export of this org's clients or bookings.\n\n**THE PLAN BOUNDARY LIVES HERE**, and it is a SUBSCRIPTION-STATUS gate rather than a `PlanFeature` one, which no other route in this API does. `exportAccess()` in src/lib/plan.ts reads `book_billing.subscription_status` raw: `active` passes, `trialing` is refused, and everything else (`past_due`, `canceled`, or no billing row at all) is refused with different copy. A trial deliberately fails even though it grants every other paid feature, because taking the data out is not what a trial is for. The hub disables its own buttons for the same three states, but that is cosmetic in the sense require-member.ts means it.\n\nSmall exports run inline and are downloadable the moment this returns (`queued: false`); anything over 50 rows becomes a `data_job` on `book_job_queue` for the worker to drain.",
  "tags": [
    "Shared"
  ],
  "operationId": "createDataExport",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "entity"
          ],
          "properties": {
            "entity": {
              "type": "string",
              "enum": [
                "clients",
                "bookings"
              ],
              "description": "Which surface. `bookings` resolves to `book_appointments` or `book_reservations` by the org's own `business_type`."
            },
            "format": {
              "type": "string",
              "enum": [
                "csv",
                "json"
              ],
              "default": "csv",
              "description": "Anything other than `json` is treated as `csv`."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Started.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "status",
              "queued"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "status": {
                "type": "string",
                "enum": [
                  "queued",
                  "running",
                  "done",
                  "failed"
                ]
              },
              "queued": {
                "type": "boolean",
                "description": "False when the export ran inline and is already downloadable."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`entity` was not `clients` or `bookings`, or the job row could not be inserted.",
      "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 subscription is not `active`. `access` is `trial` or `locked` and `subscriptionStatus` is Stripe's own word (or null with no billing row), so the client can tell \"upgrade from your trial\" apart from \"reactivate your lapsed account\" apart from \"you have never had a plan\".",
      "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"
          }
        }
      }
    },
    "503": {
      "description": "The job row was created but `book_job_queue` refused the insert, so nothing is draining it yet. The row survives and is retryable.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/data/preview

Dry-run an import file (admin)

Session cookieadmin only

Reads an uploaded file, guesses the column alignment, validates every row against the real database, and writes nothing at all: not a client, not a booking, and not even the uploaded file.

Storing nothing is the decision worth knowing about. The obvious design uploads once and re-validates against the stored copy each time the operator changes a column; the cost is a bucket slowly filling with the abandoned first attempts of every venue that opened this screen and thought better of it, each one somebody's entire client list. The browser already holds the file, so it re-sends it.

Not gated on billing status, unlike export: importing is how a venue arrives, and refusing it during a trial would be refusing the customer.

A mapping sent back with the file WINS over the auto-guess, so an operator's corrections survive touching a second column. It is filtered against this file's real headers and this entity's real fields before use.

Request body multipart/form-data

The file plus what is in it. multipart/form-data.

FieldTypeNotes
file*string (binary)

CSV, TSV, XLSX or JSON, 10MB or smaller. The extension decides the parser, never the browser-supplied MIME type: Windows Chrome sends application/vnd.ms-excel for a plain .csv.

entity*"clients" | "bookings"
mappingstring

Optional JSON object, { targetField: sourceHeader }. Absent, the columns are auto-mapped.

Responses

200

What this file would do.

FieldTypeNotes
format*"csv" | "tsv" | "json" | "xlsx"
sourceName*string
headers*string[]

De-duplicated: a second column also called "Phone" comes back as "Phone (2)", because a mapping has to be able to say which one it meant.

sample*string[][]

The first 8 data rows, for the alignment preview.

fields*object[]

The target columns for this entity AND this org's engine. Bookings fork: an appointment has a service and a staff member, a sitting has covers.

fields[].key*string
fields[].label*string
fields[].required*boolean
fields[].hintstring
mapping*object
missingRequired*string[]

Labels of required fields with no column. Reported alongside the counts rather than instead of them, so the operator is not told one problem at a time.

summary*object

{ total, valid, duplicates, invalid, headerCount, unmappedHeaders, issues, issuesTruncated }. issues is capped at 50.

400

No file, no/unknown entity, an unsupported extension, over 10MB, an unreadable file, a header row with no data rows, or more than 20,000 rows.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

Raw OpenAPI operation
{
  "summary": "Dry-run an import file (admin)",
  "description": "Reads an uploaded file, guesses the column alignment, validates every row against the real database, and **writes nothing at all**: not a client, not a booking, and not even the uploaded file.\n\nStoring nothing is the decision worth knowing about. The obvious design uploads once and re-validates against the stored copy each time the operator changes a column; the cost is a bucket slowly filling with the abandoned first attempts of every venue that opened this screen and thought better of it, each one somebody's entire client list. The browser already holds the file, so it re-sends it.\n\n**Not gated on billing status**, unlike export: importing is how a venue arrives, and refusing it during a trial would be refusing the customer.\n\nA `mapping` sent back with the file WINS over the auto-guess, so an operator's corrections survive touching a second column. It is filtered against this file's real headers and this entity's real fields before use.",
  "tags": [
    "Shared"
  ],
  "operationId": "previewDataImport",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "description": "The file plus what is in it. multipart/form-data.",
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file",
            "entity"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "CSV, TSV, XLSX or JSON, 10MB or smaller. The extension decides the parser, never the browser-supplied MIME type: Windows Chrome sends `application/vnd.ms-excel` for a plain .csv."
            },
            "entity": {
              "type": "string",
              "enum": [
                "clients",
                "bookings"
              ]
            },
            "mapping": {
              "type": "string",
              "description": "Optional JSON object, `{ targetField: sourceHeader }`. Absent, the columns are auto-mapped."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "What this file would do.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "format",
              "sourceName",
              "headers",
              "sample",
              "fields",
              "mapping",
              "missingRequired",
              "summary"
            ],
            "properties": {
              "format": {
                "type": "string",
                "enum": [
                  "csv",
                  "tsv",
                  "json",
                  "xlsx"
                ]
              },
              "sourceName": {
                "type": "string"
              },
              "headers": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "De-duplicated: a second column also called \"Phone\" comes back as \"Phone (2)\", because a mapping has to be able to say which one it meant."
              },
              "sample": {
                "type": "array",
                "items": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "description": "The first 8 data rows, for the alignment preview."
              },
              "fields": {
                "type": "array",
                "description": "The target columns for this entity AND this org's engine. Bookings fork: an appointment has a service and a staff member, a sitting has covers.",
                "items": {
                  "type": "object",
                  "required": [
                    "key",
                    "label",
                    "required"
                  ],
                  "properties": {
                    "key": {
                      "type": "string"
                    },
                    "label": {
                      "type": "string"
                    },
                    "required": {
                      "type": "boolean"
                    },
                    "hint": {
                      "type": "string"
                    }
                  }
                }
              },
              "mapping": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              },
              "missingRequired": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Labels of required fields with no column. Reported alongside the counts rather than instead of them, so the operator is not told one problem at a time."
              },
              "summary": {
                "type": "object",
                "additionalProperties": true,
                "description": "`{ total, valid, duplicates, invalid, headerCount, unmappedHeaders, issues, issuesTruncated }`. `issues` is capped at 50."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, no/unknown `entity`, an unsupported extension, over 10MB, an unreadable file, a header row with no data rows, or more than 20,000 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": "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"
          }
        }
      }
    }
  }
}
post/api/data/imports

Commit an import (admin)

Session cookieadmin only

Stores the file in the private data-imports bucket, records the mapping the operator CONFIRMED, and either does the work inline (50 rows or fewer) or enqueues a data_job for the worker.

The mapping is stored rather than re-derived, because the auto-mapper is a heuristic that will change: a job re-run six months from now must apply what a human approved, not what this build would guess today.

The file is parsed here as well as in the runner. That is not waste: it is the only way to know the row count before choosing inline-vs-queued, and it means an unreadable file is a 400 the operator sees rather than a queued job failing somewhere they have to go looking for.

Not gated on billing status, same reasoning as /api/data/preview.

Request body multipart/form-data

The file and the confirmed mapping. multipart/form-data.

FieldTypeNotes
file*string (binary)

The same file that was previewed. CSV, TSV, XLSX or JSON, 10MB or smaller.

entity*"clients" | "bookings"
mapping*string

JSON object, { targetField: sourceHeader }. Filtered against this file's headers and this entity's fields; every required field must be present or this is a 400.

Responses

200

Running or queued.

FieldTypeNotes
id*string (uuid)
status*"queued" | "running" | "done" | "failed"
queued*boolean
rowsinteger

Present when queued: how many data rows the worker will process.

summaryobject

Present when it ran inline. valid is what actually LANDED, not what passed validation: the two differ exactly when the database refused a row the parser was happy with.

400

No file, no/unknown entity, an unsupported extension, over 10MB, an unreadable file, a required field with no mapped column, or the upload/insert failed.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

503

The file and job row were stored but book_job_queue refused the insert. The row survives and is retryable.

Raw OpenAPI operation
{
  "summary": "Commit an import (admin)",
  "description": "Stores the file in the private `data-imports` bucket, records the mapping the operator CONFIRMED, and either does the work inline (50 rows or fewer) or enqueues a `data_job` for the worker.\n\nThe mapping is stored rather than re-derived, because the auto-mapper is a heuristic that will change: a job re-run six months from now must apply what a human approved, not what this build would guess today.\n\nThe file is parsed here as well as in the runner. That is not waste: it is the only way to know the row count before choosing inline-vs-queued, and it means an unreadable file is a 400 the operator sees rather than a queued job failing somewhere they have to go looking for.\n\n**Not gated on billing status**, same reasoning as `/api/data/preview`.",
  "tags": [
    "Shared"
  ],
  "operationId": "createDataImport",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "description": "The file and the confirmed mapping. multipart/form-data.",
    "content": {
      "multipart/form-data": {
        "schema": {
          "type": "object",
          "required": [
            "file",
            "entity",
            "mapping"
          ],
          "properties": {
            "file": {
              "type": "string",
              "format": "binary",
              "description": "The same file that was previewed. CSV, TSV, XLSX or JSON, 10MB or smaller."
            },
            "entity": {
              "type": "string",
              "enum": [
                "clients",
                "bookings"
              ]
            },
            "mapping": {
              "type": "string",
              "description": "JSON object, `{ targetField: sourceHeader }`. Filtered against this file's headers and this entity's fields; every required field must be present or this is a 400."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Running or queued.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "id",
              "status",
              "queued"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "status": {
                "type": "string",
                "enum": [
                  "queued",
                  "running",
                  "done",
                  "failed"
                ]
              },
              "queued": {
                "type": "boolean"
              },
              "rows": {
                "type": "integer",
                "description": "Present when queued: how many data rows the worker will process."
              },
              "summary": {
                "type": "object",
                "additionalProperties": true,
                "description": "Present when it ran inline. `valid` is what actually LANDED, not what passed validation: the two differ exactly when the database refused a row the parser was happy with."
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "No file, no/unknown `entity`, an unsupported extension, over 10MB, an unreadable file, a required field with no mapped column, or the upload/insert 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": "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"
          }
        }
      }
    },
    "503": {
      "description": "The file and job row were stored but `book_job_queue` refused the insert. The row survives and is retryable.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
post/api/data/support

Ask for free migration help (admin)

Session cookieadmin only

The button on the proactive support banner. Stamps support_requested_at on one of this org's data jobs and pages us (console line plus SUPPORT_WEBHOOK_URL, if set).

Deliberately small: it does not open a ticket in another system, email the customer, or promise a response time. The durable record is the row, the superadmin queue reads it, and a human replies.

Scoped with an explicit .eq('company_id') on top of the id, because this route holds a service-role client and so has to spell out the tenancy that RLS would otherwise enforce. A zero-row update is a PostgREST 204 rather than an error, so the returned row (not the absence of an error) is what tells a real request from one naming somebody else's job.

Request body application/json

FieldTypeNotes
jobId*string (uuid)

The import this is about. Must belong to the caller's own company.

notestring(max length 1000)

Anything the venue wants to say. Truncated at 1000 characters.

Responses

200

Recorded.

FieldType
ok*true
requestedAtstring | null (date-time)
400

jobId was missing, or a Postgres error updating the row.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No data job with that id belongs to this company.

Raw OpenAPI operation
{
  "summary": "Ask for free migration help (admin)",
  "description": "The button on the proactive support banner. Stamps `support_requested_at` on one of this org's data jobs and pages us (console line plus `SUPPORT_WEBHOOK_URL`, if set).\n\nDeliberately small: it does not open a ticket in another system, email the customer, or promise a response time. The durable record is the row, the superadmin queue reads it, and a human replies.\n\nScoped with an explicit `.eq('company_id')` on top of the id, because this route holds a service-role client and so has to spell out the tenancy that RLS would otherwise enforce. A zero-row update is a PostgREST 204 rather than an error, so the returned row (not the absence of an error) is what tells a real request from one naming somebody else's job.",
  "tags": [
    "Shared"
  ],
  "operationId": "requestMigrationSupport",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "jobId"
          ],
          "properties": {
            "jobId": {
              "type": "string",
              "format": "uuid",
              "description": "The import this is about. Must belong to the caller's own company."
            },
            "note": {
              "type": "string",
              "maxLength": 1000,
              "description": "Anything the venue wants to say. Truncated at 1000 characters."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "Recorded.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "requestedAt": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`jobId` was missing, or 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": "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 data job with that id belongs to this company.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}
patch/api/loyalty/members/{id}/adjust

Manually adjust a loyalty member's points balance (admin)

Session cookieadmin only

A birthday bonus, a correction, a chargeback clawback: the ONE trigger the loyalty wallet-card push engine has today (migration 0161). There is no automatic points-accrual formula for completed bookings anywhere in this product yet, so nothing else calls this.

delta is a signed, non-zero integer added to points_balance via book_loyalty_adjust_points, a security definer RPC rather than a plain PostgREST update: PostgREST cannot express points_balance = points_balance + delta atomically, and a read-then-write from this route would race under concurrent adjustments. The RPC leans on book_loyalty_members.points_balance >= 0 (migration 0160) as the single source of truth for the floor, so a delta that would take the balance negative is rejected by the database, not by application logic re-implementing the same rule.

Every adjustment is recorded in book_loyalty_point_adjustments (company, member, delta, an optional reason, and who made it) for a durable audit trail: redeemable points are worth being able to explain.

No `Idempotency-Key`, unlike `/api/payments/refunds`. That machinery exists there because a retry calls an external payment processor that could double-charge with no way to detect the duplicate. A double-submitted delta has no such blast radius: it is one UPDATE against this database, its result is returned in this same response, and it is trivially correctable with the opposite delta.

On success, enqueues loyalty_pass_push so both wallets pick up the new balance: Apple via a silent APNs wake-up that makes the device re-fetch its pass, Google by re-patching the LoyaltyObject directly. That enqueue is best-effort and never fails this request: a queue hiccup must not turn a successful points adjustment into a client-visible error.

Not engine-gated: a loyalty member hangs off book_customers, a table both engines already share, and carries no engine column at all.

Parameters

  • id*pathstring (uuid)

    The book_loyalty_members.id to adjust.

Request body application/json

FieldTypeNotes
delta*integer

A non-zero integer added to the current balance. Negative to deduct. Zero, a non-integer, or a non-number is refused before the database is ever touched.

reasonstring

Optional free text, recorded on the audit row. Trimmed; an empty or whitespace-only value is stored as absent.

Responses

200

The balance was adjusted.

FieldTypeNotes
ok*true
member*object
member.id*string (uuid)
member.pointsBalance*integer

The balance AFTER this adjustment.

member.tier*string | null
member.status*string
delta*integer

Echoes what was applied.

reason*string | null
400

delta is missing, zero, not an integer, or applying it would take points_balance below zero (the database's own CHECK constraint, translated to a readable message rather than a raw Postgres error).

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

No loyalty member with this id in this organization, or it is not active (the RPC's where clause matches neither).

500

The adjustment failed for a reason other than the balance floor: a genuine server error, logged server-side with the member id.

Raw OpenAPI operation
{
  "summary": "Manually adjust a loyalty member's points balance (admin)",
  "description": "A birthday bonus, a correction, a chargeback clawback: the ONE trigger the loyalty wallet-card push engine has today (migration 0161). There is no automatic points-accrual formula for completed bookings anywhere in this product yet, so nothing else calls this.\n\n`delta` is a signed, non-zero integer added to `points_balance` via `book_loyalty_adjust_points`, a `security definer` RPC rather than a plain PostgREST update: PostgREST cannot express `points_balance = points_balance + delta` atomically, and a read-then-write from this route would race under concurrent adjustments. The RPC leans on `book_loyalty_members.points_balance >= 0` (migration 0160) as the single source of truth for the floor, so a delta that would take the balance negative is rejected by the database, not by application logic re-implementing the same rule.\n\nEvery adjustment is recorded in `book_loyalty_point_adjustments` (company, member, delta, an optional `reason`, and who made it) for a durable audit trail: redeemable points are worth being able to explain.\n\n**No `Idempotency-Key`, unlike `/api/payments/refunds`.** That machinery exists there because a retry calls an external payment processor that could double-charge with no way to detect the duplicate. A double-submitted delta has no such blast radius: it is one UPDATE against this database, its result is returned in this same response, and it is trivially correctable with the opposite delta.\n\nOn success, enqueues `loyalty_pass_push` so both wallets pick up the new balance: Apple via a silent APNs wake-up that makes the device re-fetch its pass, Google by re-patching the `LoyaltyObject` directly. That enqueue is best-effort and never fails this request: a queue hiccup must not turn a successful points adjustment into a client-visible error.\n\nNot engine-gated: a loyalty member hangs off `book_customers`, a table both engines already share, and carries no engine column at all.",
  "tags": [
    "Shared"
  ],
  "operationId": "adjustLoyaltyPoints",
  "security": [
    {
      "sessionCookie": []
    }
  ],
  "x-required-role": "admin",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
        "type": "string",
        "format": "uuid"
      },
      "description": "The `book_loyalty_members.id` to adjust."
    }
  ],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "required": [
            "delta"
          ],
          "properties": {
            "delta": {
              "type": "integer",
              "description": "A non-zero integer added to the current balance. Negative to deduct. Zero, a non-integer, or a non-number is refused before the database is ever touched."
            },
            "reason": {
              "type": "string",
              "description": "Optional free text, recorded on the audit row. Trimmed; an empty or whitespace-only value is stored as absent."
            }
          }
        }
      }
    }
  },
  "responses": {
    "200": {
      "description": "The balance was adjusted.",
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "required": [
              "ok",
              "member",
              "delta",
              "reason"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "member": {
                "type": "object",
                "required": [
                  "id",
                  "pointsBalance",
                  "tier",
                  "status"
                ],
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "pointsBalance": {
                    "type": "integer",
                    "description": "The balance AFTER this adjustment."
                  },
                  "tier": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "status": {
                    "type": "string"
                  }
                }
              },
              "delta": {
                "type": "integer",
                "description": "Echoes what was applied."
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      }
    },
    "400": {
      "description": "`delta` is missing, zero, not an integer, or applying it would take `points_balance` below zero (the database's own CHECK constraint, translated to a readable message rather than a raw 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 loyalty member with this id in this organization, or it is not `active` (the RPC's `where` clause matches neither).",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    },
    "500": {
      "description": "The adjustment failed for a reason other than the balance floor: a genuine server error, logged server-side with the member id.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  }
}

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/keys

Issue an API key (admin)

Session cookieadmin only

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.

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

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

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.

expiresInDaysinteger | null(min 1, max 3650)

Omit for a key that never expires, which is what most server-to-server integrations actually want.

Responses

200

Issued. Capture secret now; it is unrecoverable.

FieldTypeNotes
ok*true
key*object

The stored row. Snake_case, selected straight back.

key.idstring

The non-secret 16-hex key id. Safe in logs and in the dashboard.

key.namestring
key.scopesstring[]
key.last_fourstring

Last four characters of the raw token, for display only.

key.created_atstring (date-time)
key.expires_atstring | null (date-time)
key.revoked_atstring | null (date-time)
key.rate_limit_per_minuteinteger

Defaults to 120.

secret*string

The full 68-character token, sk_live_<16 hex>_<43 base64url>. Returned exactly once.

400

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.

401

No valid session cookie. {"error":"Not signed in"}.

403

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)

Session cookieadmin only

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*pathstring

    The key id; the non-secret 16-hex segment of the token, not the token itself.

Responses

200

Revoked.

FieldType
ok*true
400

A Postgres error.

401

No valid session cookie. {"error":"Not signed in"}.

403

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

404

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