Appointments engine

List appointments (API key)

GET
/api/v1/appointments

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:read
AuthorizationBearer <token>

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, plan_required (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.
  • /api/v1/ is a real version segment, not decoration. There is no v2 yet. When one ships, v1 keeps answering unchanged for a notice window of at least 6 months, and every v1 response carries Deprecation and Sunset headers (RFC 9745, RFC 8594) for that whole window. Only a security fix can shorten it. See docs/api-keys.md.

In: header

Scope: appointments:read

Query Parameters

limit?integer

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.

Range1 <= value <= 200
Default50
cursor?string

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.

from?string

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.

Formatdate-time
to?string

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.

Formatdate-time

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