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 #
- A durable event is published (
message.createdorcall.started). - Room members are listed; the sender (or call initiator) is skipped.
- Anyone with live presence — i.e. a connected chat socket — is skipped: they already got it.
- A per-member, per-room cooldown (30 s) is applied.
- What remains is delivered on every transport that member has registered: Web Push, FCM, APNs.
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.
| Field | Where it comes from |
|---|---|
fcm.service_account | Firebase console → Project settings → Service accounts → generate a private key (the JSON file). |
apns.p8 | Apple Developer → Keys → a key with APNs enabled; the downloaded .p8 contents. |
apns.key_id | The key's 10-character id. |
apns.team_id | Your Apple developer team id. |
apns.topic | The app's bundle id. |
apns.env | sandbox 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.