Add a service worker, subscribe the browser and register it with your web key to push to Chrome, Edge, Firefox and Safari.
1. Get your keys
Web push needs no credentials from you: Native Notify creates the app's VAPID keypair and keeps the private key encrypted on the server. Browser code uses your publishable web key (nnweb_…) from App Settings → App keys. It can only register one browser device and read that device's own inbox, so it's safe in page source. Sends stay on your server with the app token. See App keys.
Read the VAPID public key. The keypair is created on the first call, so there's nothing to paste:
GET https://app.nativenotify.com/api/universal/web-push/keys/<APP_ID>/<WEB_KEY>
{
"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 what pushManager.subscribe() takes as applicationServerKey. The code below reads it at runtime, so a rotation never leaves a stale key in your site. maxPayloadBytes is the largest payload, before encryption, that every push service accepts.
2. Add the service worker
Put it at the site root (/sw.js, for example public/sw.js) so its scope covers your whole app. Step 3 registers it as /sw.js?appId=…&webKey=…&deviceId=…, so the worker can re-register the browser 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));
}
A scope has one service worker, so if your site already registers one at the root, add these listeners to it and register it with the same query string.
3. Subscribe and register
Ask for permission from a click: Safari and Firefox show the prompt only after a user gesture, and a denied prompt can't be asked again from code. Register the subscription JSON ({ endpoint, keys: { p256dh, auth } }) as webPush with platform: "web". Web push needs HTTPS, with localhost as the one development exception.
iPhone and iPad
On iOS 16.4 and later, web push works only for a site added to the Home Screen (Safari → Share → Add to Home Screen) and opened from there, with a manifest that sets "display": "standalone" and permission asked from a tap inside it.
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>
import { useEffect } from "react";
import { registerWebPush, syncWebPush } from "./web-push"; // the Plain JS module
export function WebPushButton() {
useEffect(() => {
// Already allowed? Keep this browser registered — no prompt is shown.
syncWebPush().catch((err) => console.warn("Web push:", err));
}, []);
return (
<button type="button" onClick={() => registerWebPush().catch((err) => console.warn("Web push:", err))}>
Enable notifications
</button>
);
}
"use client";
import { useEffect } from "react";
// The Plain JS module, saved as lib/web-push.js (Next.js projects allow JS imports).
import { registerWebPush, syncWebPush } from "@/lib/web-push";
export function WebPushButton() {
useEffect(() => {
syncWebPush().catch((err) => console.warn("Web push:", err));
}, []);
return (
<button type="button" onClick={() => registerWebPush().catch((err) => console.warn("Web push:", err))}>
Enable notifications
</button>
);
}
Put the button in your layout and sw.js in /public. In lib/web-push.js, read appId: Number(process.env.NEXT_PUBLIC_NN_APP_ID) and webKey: process.env.NEXT_PUBLIC_NN_WEB_KEY. The web key is safe to inline, unlike the app token.
Registration is idempotent: the endpoint is the token's identity, so a repeat refreshes the row and clears a retired flag. Run syncWebPush() on every visit to keep 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 send. An all audience, a device list, subscriber ids or a group key 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 and nn_source. |
subtitle | event.data.json().subtitle. Not shown by default; render it yourself. |
bigPictureURL | image on the notification. Dropped 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. |
byType.web.accepted is what the browser's push service accepted for delivery. delivered stays null because no transport can prove display.
Dead subscriptions
Test one browser with /api/universal/test-send, using tokenType: "web" and the subscription's endpoint as token.
A revoked subscription answers 404 or 410 from the push service. That verdict is final, so the token is retired on the first such answer, with its strike recorded, and excluded from future audiences. Rate limits and transient errors never strike a token. A 401 or 403 means the VAPID keys don't match the subscription and is reported as a credential problem, not a dead browser. GET /api/universal/health/<APP_ID>/<APP_TOKEN> counts live and retired browser subscriptions under tokens.web and retired.web.
The server connects only to public push services. An endpoint that is, or resolves to, a loopback, private-network or link-local address is refused before any connection as EndpointNotAllowed, never a strike.
Rotate the VAPID keys
Rotation invalidates every existing browser subscription, so rotate only if the keys leaked.
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, for owners and admins only. After a rotation every browser subscribes again on its next visit, with no prompt because permission was already granted. The previous public key is kept for audit as previousPublicKey.