Last updated 2026-05-28

Register a Push Token

End-to-end flow for both Web Push (browser, VAPID) and mobile push (FCM). The token is stored in notify's device table keyed by (tenant, user, device_type); sending then just supplies the user id and notify resolves the right token per channel.

When you'd use this

First-launch of your client app, or whenever the browser /OS rolls the underlying token (push services do this occasionally). Always re-register on app start — UpsertDevice is idempotent; a no-change call is a cheap no-op.

Web Push — full round-trip

1. Set up VAPID keys on the server

# One-off. Generate a VAPID key pair and feed the public key to the browser,
# the private key to notify.
npx web-push generate-vapid-keys
# Public Key: BNcL...
# Private Key: Yt8L...
# notify config
NOTIFY_WEBPUSH_PROVIDER=vapid
NOTIFY_WEBPUSH_VAPID_PUBLIC=BNcL...
NOTIFY_WEBPUSH_VAPID_PRIVATE=Yt8L...
NOTIFY_WEBPUSH_CONTACT_EMAIL=mailto:ops@example.com

2. Register the service worker

public/sw.js
// public/sw.js
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title || "Notification", {
body: data.body,
data: data.data,
icon: "/icon-192.png",
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data?.url || "/")
);
});

3. Subscribe and register

features/push/web-push.ts
// features/push/web-push.ts
import { client } from "../../shared/notify-client";
const VAPID_PUBLIC_KEY = "<your public key>";
function urlBase64ToUint8Array(b64: string): Uint8Array {
const padded = (b64 + "=".repeat((4 - b64.length % 4) % 4))
.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(padded);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
export async function enableWebPush(): Promise<void> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
throw new Error("Push API not available in this browser");
}
// 1. Service worker.
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
// 2. Permission. Must be triggered by a user gesture.
const perm = await Notification.requestPermission();
if (perm !== "granted") {
throw new Error("Notification permission denied");
}
// 3. PushManager subscription.
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
}
// 4. Hand the subscription JSON to notify.
await client.registerPushToken({
deviceType: "DEVICE_TYPE_BROWSER",
token: JSON.stringify(sub),
});
}

4. Send via Web Push

From the producer, you don't need to thread the address — once registered, notify will resolve the device token automatically when a follow-up wave wires "store-side address lookup". Until then, in v0.1 supply the subscription JSON in Addresses as well:

curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \
-H 'Content-Type: application/json' \
-H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \
-d "$(jq -n --argjson sub "$WEBPUSH_SUB_JSON" '{
tenantId: "acme",
notificationId: "msg-9001",
userIds: ["user-alice"],
channels: ["DELIVERY_CHANNEL_WEB_PUSH"],
title: "New comment",
body: "Bob commented on Task 42",
addresses: { "user-alice": { byChannel: { web_push: ($sub|tostring) } } }
}')"

Mobile push (FCM) — full round-trip

1. Set up FCM on the server

# Download a service-account JSON from Firebase Console → Project Settings → Service accounts.
# Pass it to notify as a single env var.
NOTIFY_MOBILEPUSH_PROVIDER=fcm
NOTIFY_FCM_PROJECT_ID=my-firebase-project
NOTIFY_FCM_CREDENTIALS_JSON="$(cat ./service-account.json)"

2. Android client — register the FCM token

build.gradle.kts
// implementation("com.google.firebase:firebase-messaging:24.0.0")
import com.google.firebase.messaging.FirebaseMessaging
class NotifyTokenManager(private val notifyApi: NotifyApi) {
fun registerOnStartup() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("notify", "FCM token fetch failed", task.exception)
return@addOnCompleteListener
}
val token = task.result
notifyApi.registerPushToken(
RegisterPushTokenRequest(
deviceType = DeviceType.DEVICE_TYPE_ANDROID,
token = token,
)
)
}
}
}
// Token rotation — implement onNewToken so an OS-side token roll
// re-registers automatically.
class NotifyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
notifyApi.registerPushToken(
RegisterPushTokenRequest(
deviceType = DeviceType.DEVICE_TYPE_ANDROID,
token = token,
)
)
}
}

3. iOS client — register the FCM token

import Firebase
import FirebaseMessaging
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
application.registerForRemoteNotifications()
return true
}
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken else { return }
notifyApi.registerPushToken(
deviceType: .ios,
token: token,
)
}
}

4. Producer-side send via FCM

curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \
-H 'Content-Type: application/json' \
-H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \
-d '{
"tenantId": "acme",
"notificationId": "msg-9001",
"userIds": ["user-alice"],
"channels": ["DELIVERY_CHANNEL_MOBILE_PUSH"],
"title": "Bob",
"body": "Are you around?",
"addresses": {
"user-alice": {
"byChannel": { "mobile_push": "fzM-zR...:APA91bH..." }
}
}
}'

Handling dead tokens

Push tokens go stale — users uninstall apps, browsers unsubscribe, OSes roll tokens. Both providers surface "this token is gone" as a typed error:

  • Web Push: webpush.ErrSubscriptionGone on HTTP 410.
  • FCM: fcm.ErrUnregisteredToken on UNREGISTERED / NOT_FOUND.

The provider does not purge the device row itself — that's the orchestrator/caller's job. The recommended shape is to wire a callback on NotifyResponse failures that calls RegisterPushToken with an empty token to delete the row (a follow-up wave will add an explicit DeleteDevice RPC; for now the recommended path is for the failing producer to remove the token from its own consumer user store and stop sending to that channel).

Related