Reply to a guest
/api/messagesAny 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.
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
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/messages" \ -H "Content-Type: application/json" \ -d '{ "kind": "reservation", "bookingId": "9a471128-954e-4e64-bde9-e8147015df89", "message": "string" }'{ "ok": true}One thread's messages GET
A 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."
Mark a thread read PATCH
Marks this booking's unread GUEST messages as read. Separate from POST so opening a conversation does not require writing one. The booking is looked up before the update, and an id that is not this org's is a 404. The number of messages actually flipped is deliberately NOT reflected in the status: a thread of your own with nothing currently unread matches zero rows and is still a 200, because marking a read thread read is idempotent, not an error.