Build it

Push & ringing

Push exists to reach members who are not connected. Targeting is presence-aware, so an active conversation never turns into a notification storm.

How targeting works #

  1. A durable event is published (message.created or call.started).
  2. Room members are listed; the sender (or call initiator) is skipped.
  3. Anyone with live presence — i.e. a connected chat socket — is skipped: they already got it.
  4. A per-member, per-room cooldown (30 s) is applied.
  5. What remains is delivered on every transport that member has registered: Web Push, FCM, APNs.
Only call.started rings. call.ended is delivered to sockets and webhooks but deliberately does not push — nobody wants a notification that a call stopped.

Web Push #

// 1. get the server's VAPID public key
const { key } = await fetch("/v1/push/vapid-public-key").then(r => r.json());

// 2. subscribe in the service worker
const sub = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: key,
});

// 3. register it (bearer = the room-scoped session token)
await fetch("/v1/push/subscriptions", {
  method: "POST",
  headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json" },
  body: JSON.stringify(sub),
});

Subscriptions are per device and scoped to the principal that registered them — one user cannot clobber or delete another's. When the push service reports an endpoint as gone (404/410) it is dropped automatically, so dead subscriptions do not accumulate.

Native FCM & APNs #

Native push uses your vendor accounts. You store credentials once per directory app; they are validated at that moment and are never readable back.

curl -X PUT https://api.example.com/v1/account/directory-apps/$APP/push \
  -H "Authorization: Bearer as_…" \
  -H "Content-Type: application/json" \
  -d '{
    "fcm": {
      "service_account": {
        "project_id":   "my-firebase-project",
        "client_email": "fcm@my-project.iam.gserviceaccount.com",
        "private_key":  "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----\n"
      }
    },
    "apns": {
      "p8":      "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----\n",
      "key_id":  "ABC123DEFG",
      "team_id": "TEAM123456",
      "topic":   "com.example.app",
      "env":     "production"
    }
  }'
# → { "push_fcm": true, "push_apns": true }

Then each signed-in device registers its token:

POST /v1/directory/devices
Authorization: Bearer du_…
{ "platform": "fcm", "push_token": "<FCM registration token>" }

GET    /v1/directory/devices     # list this user's devices
DELETE /v1/directory/devices     # { "push_token": "…" } — unregister on logout

The device is stamped with the app it was registered under, which is how the right credentials are chosen when a tenant runs more than one app. Dead tokens — FCM 404 UNREGISTERED, APNs 410 — are dropped automatically.

FieldWhere it comes from
fcm.service_accountFirebase console → Project settings → Service accounts → generate a private key (the JSON file).
apns.p8Apple Developer → Keys → a key with APNs enabled; the downloaded .p8 contents.
apns.key_idThe key's 10-character id.
apns.team_idYour Apple developer team id.
apns.topicThe app's bundle id.
apns.envsandbox for debug builds, production for TestFlight/App Store. Getting this wrong is the most common cause of silent APNs failure.

Or send it yourself #

Native sending is optional. Subscribe a webhook to message.created and call.started and push from your own app-server instead — the events carry everything you need. Both paths are fully supported; letting us send just removes a hop.

Payloads and privacy #

  • Full mode carries a title (sender) and a short preview.
  • Content-free mode carries no sender and no preview — configurable, and forced in end-to-end-encrypted rooms, where the server has no plaintext to preview anyway.
  • A call push is a fixed "Incoming call" — it never carries message content.
  • Push endpoints and tokens are treated as bearer secrets: they never appear in logs.

What push does not guarantee #

Push is best-effort. A delivery job covers all of a user's devices at once, so a transient outage at the push service drops those notifications rather than risking a duplicate notification to devices that already succeeded. Outcomes are counted by transport (delivered/gone/rejected/unsendable) so failures are visible rather than silent — but the durable record is always the message in the room, never the notification. Your client must reconcile on reconnect, not depend on push having arrived.
Verify on a real device. The vendor round-trip cannot be exercised in CI — it needs real credentials and a real device token. Background the app, have someone message and call you, and confirm both a notification and a ring before you ship.