Skip to main content

Web SDK

@opennotification/web — browsers and PWAs. Nothing runs at import time, so importing during SSR is safe, and only types come from @opennotification/core, so no validation library lands in your bundle.

Before this works you need HTTPS, a manifest, the VAPID pair and your origin in CORS_ORIGINS: Web platform setup.

1 Install

bun add @opennotification/web

or straight from a CDN:

<script type="module">
import { init } from "https://cdn.jsdelivr.net/npm/@opennotification/web/+esm";
</script>

2 The service worker

Copy the prebuilt bundle to your web root:

bun --filter @opennotification/web build:sw
cp packages/sdk-web/dist/sw.js public/sw.js

The SDK registers it as /sw.js?api=<endpoint> — the worker reads its API base URL from that query string, so one build works for every deployment.

Already have a service worker? Import the handlers into it instead:

// sw.ts
import { registerPushHandlers } from "@opennotification/web/sw";

registerPushHandlers({
apiUrl: "https://push.example.com",
defaults: { icon: "/icons/192.png", badge: "/icons/badge.png" },
});

The handlers cover:

EventDoes
pushShows the notification (title, body, icon, image, badge, tag, actions, silent) and reports delivered.
notificationclickReports opened (body) or clicked (action), focuses an existing tab on the target URL before opening a new one.
notificationcloseReports dismissed.
pushsubscriptionchangeRe-subscribes and calls /v1/subscriptions/rotate so a browser that silently re-issued its subscription does not cost you the subscriber.

3 Initialise and check capability

import { init, mountInstallPrompt } from "@opennotification/web";

const on = init({
endpoint: "https://push.example.com",
appKey: "pk_live_…", // public key
vapidKey: "BNcRd…", // the app's VAPID public key
serviceWorkerPath: "/sw.js", // default
});

const cap = on.capability();
// { supported: true, reason: null, platform: "desktop" }
// { supported: false, reason: "IOS_NEEDS_INSTALL", platform: "ios" }
reasonMeaningWhat to do
nullPush works.Show your enable button.
IOS_NEEDS_INSTALLiOS Safari tab. Push exists only for an installed PWA.Show the install guide (below).
IOS_UNSUPPORTED_BROWSERChrome/Firefox/Edge on iOS — WebKit shells that cannot install.Tell them to open the site in Safari.
INSECURE_CONTEXTServed over HTTP.Fix your hosting.
UNSUPPORTED_BROWSERNo service worker / Push API.Hide the button.

The iOS install guide

const guidance = on.guidance("tr"); // "en" | "tr"
if (guidance?.actionable) mountInstallPrompt({ guidance });

guidance() returns the Share → Add to Home Screen steps as text; mountInstallPrompt() renders a small sheet if you do not want to build your own. actionable is false for reasons the user cannot fix (an unsupported browser).

4 Subscribe — from a click

document.querySelector("#enable")!.addEventListener("click", async () => {
const result = await on.subscribe({
externalId: "user_123",
tags: { plan: "premium" },
language: navigator.language,
});
if (!result.ok) {
switch (result.error.code) {
case "permission_denied": // blocked for this origin — show how to unblock
case "permission_dismissed": // closed the prompt, or called outside a gesture
case "unsupported":
case "service_worker_failed":
case "push_subscribe_failed":
case "api_error":
case "network_error":
}
}
});

subscribe("user_123") is shorthand for { externalId }. The SDK registers the worker, waits for it to be ready, asks for permission, subscribes with the VAPID key and POSTs the subscription with browser, standalone (installed as PWA), timezone and language.

Only inside a user gesture

Notification.requestPermission() outside a click/tap handler is silently rejected on Safari and increasingly throttled in Chrome. The SDK returns permission_dismissed when that happens.

5 Identity, tags, unsubscribe

await on.login("user_456");
await on.setTags({ plan: "premium", streak: 42 });
await on.removeTags(["streak"]);
await on.setLanguage("tr");
await on.logout();
await on.unsubscribe(); // unsubscribes in the browser and deletes the row

on.permission(); // "granted" | "denied" | "default" | "unsupported"
await on.isSubscribed();
on.subscriptionId(); // from localStorage

const stop = on.trackSessions(); // count a session now and on every return to the tab
await on.trackSession(); // or ping once yourself

Semantics: Identity. Sessions are deduplicated on the server (30-minute idle rule). Silent (background) campaigns never reach browsers — the fan-out skips web subscriptions, because a push that shows nothing gets the subscription revoked.

Payload the worker receives

For reference, what the server encrypts into each push:

{
"title": "Weekend flash sale",
"body": "30% off everything until Sunday night.",
"icon": "https://…/icon.png",
"image": "https://…/sale.jpg",
"badgeCount": 3,
"tag": "<collapseId>",
"actions": [{ "action": "shop", "title": "Shop now" }],
"data": { "msgId": "<signed>", "campaignId": "…", "url": "https://…/sale", "a:shop": "https://…/sale?cta=1", "screen": "sale" }
}

Button targets travel as data["a:<id>"]; the click handler picks the right one. silent: true is added when the campaign's sound is none; tag comes from collapseId so a newer push replaces an older one.

Notes

  • Errors come back as Result, never thrown.
  • The subscription id is kept in localStorage; clearing site data means a re-subscribe, which upserts the same endpoint.
  • Nothing in the SDK reads or stores the VAPID private key — only the public one ever reaches the browser.