Skip to content

Push notifications — design

Canonical specification

This page is ProjectInfo/push-notifications.md on docs/encryption-modes-spec, rendered at build time. That file is the source of truth; the site holds no copy of it.

Small differences from the implementation are listed in the spec errata.

Status (2026-10-15): implemented for Android. Server on the push-notifications branch of Server, client on the push-notifications branch of Client-Flutter. iOS/APNs and desktop are out of scope; web keeps its in-app behaviour and registers nothing.

Two transports, chosen per device:

  • FCM, for phones with Google Play services. Content-free: the server sends a data-only wake that names nobody and nothing.
  • UnifiedPush, for phones with a distributor installed (ntfy, NextPush, Sunup…), which is how a de-Googled phone receives push at all. Same wake, additionally encrypted end to end to the app (RFC 8291) so the distributor's server sees only an opaque blob.

If a UnifiedPush distributor is installed, it wins. Someone who installed one did so on purpose.


1. The model: a wake, not a message

The Signal model, adopted wholesale:

  1. A message arrives for you. The server records that something is waiting for this device and sends the device a wake.
  2. The wake carries no sender, no channel, no server, no content and no count — a constant {"v":"1","t":"wake"}. Its size never varies.
  3. The app wakes, authenticates to its own instance over its own TLS connection, and asks what is waiting (POST /api/push/drain).
  4. It fetches those messages through the ordinary REST endpoints, decrypts them on the device with the keys it already holds, decides whether this is worth a notification, and posts a local notification.

The notification text — sender name, channel, message preview — is assembled on the phone from decrypted data. No intermediary (Google, the distributor, or the instance beyond what it already knows) ever sees it.

Why not put a sender name in the push, as the old unrouted code did ("Message from alice")? Because then Google holds a log of who messages whom, timestamped, for every user of the main server. The hosting-model rule — on the main server anything the operator can read is a defect unless chosen — applies with more force to a third party the user never chose.

2. What the server stores per device

One registration per (user, device), in push_registrations (migration 000022):

Column Meaning
user_id, device_id The device, as named in its JWT (device_id claim). One row per pair — switching transport replaces the row.
transport fcm or unifiedpush
token FCM registration token, or the UnifiedPush endpoint URL
up_p256dh, up_auth The app's RFC 8291 public key and auth secret (UnifiedPush only, optional). Without them the wake is posted in the clear — it is a constant, so this leaks nothing but its existence.
wake_outstanding, last_woken_at Coalescing state (§4)
failures Consecutive transient send failures
created_at, updated_at updated_at is bumped every time the app re-registers, which it does on every start

The per-channel queue the app drains on wake is push_pending, one row per (registration, channel) — not per message, so a busy channel costs one row per recipient device however many messages it carries:

Column Meaning
registration_id, channel_id Primary key
message_count Messages since the device last drained
first_message_id, first_at, last_message_id Where to start reading
is_dm Whether this is a DM / group DM — drives wake priority (§4)

The previous device_tokens table (migration 000010) is dropped by 000022. It was written by a worker endpoint (POST /api/device-tokens) that nginx never routed, so no client could ever reach it and no row could exist.

Pruning stale registrations

A registration is deleted when any of these holds:

  1. The transport says the token is dead. FCM 404 UNREGISTERED or 400 INVALID_ARGUMENT on the token; a UnifiedPush endpoint answering 404 or 410. Deleted on the spot.
  2. The device has no live session. No unrevoked, unexpired refresh_tokens row for that (user_id, device_id). This one rule covers logout, password change (which revokes every session), account deletion, device revocation and simply not opening the app until the refresh token expires. The worker sweeps for it every 10 minutes. A device that cannot authenticate cannot drain a wake, so waking it is pure cost — and a wake that keeps arriving after you signed out is a privacy leak in its own right.
  3. Device revocation (DELETE /api/keys/devices/{id}) deletes it in the same transaction as the key bundle and refresh tokens, rather than waiting for the sweep.
  4. Explicit unregister — the app calls DELETE /api/push/registration on logout, before clearing its tokens.
  5. Ten consecutive transient failures (5xx, timeouts), so an endpoint on a self-hosted ntfy that went away for good does not get retried forever.
  6. Not re-registered for 90 days. Belt and braces; rule 2 normally fires first.

push_pending rows cascade with their registration, and any row untouched for 7 days is dropped.

3. What triggers a wake

The worker consumes the existing channel.*.MESSAGE_CREATE NATS events — the same ones the gateway forwards — for both server channels and DMs.

Recipients:

Channel Recipients
DM / group DM Every channel member except the author
Server text channel Every server member who holds ViewChannel for that channel (full resolution: owner, roles, overwrites — middleware.ComputePermissions), except the author

minus anyone with a live mute on the channel or its server (notification_settings, the table behind /api/notifications). A muted channel never wakes the phone at all; that is the battery win of doing the mute check server-side.

A DM call needs nothing extra: its ring is an ordinary E2EE DM message (payload t: "call"), so it already wakes the callee's devices as an urgent DM, and the phone shows "Incoming voice call" after decrypting it. See dm-calls.md §6.

