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.
-
Anyone who has muted the room is skipped — for eight hours, a week or until they
unmute, set by the user themselves on the directory plane
(
PUT /v1/directory/conversations/{room_id}/mute) or by your server on the customer plane (PUT /v1/server/rooms/{room_id}/members/{principal_id}/mute, scopemembers:write), body{"duration": "8h" | "1w" | "always"}. A mute silences push only: the socket still delivers and a call still rings. One exception: a message whosebody.mentionsnames the muted member is pushed anyway — a mute silences the room, not someone addressing you. -
The throttle that applies depends on what it is. A message is limited to one
per member per room per 30 s. A ring is limited to one per member per
call_idper 300 s — deliberately not the room cooldown, which would drop the ring when someone had just texted the same room. A cancel is sent only to the members who were actually rung. - What remains is delivered on every transport that member has registered: Web Push, FCM, APNs.
call.ended pushes too — silently. It is the retraction that stops
the ringing, so it goes only to members who were rung — to every device each of them has
registered at that moment — as a background push with no alert. Nobody sees "a call stopped"; your app sees it and cancels the notification it posted.
How to handle it → Web Push #
// 1. get the server's VAPID public key
const { vapid_public_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: vapid_public_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.
The endpoint must be https with a public host. Cleartext, and
loopback / private / link-local IP literals, are refused with 400: the endpoint is a
bearer capability our worker will later POST to, so accepting a private address would let a
subscription point us at something inside our own network. A hostname is checked again every time
we deliver: if it resolves to such an address we do not connect, and the subscription is dropped.
DELETE /v1/push/subscriptions
with that endpoint, A's pushes — previews included — keep arriving in the browser B now uses. The
push service will not end it, because B keeps the endpoint alive. The delete needs A's token but
not A's room membership, so call it while that token is still valid.
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.ollacore.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": "[email protected]",
"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.
What a native push carries #
Every FCM and APNs push carries structured data alongside the display text, so your client routes
on fields rather than parsing a title. FCM puts it in message.data; APNs puts the same
keys at the top level beside aps.
| Key | Meaning |
|---|---|
type | message, call.started or call.ended |
room_id | The room the event belongs to. |
call_id | On both call types. Ties a ring to its cancel. |
sender | Who sent or called. Absent on a message in an E2EE room; a call always carries its initiator, encrypted room or not. |
encrypted | "true" when the room is end-to-end encrypted. |
// FCM — an incoming call. Data-only, deliberately: with a
// "notification" block Play Services would draw it and onMessageReceived
// would never fire, so your app could not ring.
{
"message": {
"token": "<device token>",
"data": {
"type": "call.started",
"room_id": "0f5c8e21-…",
"call_id": "9b2f4c10-…",
"sender": "ada"
},
"android": {
"priority": "high",
"ttl": "45s",
"collapse_key": "call-9b2f4c10-…"
}
}
}
// FCM — an ordinary message. Carries both, so it still displays
// even if your app does nothing.
{
"message": {
"token": "<device token>",
"notification": { "title": "Ada", "body": "see you at 3" },
"data": { "type": "message", "room_id": "0f5c8e21-…", "sender": "ada" },
"android": {
"priority": "high",
"notification": { "channel_id": "messages" }
}
}
}
// APNs — the same call, then its retraction when the caller hangs up
// headers: apns-push-type: alert, apns-priority: 10,
// apns-collapse-id: call-9b2f4c10-…
{
"aps": { "alert": { "title": "Incoming call from Ada", "body": "" }, "sound": "default" },
"type": "call.started", "room_id": "0f5c8e21-…", "call_id": "9b2f4c10-…", "sender": "ada"
}
// headers: apns-push-type: background, apns-priority: 5,
// apns-collapse-id: call-9b2f4c10-… ← same id, so it supersedes
{
"aps": { "content-available": 1 },
"type": "call.ended", "room_id": "0f5c8e21-…", "call_id": "9b2f4c10-…"
} call.ended, or phones keep ringing. When a caller
hangs up before the callee answers, we send a silent retraction — APNs
content-available with no alert, FCM data-only — carrying the same
call_id. On receipt, cancel the notification you posted for that call. Neither
platform lets a server remove a notification it already delivered; it can only be
superseded, which is why the ring and its cancel share a collapse id and tag of
call-{call_id}.
Two more things that will bite
- Dedupe on
call_id. We already do — one ring per call per member, however the RTC session flaps. Note "per member", not per device: every device that member has registered receives that ring, so a phone and a tablet both ring, and a client that treats every push as a new call will double-ring if it also has a socket open. - On Android, a call is data-only — and that has a cliff. A push carrying a
notificationblock is drawn by Play Services, andonMessageReceivednever fires while your app is backgrounded. So a call push carries no notification block: your app is woken, readsdata, and posts its own full-screen incoming call on a channel it owns. The cliff: if you do not implementonMessageReceived, calls show nothing at all. That is the right trade — a banner Play Services drew cannot ring, cannot start audio, and cannot be dismissed when the caller hangs up — but it means the handler is not optional. Messages are different: they carry both, so they still display if your app does nothing.
Web Push carries a smaller, separate vocabulary
Browsers get their own payload, and its type values are deliberately
not the native ones. new_message predates the call events and
deployed service workers already switch on it, so it stays — the inconsistency is the price of
not breaking them.
| Key | When |
|---|---|
type | Always — new_message, call.started or call.ended. |
room_id | Always, so you can catch up on one room rather than all of them. |
call_id | On both call types. |
title, body, sender | Only outside content-free mode, and never on a call.ended. In an E2EE room a message carries none of them; a ring still carries its title ("Incoming call from …") and sender, because who is calling is metadata we hold, not message content. |
A withheld field is simply absent: there is no encrypted key and no placeholder
title in a Web Push payload. A content-free payload is still routable: you always learn
what happened and where, just not what was said.
Payloads and privacy #
- Full mode carries a title (the sender's display name) and a short preview.
- End-to-end-encrypted rooms: a message push is forced content-free —
no sender, no preview, because the server has no plaintext to preview. The title is a neutral
placeholder and APNs sets
mutable-content, so a Notification Service Extension can decrypt and rewrite it before it is shown. A ring in the same room still names the caller, except on Web Push in content-free mode, which withholds it too. - A call push never carries message content — only the caller and the call id.
- Content-free mode is a deployment setting, and today it governs Web Push only. Native FCM and APNs pushes do not consult it, so on a deployment set to content-free, a native message push still carries the sender's name and a preview. Our hosted service runs in full mode; if your threat model needs content-free native pushes, ask us before relying on the setting.
- Push endpoints and tokens are treated as bearer secrets: they never appear in logs.