Appointments engine

Update an appointment

patch/api/appointments/{id}

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.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<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.

In: cookie

Path Parameters

id*string

Appointment id.

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Properties1 <= properties

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

curl -X PATCH "https://example.com/api/appointments/string" \  -H "Content-Type: application/json" \  -d '{}'
{  "ok": true}

Read one booking GET

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.

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.