Web Push Recipe
Send browser push notifications from any web app — plain JS, React or Next.js. Read your app's VAPID public key, add one service worker, subscribe the browser, and register the PushSubscription with the universal API.
Web push reaches browsers — Chrome, Edge, Firefox, Safari — through the standard Web Push protocol. It is the fourth universal token type next to APNs (iOS) and FCM (Android): a browser's PushSubscription registers exactly like a device token, and every universal send reaches it through the same pipeline.
What is different about the web:
| 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) |
Agent Notify can add it for you:
Ask Agent Notify to "add web push to my site" — it detects the framework and opens a pull request with the service worker + registration code, exactly like the mobile setup. You can then add the optional Notification Bell & Inbox (Web) on top with the same flow.
Step 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 (the browser API name for the same value). It is public by design — the private half never leaves the server and is stored encrypted. The Step 3 code reads it from this endpoint at runtime, so a key rotation never leaves a stale key in your site. maxPayloadBytes is informational: the largest notification payload (in bytes, before encryption) every push service is guaranteed to accept.
Step 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=…&appToken=…&deviceId=…, which lets the worker re-register the browser on its own when the push service replaces a subscription, and report notification taps for open analytics.
const API = "https://app.nativenotify.com";
// appId / appToken / deviceId come from the registration URL (see Step 3).
const CONFIG = new URL(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(
Promise.all([
reportOpen(data),
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("appToken")}`);
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")),
appToken: CONFIG.get("appToken"),
platform: "web",
deviceId: CONFIG.get("deviceId"),
webPush: subscription.toJSON(),
}),
});
if (!res.ok) throw new Error(`Native Notify registration failed (${res.status})`);
}
// Open analytics: a tap on a notification that carries nn_notification_id.
async function reportOpen(data) {
if (!data.nn_notification_id) return;
try {
await fetch(`${API}/api/notification/opened`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: Number(CONFIG.get("appId")),
appToken: CONFIG.get("appToken"),
notification_id: String(data.nn_notification_id),
deviceId: CONFIG.get("deviceId"),
}),
});
} catch (err) {
// Analytics are best-effort; never block the click.
}
}
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));
}
The click handler focuses a tab that is already on the deep-linked page, and otherwise opens a new one.
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.
Step 3 — Subscribe the browser and register it
The subscription JSON ({ endpoint, keys: { p256dh, auth } }) is registered through webPush on the universal registration endpoint, with platform: "web". 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.
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 — a regular Safari tab cannot receive push. 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). Desktop and Android browsers (Chrome, Edge, Firefox) need no such step.
const NN = {
appId: 123, // your app id — read both from your config
appToken: "YOUR_APP_TOKEN", // your app token
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), appToken: NN.appToken, 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.appToken}`);
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,
appToken: NN.appToken,
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.
Step 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. (A browser registered without a subscriberId, as above, is a mass device: all and devices reach it. Register it with the logged-in user's subscriberId to reach it through subscribers instead — see Send Notifications.)
POST https://app.nativenotify.com/api/universal/notifications/send
{
"appId": 123,
"appToken": "YOUR_APP_TOKEN",
"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" }
}
How the universal fields map to a browser notification:
| 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 for open attribution) |
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 — push services accept at most 4,096 encrypted bytes) |
collapseId | The notification tag — a newer push replaces an older one with the same tag (and the push-service coalescing topic when it is base64url-safe and ≤ 32 characters) |
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.
Verify, and what happens when a subscription dies
Test one browser without sending to everyone:
{
"appId": 123,
"appToken": "YOUR_APP_TOKEN",
"tokenType": "web",
"token": "<the subscription endpoint>",
"title": "Test",
"message": "Hello browser"
}
Full results are on Verify Delivery.
A subscription that the user has revoked answers 404 or 410 from the push service. Native Notify treats those exactly like BadDeviceToken / Unregistered: the token collects a strike, and after two separate occasions it is retired (with DELETE_DEAD_TOKENS=1) and excluded from future audiences. Other push-service answers (rate limits, transient errors) are reported but never strike the token — and a 401/403 means the VAPID keys are wrong for that subscription, which is reported as a credential problem, not a dead browser. Per-send results are in each send response (byType.web, health.web), and 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: a registered 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). Register the browser's real PushSubscription endpoint.
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 must subscribe again: the recipe's syncWebPush() notices the key changed on the browser's next visit, unsubscribes the old subscription and registers a new one — no prompt, since permission was already granted. The previous public key is kept for audit as previousPublicKey.
Pair it with the bell:
Web push delivers the notification; the Notification Bell & Inbox (Web) widget gives your site the in-app history — bell, badge, list, read/unread and deep links — from the same universal inbox every send already writes into.
- Notification Bell & Inbox (Web) — the drop-in widget for your site.
- Send Notifications — audiences, groups, scheduling.
- Device Registration — the full registration contract (web included).
- Verification — test sends and health.
- Framework Recipes — the native + cross-platform recipes.