# 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)](/docs/universal-push/web-inbox) 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:

```text
GET https://app.nativenotify.com/api/universal/web-push/keys/<APP_ID>/<APP_TOKEN>
```

```json
{
  "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.

```js title="public/sw.js"
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.

#### Plain JS

```js title="web-push.js"
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));
}
```

```html
<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>
```

#### React

```jsx
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>
  );
}
```

#### Next.js

```tsx title="app/components/web-push-button.tsx"
"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 (or a settings page) and `sw.js` in `/public`. In `lib/web-push.js`, read the ids from your environment, e.g. `appId: Number(process.env.NEXT_PUBLIC_NN_APP_ID)` and `appToken: process.env.NEXT_PUBLIC_NN_APP_TOKEN`.

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](#rotating-the-vapid-keys), 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](/docs/universal-push/sending#audience-shapes).)

```text
POST https://app.nativenotify.com/api/universal/notifications/send
```

```json
{
  "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:

```json
{
  "appId": 123,
  "appToken": "YOUR_APP_TOKEN",
  "tokenType": "web",
  "token": "<the subscription endpoint>",
  "title": "Test",
  "message": "Hello browser"
}
```

Full results are on [Verify Delivery](/docs/universal-push/verification).

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:

```text
POST https://app.nativenotify.com/api/universal/web-push/keys/<APP_ID>/<APP_TOKEN>/rotate
```

```json
{ "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)](/docs/universal-push/web-inbox) 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**

- [Notification Bell & Inbox (Web)](/docs/universal-push/web-inbox) — the drop-in widget for your site.
- [Send Notifications](/docs/universal-push/sending) — audiences, groups, scheduling.
- [Device Registration](/docs/universal-push/registration) — the full registration contract (web included).
- [Verification](/docs/universal-push/verification) — test sends and health.
- [Framework Recipes](/docs/universal-push/recipes) — the native + cross-platform recipes.
