Super People Docs
Getting Started

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:

linkScreen
/feed#post-<id>Post detail, GET /posts/{id}
/feedThe 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-people at first launch. Android 8+ silently drops a notification whose channel does not exist.
  • iOS: the payload sets content-available: 1 so a backgrounded app is handed the data alongside 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-stream

One 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.

EventWhat to do
readyThe stream is up. data is { "epoch": "<uuid>" }.
changedata is one event object, below. Act on it.
resyncThe server cannot say what you missed. Refetch whatever is on screen.
pingKeep-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

TypePayloadAudience
post.createdpostId, authorId, isAnnouncement, isRecognition, hasPolleveryone
post.updated · post.deletedpostId (+ authorId on delete)everyone
post.reaction.updated · post.share.updated · post.poll.updatedpostIdeveryone
post.comment.created · post.comment.deletedpostId, commentId, parentIdeveryone
post.comment.vote.updatedpostId, commentIdeveryone
notification.creatednotificationId, kindyou only
notification.readnotificationId (null = all)you only
profile.updateduserIdeveryone
profile.avatar.updateduserId, imageeveryone
member.created · member.updateduserIdeveryone
team.updatedteamId (null = re-read the list)everyone
event.updatedeventId (null = re-read the list)everyone
presence.updateduserId, presenceeveryone
bookmark.updatedpostId, bookmarkedyou only
follow.updatedfollowingId, followingyou 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.

On this page