Cariosan

Push notifications

Notify users of new messages when the app is closed — FCM, minimal payload, bring-your-own Firebase.

3 min readUpdated May 31, 2026

Push delivers a notification when a new message arrives in a channel the user belongs to — even with the app closed. It's FCM-only (one API covering Android, iOS via APNs, and Web), opt-in, and privacy-first: only a title is ever sent, never the message body.

How it works

  1. The client obtains an FCM registration token from the device and calls client.registerDevice(token, platform).
  2. On a new message, the server looks up every other channel member's device tokens and sends a minimal FCM notification — "{sender} sent a message" plus channel_id + message_id in the data payload.
  3. The app fetches the actual content after it opens (using the ids) — so message text never transits the push network. This matters for healthtech/fintech.

The send is fire-and-forget and never blocks the message send path; the sender is excluded from their own pushes.

register.ts
// After getting a token from Expo / the FCM SDK:
await client.registerDevice(fcmToken, "android"); // "ios" | "android" | "web"
 
// On logout:
await client.unregisterDevice(fcmToken);

Enabling it (self-host = BYO Firebase)

Push is off by default — device registration is accepted, but nothing is delivered until you configure Firebase:

  1. Create a Firebase project and download a service-account JSON key.
  2. Set CARIOS_FCM_SERVICE_ACCOUNT_FILE (and optionally CARIOS_FCM_PROJECT_ID) — see environment.
  3. For iOS, upload an APNs auth key to the Firebase project (Apple Developer account). No server-side APNs code is needed — FCM routes to APNs.

FCM is free (no per-push charge). The self-hoster supplies their own Firebase — Cariosan charges nothing.

Minimal payload by default

Notifications carry a title only. The body is intentionally omitted; the app fetches content after opening via the channel_id/message_id in the data payload. There's no server config to include the body — privacy is the default, not an option.

Was this page helpful?

On this page