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 pushWeb push
You upload APNs / FCM credentialsNothing to upload — Native Notify generates your app's VAPID keypair server-side
The client gets a token from the OSThe client's service worker subscribes with your VAPID public key and gets a subscription (endpoint + keys)
Sending needs your .p8 / service accountSending 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 fieldWeb push meaning
title / messageThe notification's title and body
pushDataevent.data.json().data in your service worker (plus nn_notification_id / nn_source for open attribution)
subtitleevent.data.json().subtitle — not shown by default; render it yourself if you want it
bigPictureURLimage on the notification (dropped automatically when the payload would exceed maxPayloadBytes — push services accept at most 4,096 encrypted bytes)
collapseIdThe 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 / expirationHow 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.

Next