What the notification bell shows
/api/notificationsThe next few bookings and the newest unread guest messages, in one read. Polled by NotificationDataProvider.tsx every 60 seconds: there is no Supabase Realtime wiring in this product (no supabase.channel() calls, no publication on the booking tables), and standing that up for one bell would be a great deal of infrastructure for what is, per tenant, a handful of rows a minute.
Shared, and genuinely so rather than merely ungated. It calls requireMember() with no vertical and then branches on business_type itself; upcoming appointments for one engine, upcoming reservations for the other; because one bell serves either product. Naming a vertical here would be wrong, not missing.
Both lists are capped at 5 and are a display feed, not a paged resource: there is no cursor, and asking for more is what the Bookings and Inbox screens are for. An account with no company answers 200 with two empty arrays, not a 403; the bell renders on every dashboard page and must not be the thing that breaks a half-provisioned session.
seenAt (migration 0208) is the caller's own "last opened the bell" cursor: bookings/messages/overrides created at or before it render as seen (greyed, not removed) in the dropdown, and only unseen ones count toward its badge. null means never opened. Set via PATCH on this same path.
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
Response Body
application/json
application/json
application/json
curl -X GET "https://example.com/api/notifications"{ "bookings": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "startsAt": "2019-08-24T14:15:22Z", "customerName": "string", "title": "string", "color": "string", "href": "string", "createdAt": "2019-08-24T14:15:22Z" } ], "messages": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "customerName": "string", "preview": "string", "createdAt": "2019-08-24T14:15:22Z", "href": "string" } ], "seenAt": "2019-08-24T14:15:22Z"}Timestamp of the newest visible message GET
One `created_at`, nothing else: the newest `book_booking_messages` row this login can see (guest message or staff reply alike). `/inbox` (`MessagesClient.tsx`) polls this every 45 seconds while the tab is visible and compares it with the value the page rendered from, calling `router.refresh()` only when they differ. Replaces a blind full-page refresh on the same interval. Same RLS-scoped client as the inbox's own read, so the two agree exactly when nothing is new.
Mark the notification bell seen PATCH
Fired the instant the bell dropdown opens (`NotificationDataProvider.tsx`'s `markSeen()`), not per item: sets one cursor, `book_org_members.notifications_seen_at`, to now for the caller's own row. Nothing is deleted or marked read by this alone: a message still needs its own `read_at` (PATCH /api/messages) to leave the Inbox nav badge, and that is deliberate, since the bell and the Inbox counter are independent by design (Notification System Requirements, 2026-09-07). No body, no id: always the caller's own `book_org_members` row via session user id, same self-only shape as PATCH /api/notification-prefs, and written through service-role rather than the RLS-scoped client for the same reason that route is: UPDATE on that table was revoked from `authenticated` after migration 0082 (docs/grants-and-rls.md).