One thread's messages
/api/messagesA single booking or enquiry's conversation, for the compact panel inside a booking's Drawer (ReservationsClient/BookingsClient); the full inbox at /inbox assembles every thread server-side in one page load; this is the one-thread equivalent for a client component that only has an id.
Same staff-scope as /inbox's own appointment branch (docs/staff-privacy.md): a kind=appointment booking outside a scoped staff member's own confirmed provider resolves to nothing; a 404, identical to a booking id from another tenant or one that no longer exists. reservation and enquiry are never scoped.
senderName/senderAvatarUrl (0057) are present on staff messages only: the specific provider's own identity when the reply is attributed to one, else the company's own; "the business replied."
Authorization
sessionCookie 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
Query Parameters
Value in
- "reservation"
- "appointment"
- "enquiry"
uuidResponse Body
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/messages?kind=reservation&bookingId=497f6eca-6276-4993-bfeb-53cbbbba6f08"{ "messages": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "author": "guest", "body": "string", "createdAt": "2019-08-24T14:15:22Z", "senderName": "string", "senderAvatarUrl": "string" } ]}Update organization settings (admin, or manage_org_settings, or deposit_staff_editable) PATCH
A true partial update: a key that is not in the body is not in the UPDATE. This paragraph used to say the opposite, and the opposite used to be true; every field was derived with a fallback, so omitting `tagline` nulled it, omitting `notifyEmailEnabled` forced it `true`, and omitting `bookingTheme` reset it to `light`. That was fixed in the route (a `has()` gate, the same discipline the reservations, appointments and clients PATCH routes use) and the description was not moved with it. `scripts/check-hardening.ts` asserts the current behaviour column by column. **Three ways in (migration 0082), checked in order of breadth.** An admin may write anything below. A staff login granted `manage_org_settings` may write anything EXCEPT five admin-only keys that either change the org's public identity (`slug`, `status`) or grant a permission (`depositStaffEditable`, `unlinkedStaffFullAccess`, `staffClientVisibility`); a permission that can grant permissions is not delegable, it is the thing doing the delegating. Absent that, a staff login may still write the six deposit-policy fields alone, but only once this org's admin has turned on `depositStaffEditable` (migration 0075). `slug`, `status` and the three admin-only permission flags are written with service-role, because migration 0022 (and 0075 for the deposit flag) leaves those columns out of the tenant column grant; a staff JWT provably could flip them through raw PostgREST before that. Everything else goes through the RLS-scoped client. `business_type` is NOT settable. Flipping a live org's vertical strands whatever it already has; a conversion is a data migration, not a settings toggle.
Reply to a guest POST
Any member, not admin-only: answering a customer is the job, and gating it behind admin would leave the person actually on shift unable to respond. The `author` value is pinned to `staff` by the RLS WITH CHECK, not merely set here; a crafted request cannot post in a guest's name. After the reply is recorded, `notifyMessageReply()` sends it to the guest on the requested `channel` (email by default); awaited, not fired-and-forgotten, and never throws, so a delivery failure changes nothing about this response. `sms` additionally requires the SMS entitlement (`plan.ts`'s `planAllows(companyId, 'sms')`, i.e. a plan with texts, Growth and Pro (Venue Growth and Venue Pro), and never during a trial) and a phone number that can be resolved to E.164 against the company's country/timezone; either failing means only the log records it, the reply itself still succeeds. **Not `smsIncluded`.** That limit is 0 on every tier since texts became metered, so gating on it refused everyone; the `/inbox` toggle did exactly that until 2026-08-11.