# 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](/docs/push/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:

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

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

#### Plain JS

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

```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 `webKey: process.env.NEXT_PUBLIC_NN_WEB_KEY`. (That env value is the publishable web key — safe to inline, unlike the 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.

## 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:

```json
{
  "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`](/docs/push/verification) 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:

```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 subscribes again on its next visit — no prompt, since permission was already granted — and the previous public key is kept for audit as `previousPublicKey`.

**Next**

- [Notification Bell & Inbox (Web)](/docs/push/web-inbox) — the in-app history for your site, from the same universal inbox.
- [App Keys & Token Rotation](/docs/push/app-keys) — the publishable web key and app-token rotation in full.
