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. 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, scope members:write), body {"duration": "8h" | "1w" | "always"}. A mute silences push only: the socket still delivers and a call still rings. One exception: a message whose body.mentions names the muted member is pushed anyway — a mute silences the room, not someone addressing you.
  5. 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_id per 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.
  6. 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.

On sign-out or account switch, unsubscribe first. A browser hands the same endpoint to every account that subscribes in it, and each account keeps its own subscription. If account A signs out and B signs in without A calling 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.

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.

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.

KeyMeaning
typemessage, call.started or call.ended
room_idThe room the event belongs to.
call_idOn both call types. Ties a ring to its cancel.
senderWho 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-…"
}
You must handle 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 notification block is drawn by Play Services, and onMessageReceived never fires while your app is backgrounded. So a call push carries no notification block: your app is woken, reads data, and posts its own full-screen incoming call on a channel it owns. The cliff: if you do not implement onMessageReceived, 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.

KeyWhen
typeAlways — new_message, call.started or call.ended.
room_idAlways, so you can catch up on one room rather than all of them.
call_idOn both call types.
title, body, senderOnly 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.

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.