Appointments engine

List services (API key)

GET
/api/v1/services

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.

Authorization

bearerApiKey services: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: services: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.

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/api/v1/services"
{  "data": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string",      "description": "string",      "durationMinutes": 0,      "price": 0,      "color": "string",      "active": true,      "imageUrl": "string"    }  ],  "nextCursor": "string"}

List appointments (API key) GET

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.

List staff (API key) GET

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.