Notifications and Live Updates
Push registration and delivery, the in-app badge, and the server-sent event stream.
Two mechanisms, with different jobs. Push (FCM) interrupts somebody who is not looking at the app. The live stream (Live updates (SSE)) keeps an app that is open from showing stale data. They are not alternatives — a client wants both, and they are deliberately built so that either can arrive first without the other producing a duplicate.
Push: register on every launch
Firebase Cloud Messaging, the same project as the web app.
POST /api/v1/push/tokens
{ "token": "<fcm-registration-token>", "platform": "ios", "userAgent": "SuperPeople/1.2.0 (iPhone15,2; iOS 18.2)" }platform is ios, android or web, and is not optional for you — it
decides the shape of the message FCM is asked to send. Omit it and you are
registered as a browser, and nothing is drawn while the app is backgrounded.
Call this on every launch, not only when permission is first granted. FCM rotates tokens, and a stale one stops receiving without saying so. Also call it from the SDK's token-refresh callback.
What arrives
Native clients get an FCM notification block (so the OS draws the alert with
the app backgrounded or killed) and a data payload:
{ "title": "Priya commented on your post", "body": "Nice work on this", "link": "/feed#post-abc123", "kind": "comment" }kind is one of mention, comment, reply, reaction, recognition,
poll_closed. link is a web path — map it to a screen:
link | Screen |
|---|---|
/feed#post-<id> | Post detail, GET /posts/{id} |
/feed | The feed |
Because the same data is sent to all platforms, your tap handler is one piece
of code whichever layer drew the alert.
Platform setup
- Android: create the notification channel
super-peopleat first launch. Android 8+ silently drops a notification whose channel does not exist. - iOS: the payload sets
content-available: 1so a backgrounded app is handed thedataalongside the alert. Enable the Remote notifications background mode and upload the APNs key to Firebase.
On sign-out
DELETE /api/v1/push/tokens/{token}Before signing out, while the session still exists. Skipping this leaves the device receiving the previous person's notifications.
In-app badge
GET /api/v1/notifications/unread-count is cheap. Call it on launch and on
foreground; after that let the notification.created event (Live updates (SSE)) move the
badge, and fall back to a foreground refresh when the stream is not connected.
Do not poll it on a timer — the web client used to, and that 30-second delay was
the whole reason this stream exists.
POST /api/v1/notifications/read with no body marks everything read — what
opening the bell means. It emits notification.read to the person's other
clients, so a badge cleared on a laptop clears on the phone too.
Live updates (SSE)
GET /api/v1/realtime/stream
Authorization: Bearer <token>
Accept: text/event-streamOne long-lived Server-Sent Events connection that says what changed, so a client can re-read exactly that and nothing else. It is behind the same session guard as every other route, so the bearer token you already hold is all it needs.
Not in the OpenAPI spec, on purpose. A generator would describe this as a single JSON response and produce a client that waits forever for a body that never ends. Hand-write this one, or use your platform's SSE library.
Events carry identifiers, never rows. A post.reaction.updated names a post
id; you then GET /api/v1/posts/{postId} for it. That is deliberate — the
answer differs per reader (hasBookmarked, viewerReaction, a poll that hides
its tallies until you vote), so it has to be computed for you, by the routes
that already know how.
The frames
Four named SSE events. Only change matters to your data layer.
| Event | What to do |
|---|---|
ready | The stream is up. data is { "epoch": "<uuid>" }. |
change | data is one event object, below. Act on it. |
resync | The server cannot say what you missed. Refetch whatever is on screen. |
ping | Keep-alive, every 25s. Ignore it — but its absence is how you notice a dead connection. |
A change frame:
id: 9f8c1b2e-...:1487
event: change
data: {"seq":1487,"type":"post.comment.created","at":"2026-09-16T10:22:41.913Z","actorId":"usr_abc","payload":{"postId":"pst_123","commentId":"cmt_456","parentId":null}}Event types
| Type | Payload | Audience |
|---|---|---|
post.created | postId, authorId, isAnnouncement, isRecognition, hasPoll | everyone |
post.updated · post.deleted | postId (+ authorId on delete) | everyone |
post.reaction.updated · post.share.updated · post.poll.updated | postId | everyone |
post.comment.created · post.comment.deleted | postId, commentId, parentId | everyone |
post.comment.vote.updated | postId, commentId | everyone |
notification.created | notificationId, kind | you only |
notification.read | notificationId (null = all) | you only |
profile.updated | userId | everyone |
profile.avatar.updated | userId, image | everyone |
member.created · member.updated | userId | everyone |
team.updated | teamId (null = re-read the list) | everyone |
event.updated | eventId (null = re-read the list) | everyone |
presence.updated | userId, presence | everyone |
bookmark.updated | postId, bookmarked | you only |
follow.updated | followingId, following | you only |
presence.updated and profile.avatar.updated carry the new value, so apply
those directly — re-reading a directory page to learn a word the server already
sent is the one case where a fetch is pure waste.
actorId is attribution, not suppression
actorId says who caused the change. It names a person, and that person may
have your phone and two browser tabs open — so never skip an event because
actorId is you. Doing that is exactly how one of your own devices goes stale.
Use it to say who did something, never to decide whether to apply it.
Reconnecting
The connection will drop — sleep, a tunnel, a deploy. Reconnect with the last
id you saw in a Last-Event-ID header and the server replays the gap.
Browsers do this on their own; a native client must send the header itself.
The id is <epoch>:<seq>. The epoch changes when the API restarts, because seq
restarts with it — so an id from a previous epoch names an unrelated event. When
the server cannot honestly fill the gap (a restart, or a disconnection longer
than its buffer reaches) it sends resync instead of a partial replay. Treat
resync as "refetch what you are showing."
Budget one connection per user, not per screen
The stream holds a socket open for as long as it is connected. Open it once, at the app level, and fan events out internally — do not open one per screen or per view model. The web client goes further and shares a single connection across browser tabs.
If it is down, nothing breaks
This is an accelerator, not a dependency. With the stream disconnected every route still answers, every mutation still returns the updated object, and push still arrives. Fall back to refetching on foreground, and resync after the stream comes back.