Web push in 10 minutes.
Browser notifications with no Apple, Google or Mozilla account: Notify generates the keys. Five steps, then a real push on your screen.
You need an HTTPS site (or http://localhost while developing), a workspace API key with the Send notifications preset plus notify.credential.manage, notify.settings.manage and notify.type.manage for step 1, and npm install @productcraft/notify @productcraft/notify-js.
1
Keys, origin and a type (once)
Run this once with your API key (the console does the same under Notify › Channels › Web push and Notify › Settings). Generating keys again later is a rotation: every browser has to subscribe again, so the API refuses it without confirm: "rotate".
import { Notify } from "@productcraft/notify";
const notify = new Notify();
// 1. Keys: Notify generates a VAPID key pair for the workspace. You need no
// Google, Apple or Mozilla account. Run it once: a second call with
// confirm "rotate" would orphan every existing subscription.
const webpush = await notify.channels.webpush.retrieve();
if (!webpush.configured) await notify.channels.webpush.generateVapidKeys();
// 2. Allow your site to call the client API from the browser.
// (While the list is empty only http://localhost works, for development.)
await notify.settings.update({ client_origins: ["https://app.example.com"] });
// 3. A type that pushes to browsers.
await notify.categories.create({ key: "reminders", name: "Reminders" });
await notify.notificationTypes.replaceByKey("practice.reminder", {
name: "Practice reminder",
category_key: "reminders",
routing: { always: ["inbox", "webpush"], fallback: [] },
templates: [
{ channel: "inbox", locale: "en", body: { title: "Time to practise", body: "Your daily set is ready." } },
{ channel: "webpush", locale: "en", body: { title: "Time to practise", body: "Your daily set is ready.", url: "https://app.example.com/practice" } },
],
});2
A token endpoint
The browser registers its subscription with a recipient token, so your server mints one for the signed-in user. Any server works; this is Express.
import express from "express";
import { Notify } from "@productcraft/notify";
import { requireUser } from "@/lib/auth"; // your own session middleware
const notify = new Notify();
const app = express();
// The browser calls this whenever it needs a token: at start and when one expires.
app.get("/api/notify-token", requireUser, async (req, res) => {
const { token } = await notify.recipientTokens.mint({
recipient: req.user.id, // the signed-in user, never a value from the request
ttl_seconds: 3600,
});
res.set("cache-control", "no-store").type("text/plain").send(token);
});3
Serve the service worker
It shows each push, opens its url on click (same origin or https only) and re-registers when the browser renews the subscription. It must be served from your site’s root so its scope covers every page. To merge it into a worker you already have, append NOTIFY_SW_SOURCE from @productcraft/notify-js/sw.
# Serve the ready-made service worker from your site root
cp node_modules/@productcraft/notify-js/dist/notify-sw.js public/notify-sw.js4
A button
import { createNotifyClient, NotifyClientError, registerWebPush } from "@productcraft/notify-js";
const notify = createNotifyClient({ getToken: () => fetch("/api/notify-token").then((r) => r.text()) });
// Browsers only show the permission prompt during a user gesture: never on page load.
document.querySelector("#enable-push")?.addEventListener("click", async () => {
try {
const { device } = await registerWebPush(notify, { serviceWorkerPath: "/notify-sw.js" });
console.log("push on for this browser", device.id);
} catch (e) {
if (!(e instanceof NotifyClientError)) throw e;
// WEBPUSH_UNSUPPORTED (e.g. Safari on iOS outside an installed web app),
// PERMISSION_DENIED, USER_GESTURE_REQUIRED
console.warn(e.code);
}
});
// Once permission is granted, call it on every page load (no prompt then):
// it re-registers the same device and picks up a rotated key.
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
void registerWebPush(notify, { serviceWorkerPath: "/notify-sw.js" });
}5
Send yourself one
Click the button in your browser, allow notifications, then send from your server (with the client from getting started). The push appears within a few seconds, even with the tab closed, as long as the browser runs.
// lib/notify.ts
import { Notify } from "@productcraft/notify";
// Reads PRODUCTCRAFT_API_KEY and PRODUCTCRAFT_WORKSPACE_ID from the
// environment. Server only: never ship this key to a browser or an app.
export const notify = new Notify();const sent = await notify.notifications.send(
{ type: "practice.reminder", recipients: ["user_123"], channels: ["webpush"] },
{ idempotencyKey: `practice-reminder:user_123:${new Date().toISOString().slice(0, 10)}` },
);
if ("id" in sent) {
for await (const d of notify.deliveries.list({ notification_id: sent.id })) {
console.log(d.channel, d.state, d.reason); // webpush sent (accepted by the browser's push service)
}
}sent means the browser vendor’s push service accepted it. Nothing on screen? Check the browser’s and the operating system’s notification settings for the site, and Focus or Do Not Disturb. A subscription the push service has dropped comes back as pruned and is removed. More on Why didn’t it arrive?
6
Moving from another provider
Browser subscriptions are bound to the VAPID public key they were made with. Import your current key pair instead of generating one and existing subscriptions keep working once you register them as devices (migrating).
// Your current provider's VAPID key pair (base64url, P-256). Existing
// browser subscriptions were made with this public key, so they keep working.
await notify.channels.webpush.importVapidKeys({
public_key: process.env.OLD_VAPID_PUBLIC_KEY!,
private_key: process.env.OLD_VAPID_PRIVATE_KEY!,
});