List appointments (API key)
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.
Authorization
bearerApiKey appointments: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: appointments: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/appointments"{ "data": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "startsAt": "2019-08-24T14:15:22Z", "endsAt": "2019-08-24T14:15:22Z", "status": "pending", "durationMinutes": 0, "price": 0, "notes": "string", "serviceId": "8f8bb40f-b96b-40fe-9064-5031fbe483f9", "providerId": "4834bcdc-4a64-444d-966b-1a6fe381da24", "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac", "depositStatus": "none", "depositAmountCents": 0, "createdAt": "2019-08-24T14:15:22Z" } ], "nextCursor": "string"}Update an appointment PATCH
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.
List services (API key) GET
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.