Live Updates and Push
A client-side guide to the three mechanisms Skoolsewa clients use to learn about new events — the staff event stream, the personal notification stream, and FCM push — and when to use each.
Live Updates and Push
This page is for anyone building a client against this backend — the web admin panel today, or a future Android or iOS app. It does not repeat the endpoint-level contracts already written up elsewhere; it explains which mechanism to reach for and how the pieces fit together.
1. The three mechanisms, and which to use when
| Mechanism | Route | Transport | Audience | Requires the app open |
|---|---|---|---|---|
| Staff event stream | GET /api/admin/realtime/stream | Server-sent events (SSE) | Staff with an admin session, filtered per subscriber by permission | Yes |
| Personal notification stream | GET /api/mobile/notifications/stream | SSE | Any authenticated user, scoped to their own arrivals | Yes |
| Push (FCM) | POST / DELETE /api/mobile/notification-devices | Firebase Cloud Messaging | Any authenticated user, on a registered device | No |
Full endpoint contracts: Realtime API for the staff stream; Notification API §8.14a, §8.18–8.19 for the personal stream and push device registration. This page does not duplicate those field-by-field references.
Rule of thumb. SSE is for a session that is actually open right now — a browser tab or a foregrounded app screen. It costs nothing to ignore, degrades to nothing when the connection drops, and both streams say so explicitly in their own code: every screen they enhance must remain correct on a plain refresh with the stream dead. Push is for everything that happens while the client is not open — a closed tab, a backgrounded or killed app — and it is the only one of the three that can reach a device with nothing running. History for everything is in Postgres: the staff stream's notification table and the personal stream's notification centre list are both durable and both queryable independently of whether any stream or push ever fired. Nothing that matters is ever only in Redis or only in a push payload — a client that missed every live signal can always catch up by reading the ordinary list and count endpoints.
Concretely:
- Building an admin panel screen that shows live operational events to staff → open the staff event stream while that screen (or the shell around it) is mounted.
- Building "my notifications" / a bell icon for any authenticated user → open the personal notification stream while the app is foregrounded, and poll
/unread-counton load and after reconnects. - Making sure a person finds out about something while they are not looking at the app at all → register for push, and treat the SSE streams as a pure bonus on top of it.
2. Authentication
Mobile / native clients
A mobile or native client authenticates the ordinary way: POST /api/mobile/auth/login/email returns an accessToken and a refreshToken. Every subsequent request — including opening either SSE stream — sends Authorization: Bearer accessToken. There is no separate credential for streaming or for push; the same bearer token that authorizes GET /api/mobile/notifications authorizes GET /api/mobile/notifications/stream.
Browser clients — the BFF proxy
A browser's native EventSource object cannot set request headers, so it cannot send Authorization: Bearer. The admin panel (skoolsewa-frontend) does not work around this with a token in the URL — that would land the credential in access logs and in Referer headers on any request the page later makes. Instead its own Next.js server holds the session in an httpOnly cookie and proxies each stream through a route handler that opens the backend connection server-to-server.
Read directly (no changes made — both are read-only reference in this repo):
skoolsewa-frontend/app/api/admin/realtime/stream/route.tsskoolsewa-frontend/app/api/admin/inbox/stream/route.ts
Both handlers follow the same shape:
- Read the
bh_access_tokenhttpOnly cookie server-side. Return401 { errorCode: "AUTH_REQUIRED" }if it is missing — never open an unauthenticated upstream connection. - Build an outbound
Headersobject withAuthorization: Bearer accessTokenandAccept: text/event-stream. - For the staff stream only, forward the browser's own
Last-Event-IDrequest header onto the upstream fetch, so a backend reconnect can replay what was missed. The personal-notification proxy does not forwardLast-Event-ID— that stream has noid:on its frames and no replay to ask for (see §3). fetch()the real backend route (${API_URL}/api/admin/realtime/streamor${API_URL}/api/mobile/notifications/stream) withsignal: request.signal, so closing the browser tab aborts the request the proxy made to the backend — without this, every closed tab leaks one open backend connection forever.- If the upstream response is not
ok(a401/403/503), return that status and body as-is, unwrapped, so the browser can tell "not allowed" or "unavailable" from "quiet." Both proxies are explicit that wrapping a denial as a dead stream would make anEventSourceretry against a401forever, indistinguishable from a shop with nothing happening. - Otherwise stream
upstreamResponse.bodystraight through withtext/event-stream,Cache-Control: no-cache, no-transform,Connection: keep-alive, andX-Accel-Buffering: no— never buffered, never awaited to completion (it has none for the life of the tab).
A future non-browser client (a native app, a service that can set its own headers) does not need this proxy at all — it authenticates and opens the backend route directly, exactly as it would any other authenticated GET.
3. Reconnect behaviour, per stream
Both SSE endpoints reconnect automatically through the browser's or platform's EventSource implementation, honoring the retry: directive each stream sends once at connect. Beyond that, the two behave differently and a client must not assume one behaves like the other.
Staff event stream (/api/admin/realtime/stream)
- Every frame that is not a heartbeat carries an
id:. On disconnect,EventSourceautomatically sendsLast-Event-IDset to the last id seen, on its next connection attempt. - The backend replays from a 500-event Redis history buffer (
REALTIME_HISTORY_LIMIT), filtered to the caller's permissions, asmissedframes — treat these as unread, since by definition the client has not seen them. - A first-ever connection (no
Last-Event-IDsent) instead replays the most recent 200 events (REALTIME_COLD_START_LIMIT) ashistoryframes — treat these as already read; they are context for an empty panel, not news. retry: 3000— the server states a 3-second reconnect delay explicitly, so a restarted API does not see every admin panel in the building reconnect in the same instant.- No per-user connection cap exists on this stream today.
- A
503-shaped failure from the BFF proxy (REALTIME_UNREACHABLE) means the backend could not be reached at all — fall back to polling the screens the stream would have enhanced; none of them depend on the stream to be correct.
Personal notification stream (GET /api/mobile/notifications/stream)
Full contract: Notification API §8.14a.
- Frames carry no
id:at all, and there is no replay of any kind. Anotificationframe arrives once, live, and if the connection was not open when it fired, that arrival is not recoverable from the stream. - After any disconnect — including a normal
EventSourceauto-reconnect — refetch, do not assume a gap was filled: callGET /api/mobile/notifications(or just the page the badge belongs to) andGET /api/mobile/notifications/unread-count. Both are cheap, permission-scoped reads backed by Postgres, and are the only source of truth for "what did I miss." - Per-user cap of 5 concurrent streams (
NOTIFICATION_STREAM_MAX_PER_USER). Opening a sixth evicts the oldest open connection on that user's channel, which receivesevent: evicted\ndata: {}immediately before its response ends. - On receiving
evicted, the client must close thatEventSourceand must not auto-reconnect it. Reconnecting immediately re-hits the same cap and evicts a different connection — a loop that never settles. In practice this means: close the stream, then refetch the unread count and list once, and only open a fresh stream later (e.g. on the next natural navigation), not in an immediate retry loop. retry:is sent the same way as the staff stream; treat a503the same way — fall back to polling, the notification centre and badge remain correct from Postgres regardless.
Summary table
| Staff stream | Personal stream | |
|---|---|---|
Frame id: | Yes | No |
| Replay on reconnect | Yes — up to 500 events, permission-filtered | No |
| Cold-start behavior | Last 200 events, marked read | None — starts empty |
| Per-user connection cap | None | 5, oldest evicted |
| On eviction | N/A | evicted frame; do not auto-reconnect |
| What to do after any disconnect | Rely on replay; nothing extra needed | Refetch list + unread count |
4. FCM push setup
This section describes what a client must do to receive push. With PUSH_PROVIDER at its default of mock (see §5), a push send is recorded as delivered but nothing is sent to a device, so this is the contract a client implements against for when the provider is fcm.
Every platform shares the same backend contract: register a token with POST /api/mobile/notification-devices ({ token, platform }, platform one of android | ios | web), and remove it with DELETE /api/mobile/notification-devices/{publicId} on sign-out. Full field-by-field contract: Notification API §8.18–8.19.
Context7 (the MCP tool this guide would normally use to verify exact Firebase SDK call signatures) was not reachable in this session. Per this repository's documentation rules, the steps below are described in words rather than as code, with a link to the authoritative Firebase documentation for the exact API. Do not copy an SDK call signature out of this page from memory when implementing — read it from the linked docs.
Android / iOS (native)
- Integrate the Firebase SDK for the platform (
FirebaseMessagingon Android via the Firebase Android SDK, or the Firebase iOS SDK's messaging component) following Firebase's own platform setup guide: Android client setup and iOS client setup. - After a successful login (
POST /api/mobile/auth/login/email), obtain the current FCM registration token from the platform SDK and callPOST /api/mobile/notification-deviceswith{ token, platform: "android" | "ios" }. - Listen for the platform's token-refresh callback and re-register on every refresh — re-registering an unchanged token is a cheap no-op on the backend (it only touches
last_used_at), so there is no need to track whether the token actually changed before calling the endpoint again. - On sign-out, call
DELETE /api/mobile/notification-devices/{publicId}for the device's own registration before clearing local session state.publicIdis what thePOSTresponse returned when the token was registered — cache it locally alongside the token. - When a push notification is tapped, open
data.actionUrlfrom the notification payload — this is the field both platforms' handlers are documented (in the notification module's own docs) to read for the destination screen.
Web
The web panel is a browser context, which needs three additional pieces beyond a native app: a service worker, a VAPID key, and an explicit permission prompt. Follow Firebase's own web client setup guide for the exact calls, since this guide cannot verify SDK signatures without context7. In order:
- Firebase web config. Initialize the Firebase app in the browser with the project's web config object, per the Firebase web setup guide.
- A service worker that receives background messages. Register a dedicated service worker (conventionally
firebase-messaging-sw.js) that initializes Firebase Messaging and handles background push delivery, per the receiving messages guide. This is what lets a push arrive while no tab has focus. - The permission prompt. Before requesting a token, ask the browser's Notification permission explicitly (the browser's own
Notification.requestPermission()API) — Firebase'sgetToken()call requires permission to already be granted, and requesting it silently on page load is poor practice; gate it behind a user action. - Obtain a token with a VAPID key. Once permission is granted, request a token using the project's VAPID key and the registered service worker's registration, per the messaging client guide linked above. This token is what gets sent as
tokento the backend. - Register with the backend, exactly as the native flow does:
POST /api/mobile/notification-deviceswith{ token, platform: "web" }. The backend documents thatwebregistrations get FCM'swebpushblock with a TTL and, when the notification's action URL ishttps,fcmOptions.link— the field that decides what a click opens when every tab is closed, so nothing else needs to be done client-side to make a notification click open the right page. - Re-register on load, not only once. A web push subscription/token can be invalidated by the browser independently of anything the backend does; re-run the
getToken()step on app load whenever permission is already granted, and re-POST— the same cheap-no-op behavior as the native re-registration. - On sign-out, call
DELETE /api/mobile/notification-devices/{publicId}exactly as on native.
A web push client following this contract already exists, in the admin panel. skoolsewa-frontend (read-only reference from this repo) registers a service worker at /firebase-messaging-sw.js and mounts a component once per dashboard load that either re-registers the browser's current token — re-binding it to the session open at that moment — or, when notification permission is no longer granted, removes the stored device and clears its local record. On sign-out, the same removal runs before the logout request itself, ahead of the session-ending side effect described in the auth backend documentation that would otherwise retire the token anyway once the session ends.
5. Server-side prerequisites
PUSH_PROVIDER is a validated enum in apps/api/src/config/env.validation.ts (z.enum(["mock", "fcm"]).default("mock")), selected in apps/api/src/modules/notification/channels/notification-providers.module.ts. With mock, the API logs at boot that push messages are recorded and not sent, and deliveries are still marked sent, so a device never receives anything. With fcm, sends go to Firebase Cloud Messaging.
Turning this on requires:
- Setting
PUSH_PROVIDER=fcmin the API's environment. - Providing the Firebase service account, in either of the two forms
env.validation.tsdeclares:FIREBASE_SERVICE_ACCOUNT_BASE64(the whole service-account JSON, base64-encoded), or the three fieldsFIREBASE_PROJECT_ID,FIREBASE_CLIENT_EMAILandFIREBASE_PRIVATE_KEY. The project the credentials belong to must be the same project whose web or mobile app issued the device tokens.
Until both are true, every step in §4 above still functions end-to-end on the client (tokens register, the backend records them, sends are attempted) — the only missing piece is delivery to an actual device.
6. Current status
| Piece | Status |
|---|---|
GET /api/admin/realtime/stream (staff event stream) | Built and live; consumed today by the admin panel via its BFF proxy at app/api/admin/realtime/stream/route.ts. |
GET /api/mobile/notifications/stream (personal notification stream) | Built and live; consumed today by the admin panel via its BFF proxy at app/api/admin/inbox/stream/route.ts. |
POST / DELETE /api/mobile/notification-devices | Built and live as an API contract, and called by the admin panel's web push client (see below). |
| A phone/native app | Does not exist. /api/mobile is a path prefix describing an audience (guardian, pupil, teacher, parent), not a statement about devices — its only demonstrated client today is the web panel, which proxies /api/mobile/auth/* and the two SSE streams through its own Next.js server. |
PUSH_PROVIDER | Defaults to mock: a push send is recorded as delivered, but nothing is sent to a device. fcm sends through Firebase. |
See Also
- Realtime API — the staff event stream's full endpoint contract.
- Realtime Backend — how an outbox event becomes a stream frame.
- Notification API — the personal notification stream (§8.14a) and push device registration (§8.18–8.19).