ViewChannel matters even though the wake is content-free: POST /api/push/drain returns channel ids, and a channel's existence is exactly what a member denied ViewChannel must not learn.

Mentions are decided on the phone

The server cannot see mentions in either mode: they are <@id> markers inside the ciphertext (see ProjectInfo/client/messaging.md). Adding a plaintext "mentioned users" field to the send request would hand the server a who-addresses-whom graph it does not have today. So, as Signal does for group @-mentions, the phone decides:

Message Notification
DM or group DM Always (unless muted)
Server channel, mentions you or @everyone Yes
Server channel, anything else No — woken, drained, nothing shown
Muted scope Never (re-checked on the phone, in case the mute landed after the wake)
Your own message from another device Never

Every unmuted server-channel message therefore wakes the phone once, even if it ends up showing nothing. Coalescing (§4) is what makes that affordable: a channel that is busy all afternoon costs at most one wake per 15 seconds per device, not one per message.

There is no per-channel "all messages" level yet. When it arrives it is a client-side preference applied in the same decision table; no server change is needed.

4. Coalescing and rate limits

Per registration:

  • At most one outstanding wake. After a wake is sent, wake_outstanding is set. New messages only add to push_pending. The flag clears when the device drains (POST /api/push/drain).
  • Minimum interval 15 s between wakes to one device, even after a drain.
  • Re-wake after 15 minutes if a wake is still outstanding and pending rows exist — a wake can be dropped (phone off, FCM throttling), and nothing else would ever retry it.
  • A message that arrives inside the 15 s window is not lost: a sweeper (every 5 s) wakes any registration that has pending rows, no outstanding wake, and has passed its interval.
  • Claiming a wake is a single conditional UPDATE … RETURNING, so two worker replicas cannot both send one.

Ceiling: 240 wakes per device per hour, reached only by a device that drains every wake instantly in a channel busier than one message per 15 s.

Priority. Android de-prioritises an app whose high-priority FCM messages mostly do not produce a visible notification, and a server-channel wake mostly does not. So:

  • pending includes a DM → FCM priority: HIGH, UnifiedPush Urgency: high
  • server channels only → NORMAL / normal. In Doze a mention can therefore arrive late, at the next maintenance window. That is the trade.

FCM wakes carry collapse_key: "wake" and a 1-hour TTL, so a phone that comes back online gets one wake, not a backlog. UnifiedPush gets TTL: 3600 and Topic: wake, which RFC 8030 servers use the same way.

Registration endpoints are covered by the API's global per-user limit (50 req/s). Nothing a client does can make the server send a push except causing a message to be created, which has its own limit (5 per 5 s).

5. What Google (and a UnifiedPush server) can still see

FCM, for each wake: the app's package (uk.zigz.zippchat), the device's FCM token — which Google can tie to the device and its Google account — the sending Firebase project, the time, the priority, and a constant-size payload.

UnifiedPush: the distributor's server (ntfy.sh, or one the user runs) sees the endpoint, the time, the urgency and an encrypted constant-size blob. It does not see the package name, and need not see a Google account at all.

Neither sees: the sender, the channel, the server, the instance's user id, the content, or even how many messages a wake stands for.

What remains is timing. Someone holding wake times for many devices can correlate them: two devices that wake alternately all evening are probably talking. That is traffic analysis, and it is the same exposure Signal accepts with the same design. Why it is acceptable here:

  • The alternative that removes it — no push — means a messenger that is unusable on Android, which pushes people to apps that put the content in the push.
  • The instance already sees the same timing (it routes the messages), so FCM learns nothing the operator could not; it is a second party holding it, not new information.
  • Coalescing blurs it: a wake is "at least one message in the last 15 s or more", not one per message, and mutes suppress it entirely.
  • Anyone for whom Google holding timing is unacceptable has UnifiedPush with a self-hosted distributor, where no third party is involved at all.

6. The self-hosting problem

An FCM token is bound to the Firebase project of the app that minted it. The official APK uses ZippChat's project, so only a server holding ZippChat's service-account credentials can wake it. A self-hosted instance does not have them and must not.

Options

A. Main-instance push relay. Self-hosted instances POST {fcm_token, priority} to push-relay.zippchat…, which sends the FCM message.

  • What the relay learns: that this instance exists and is active; every FCM token registered on it (stable per install, so linkable to the same phone on the main server if its owner also has a main account); and the wake timing of every one of those users. In other words, ZippChat — the operator the hosting model explicitly says a self-hoster is choosing not to rely on — gets a metadata feed about that instance's users.
  • Authentication: instances register for a relay key; the relay rate-limits per key. That stops anonymous spam but not a registered instance waking arbitrary tokens. To stop that, the app would have to obtain a relay-signed grant per (token, instance) at registration time, which means the app contacting main ZippChat on behalf of a self-hosted login — more metadata, more moving parts, and a dependency on the main service that a self-hosted instance otherwise does not have.
  • Cost: the main project carries the delivery load and the abuse handling for instances it does not run.

