List reservations (API key)
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).
Authorization
bearerApiKey reservations:readThe 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/staffrole 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 isreadorwrite.writedoes NOT implyread: 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 grantedreservations:*), and again at use time by the gate's vertical argument.wrong_verticalis 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.
organizationis read-only for the same reason migration 0022 exists. - Errors carry a machine-readable
codealongsideerror:missing_authorization,invalid_key,key_revoked,key_expired,organization_inactive(all 401);insufficient_scope,wrong_vertical,plan_required(403);rate_limited(429). Match oncode, 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-AfterandX-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. Seedocs/api-keys.md. - One envelope, one paging scheme. Every list answers
{ data: [...], nextCursor }; the singleton answers{ data: {...} }.nextCursoris keyset (seek) paging, opaque, and null exactly when there are no more rows. Every field is camelCase, unlike the database columns underneath. /api/v1/is a real version segment, not decoration. There is nov2yet. When one ships,v1keeps answering unchanged for a notice window of at least 6 months, and everyv1response carriesDeprecationandSunsetheaders (RFC 9745, RFC 8594) for that whole window. Only a security fix can shorten it. Seedocs/api-keys.md.
In: header
Scope: reservations:read
Query Parameters
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.
1 <= value <= 20050The 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.
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.
date-timeEXCLUSIVE 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.
date-timeResponse Body
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/reservations"{ "data": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "startsAt": "2019-08-24T14:15:22Z", "endsAt": "2019-08-24T14:15:22Z", "status": "hold", "partySize": 0, "turnMinutes": 0, "occasion": "string", "notes": "string", "tableId": "073fbfcb-cd05-4203-be18-a1ce6f8e4d2d", "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac", "holdExpiresAt": "2019-08-24T14:15:22Z", "depositStatus": "none", "depositAmountCents": 0, "createdAt": "2019-08-24T14:15:22Z" } ], "nextCursor": "string"}Take the public site offline, or bring it back (admin) POST
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).
List tables (API key) GET
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.