Web Push
Send browser push notifications from any web app — read your app's VAPID public key, add one service worker, subscribe the browser, and register the PushSubscription with the API using your publishable web key.
Web push reaches browsers — Chrome, Edge, Firefox, Safari — through the standard Web Push protocol. It is the browser token type next to APNs (iOS) and FCM (Android): a PushSubscription registers exactly like a device token, and every universal send reaches it through the same pipeline.
| Native push | Web push |
|---|---|
| You upload APNs / FCM credentials | Nothing to upload — Native Notify generates your app's VAPID keypair server-side |
| The client gets a token from the OS | The client's service worker subscribes with your VAPID public key and gets a subscription (endpoint + keys) |
Sending needs your .p8 / service account | Sending is signed with the per-app VAPID private key (held encrypted server-side) |
Everything that runs in the browser authenticates with your app's publishable web key (nnweb_…) — a page-source-safe credential that can register/deregister one browser device and read that device's own inbox, and nothing else. Read (or create) it under App Settings → App keys, or with the App Keys endpoint. Sends are server-side calls and keep using the app token.
1. Get your VAPID public key
The keypair is created for your app the first time this endpoint is called — there is no dashboard step and no value to paste anywhere:
GET https://app.nativenotify.com/api/universal/web-push/keys/<APP_ID>/<APP_TOKEN>
{
"ok": true,
"vapid": {
"publicKey": "BEl6...your public key...",
"applicationServerKey": "BEl6...the same value...",
"subject": "mailto:support@nativenotify.com",
"createdAt": "2026-09-27T12:00:00.000Z",
"rotatedAt": null,
"previousPublicKey": null
},
"webPush": { "tokenType": "web", "registerPath": "/api/universal/device/register", "maxPayloadBytes": 3993 }
}
publicKey is exactly what pushManager.subscribe() takes as applicationServerKey. It is public by design — the private half never leaves the server. The code below reads it at runtime, so a key rotation never leaves a stale key in your site. maxPayloadBytes is the largest payload every push service is guaranteed to accept, before encryption.
2. Add the service worker
The service worker is the only file you must add. Put it at the site root (/sw.js, e.g. public/sw.js) so its scope covers your whole app. Step 3 registers it as /sw.js?appId=…&webKey=…&deviceId=…, which lets the worker re-register the browser on its own when the push service replaces a subscription.
const API = "https://app.nativenotify.com";
// appId / webKey / deviceId come from the registration URL (see step 3).
const CONFIG = new URL(self.location.search, self.location.href).searchParams;
// Apply a new version of this worker right away instead of waiting for every tab to close.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
self.addEventListener("push", (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (err) {
payload = { title: "Notification", body: event.data ? event.data.text() : "" };
}
event.waitUntil(
self.registration.showNotification(payload.title || "Notification", {
body: payload.body || "",
image: payload.image, // the send's bigPictureURL, when one was set
tag: payload.tag, // the send's collapseId: a newer push replaces an older one
data: payload.data || {}, // your pushData (plus nn_notification_id / nn_source)
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const data = event.notification.data || {};
// Deep links come from your pushData: send {"url": "/offers"}.
const url = new URL(data.url || "/", self.location.origin).href;
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
const open = windows.find((client) => client.url === url);
return open ? open.focus() : clients.openWindow(url);
})
);
});
// The push service replaced this browser's subscription (Firefox does this):
// subscribe again with the app's current key and register the new endpoint.
self.addEventListener("pushsubscriptionchange", (event) => {
event.waitUntil(
(async () => {
const subscription =
event.newSubscription ||
(await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(await currentPublicKey()),
}));
await registerSubscription(subscription);
})()
);
});
async function currentPublicKey() {
const res = await fetch(`${API}/api/universal/web-push/keys/${CONFIG.get("appId")}/${CONFIG.get("webKey")}`);
if (!res.ok) throw new Error(`Could not read the VAPID key (${res.status})`);
return (await res.json()).vapid.publicKey;
}
async function registerSubscription(subscription) {
const res = await fetch(`${API}/api/universal/device/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: Number(CONFIG.get("appId")),
// The web key rides the appToken field — the API accepts either
// credential there and scopes the web key to this device.
appToken: CONFIG.get("webKey"),
platform: "web",
deviceId: CONFIG.get("deviceId"),
webPush: subscription.toJSON(),
}),
});
if (!res.ok) throw new Error(`Native Notify registration failed (${res.status})`);
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
Already have a service worker?:
A scope has exactly one service worker. If your site already registers one at the root (a PWA or offline cache), add these listeners to that file instead of creating a second worker, and register it with the same query string.
3. Subscribe the browser and register it
Ask for permission from a click — Safari and Firefox only show the prompt after a user gesture, and a denied prompt cannot be asked again from code. The subscription JSON ({ endpoint, keys: { p256dh, auth } }) is then registered through webPush with platform: "web".
iPhone / iPad (Safari):
On iOS 16.4+, web push works only when the site has been added to the Home Screen (Safari → Share → Add to Home Screen) and is opened from there. Ship a web app manifest with "display": "standalone", and ask for permission from a tap inside the Home Screen app. Web push also requires HTTPS (localhost is the only dev exception).
const NN = {
appId: 123, // your app id — read both from your config
webKey: "YOUR_WEB_KEY", // the publishable web key — safe in page source
api: "https://app.nativenotify.com",
};
// Call from a click: asks for permission, then subscribes and registers.
export async function registerWebPush() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
throw new Error("This browser does not support web push.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") return null;
return syncWebPush();
}
// Safe on every page load: does nothing until permission is granted, reuses
// the existing subscription, replaces one made with an old (rotated) key,
// and re-registers idempotently.
export async function syncWebPush() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return null;
if (Notification.permission !== "granted") return null;
const deviceId = getOrCreateDeviceId();
const params = new URLSearchParams({ appId: String(NN.appId), webKey: NN.webKey, deviceId });
await navigator.serviceWorker.register(`/sw.js?${params}`);
// subscribe() needs an ACTIVE worker — wait for it instead of racing the install.
const registration = await navigator.serviceWorker.ready;
const keyRes = await fetch(`${NN.api}/api/universal/web-push/keys/${NN.appId}/${NN.webKey}`);
if (!keyRes.ok) throw new Error(`Could not read the VAPID key (${keyRes.status})`);
const { vapid } = await keyRes.json();
let subscription = await registration.pushManager.getSubscription();
const subscribedKey = subscription && subscription.options && subscription.options.applicationServerKey;
if (subscribedKey && !sameKey(subscribedKey, vapid.publicKey)) {
// Made with a key that has since been rotated: the browser refuses to
// subscribe with a new key until the old subscription is gone.
await subscription.unsubscribe();
subscription = null;
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
});
}
const res = await fetch(`${NN.api}/api/universal/device/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: NN.appId,
// The web key goes in the appToken field — the API accepts either
// credential there and scopes the web key to this device's inbox.
appToken: NN.webKey,
platform: "web",
deviceId, // one stable id per browser
webPush: subscription.toJSON(), // { endpoint, expirationTime, keys: { p256dh, auth } }
}),
});
if (!res.ok) throw new Error(`Native Notify registration failed (${res.status})`);
return subscription;
}
function getOrCreateDeviceId() {
// One key for the whole web story — the notification-bell widget reads its
// inbox under "nn_web_device_id" too, so the bell shows exactly the pushes
// this browser receives.
let id = localStorage.getItem("nn_web_device_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("nn_web_device_id", id);
}
return id;
}
function sameKey(buffer, base64url) {
const a = new Uint8Array(buffer);
const b = urlBase64ToUint8Array(base64url);
return a.length === b.length && a.every((byte, i) => byte === b[i]);
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
<button id="enable-push">Enable notifications</button>
<script type="module">
import { registerWebPush, syncWebPush } from "/web-push.js";
document.querySelector("#enable-push").addEventListener("click", () => {
registerWebPush().catch((err) => console.warn("Web push:", err));
});
// Keep an already-allowed browser registered on every visit (no prompt).
syncWebPush().catch((err) => console.warn("Web push:", err));
</script>
Re-registering the same browser is idempotent: the endpoint is the token's identity, so a repeat registration refreshes the row instead of duplicating it, and it also clears a token that had been retired. Running syncWebPush() on every visit therefore keeps each allowed browser registered — including after a key rotation, when it re-subscribes with the new key.
4. Send to browsers
Browsers are part of every normal universal send — audience: { type: "all" }, a device list, subscriber ids or a group key all reach web devices with no extra flag:
{
"appId": 123,
"appToken": "yourAppToken",
"title": "Fresh bread today",
"message": "The bakery opens at 7 — come early.",
"pushData": { "url": "/menu" },
"bigPictureURL": "https://example.com/bread.jpg",
"collapseId": "daily-menu",
"ttl": 3600,
"audience": { "type": "all" }
}
| Send field | Web push meaning |
|---|---|
title / message | The notification's title and body |
pushData | event.data.json().data in your service worker (plus nn_notification_id / nn_source) |
subtitle | event.data.json().subtitle — not shown by default; render it yourself if you want it |
bigPictureURL | image on the notification (dropped automatically when the payload would exceed maxPayloadBytes) |
collapseId | The notification tag — a newer push replaces an older one with the same tag |
ttl / expiration | How long the push service may hold the message |
The response reports web deliveries like the other transports: byType.web.accepted is what the browser's push service accepted for delivery; delivered stays null because no transport can prove display.
Dead subscriptions and verification
Test one browser without sending to everyone — /api/universal/test-send with tokenType: "web" and the subscription's endpoint as token.
A subscription the user revoked answers 404 or 410 from the push service. That is the push service's definitive verdict on that endpoint, so the subscription is retired on the first such answer (the strike is recorded with it) and excluded from future audiences. Other push-service answers (rate limits, transient errors) never strike the token, and a 401/403 means the VAPID keys are wrong for that subscription — reported as a credential problem, not a dead browser. GET /api/universal/health/<APP_ID>/<APP_TOKEN> counts browser subscriptions under tokens.web (live) and retired.web.
The server only ever connects to public push services: an endpoint that is — or resolves to — a loopback, private-network or link-local address is refused before any connection is made and reported as EndpointNotAllowed (never a strike).
Rotating the VAPID keys
Only do this if the keys leaked — rotation invalidates every existing browser subscription, because each subscription is bound to the public key it was created with:
POST https://app.nativenotify.com/api/universal/web-push/keys/<APP_ID>/<APP_TOKEN>/rotate
{ "confirm": true }
Without "confirm": true the call answers 400 confirmation_required and nothing changes. An optional subject (a mailto: address or an https:// URL) sets the VAPID contact claim. The dashboard's rotate button does the same (owners and admins only). After a rotation, every browser subscribes again on its next visit — no prompt, since permission was already granted — and the previous public key is kept for audit as previousPublicKey.
- Notification Bell & Inbox (Web) — the in-app history for your site, from the same universal inbox.
- App Keys & Token Rotation — the publishable web key and app-token rotation in full.