B. UnifiedPush only for self-hosted instances. The instance posts to whatever distributor endpoint the device registered. No dependency on the main project, nothing flows to it. The cost lands on users of self-hosted instances on stock Android: they have to install a distributor app (ntfy from F-Droid or Play) and, ideally, point it at a server they trust.

C. FCM disabled, nothing else. Strictly worse than B, which is C plus a working path.

D. (Supported, not recommended for most.) A self-hoster who builds their own APK with their own google-services.json can configure FCM on their instance with their own credentials. Nothing in the design prevents it; it just isn't the official app.

Recommendation: B, now; revisit A only on demand

UnifiedPush-only for self-hosted instances. It is the only option that keeps the hosting model's boundary intact — a self-hosted instance neither depends on main ZippChat nor tells it anything — and it is already built, because the de-Googled path needs it anyway.

The trade, stated plainly: a self-hosted instance's users on stock Android must install a UnifiedPush distributor to get notifications. Without one, Settings → Notifications says so and names the fix; the app still works, it just only notifies while open.

This needs no switch: a self-hosted instance simply has no FCM credentials, so GET /api/push/config reports fcm: false and the app does not offer it. If demand for A appears, it should be opt-in per instance, documented as "this tells ZippChat when your users get messages", and built with the per-token grant so a relay key cannot be used to wake arbitrary phones.

Multi-instance installs

One installation signs in to one instance at a time today (see client/state-model.md and hosting-model.md). A UnifiedPush registration is made per instance (the connector's instance name is the instance origin), so a wake arrives already tagged locally with which server it came from, without that appearing on the wire. An FCM wake carries no instance, and is drained against the instance the app is signed in to — correct while there is only one. Cross-instance identity will need FCM wakes to carry an opaque per-instance tag; that is a constant per instance, not per user, so it adds nothing a timing observer cannot already infer from which token is woken.

7. API

All on the api service (/api/push/…, routed by the existing /api/ nginx block), all authenticated, all scoped to the calling device — the JWT's device_id claim, never a body field.

GET    /api/push/config        → {"fcm": bool, "unifiedpush": true}
PUT    /api/push/registration  {transport, token, p256dh?, auth?}
                               → 200 {transport, created_at, updated_at}
GET    /api/push/registration  → 200 {…} | 404
DELETE /api/push/registration  → 204
POST   /api/push/drain         → 200 [{channel_id, server_id, is_dm,
                                       message_count, first_message_id,
                                       first_at, last_message_id}]

POST /api/push/drain drains: it deletes what it returns and clears the outstanding-wake flag in one transaction. A second device has its own queue. Full request/response detail lives in Server/docs/api.md.

PUT validation: transport is fcm or unifiedpush; a UnifiedPush token must be an https:// URL (or http:// in development), at most 2048 bytes; p256dh must decode to a 65-byte uncompressed P-256 point and auth to 16 bytes, both or neither. An endpoint URL is attacker-chosen, so the worker posts to it with a 10 s timeout, no redirects, and a constant body — it can be aimed at an internal address, but can carry nothing there and read nothing back. PUSH_UP_ALLOW_PRIVATE=false (the production default) additionally refuses endpoints that resolve to loopback, link-local or private ranges.

fcm: true means the operator configured FCM credentials (FCM_PROJECT_ID set on the api, and the service account on the worker).

8. Client behaviour (Android)

  • Transport choice, on every start and login: a UnifiedPush distributor is installed → UnifiedPush; else the build has Firebase config and the instance reports fcm: true → FCM; else none, and the settings screen says why.
  • Registration is re-sent on every start, every login and every token or endpoint change; on logout the app unregisters before discarding its tokens.
  • Handling a wake:
  • App process alive (foreground or background): the wake is forwarded to the running UI isolate, which owns the session, keys and ratchet state.
  • App process dead: the background isolate restores the session from the stored refresh token, drains, fetches, decrypts and notifies on its own.
  • Exactly one isolate handles a wake; the DM ratchet is stateful and two isolates advancing it at once would make messages permanently unreadable. The remaining race — the user opens the app while a background wake is mid-decrypt — is narrow and fails as an unreadable message, not as a leak.
  • Notifications are grouped per channel; tapping one opens the app.
  • Channels: Android notification channels dm (high importance) and mentions (default), so users can tune them in system settings.

9. Operator setup

Main instance (FCM)

  1. Create a Firebase project; add an Android app with package uk.zigz.zippchat.
  2. Download google-services.json into Client-Flutter/android/app/. The Gradle build applies the Google Services plugin only when that file exists, so builds without it keep working with FCM off.
  3. Create a service account with the Firebase Cloud Messaging API Admin role; download its JSON key.
  4. Worker: FCM_PROJECT_ID=<project id> and FCM_CREDENTIALS_JSON=<the key JSON> (from Vault / External Secrets in k8s). api: FCM_PROJECT_ID=<project id>.

Without step 4 the worker logs FCM disabled once at start, the api reports fcm: false, and UnifiedPush is unaffected.

Any instance (UnifiedPush)

Nothing to configure. The worker posts to whichever endpoint the device registered. Users install a distributor — ntfy is the usual one — and may point it at a self-hosted ntfy server.