Skip to content

Push notifications

Realtime reaches someone who is looking at your app. Email reaches someone whose reply can wait. Push notifications are the middle: a real notification on the device, delivered when the tab is closed and the app isn’t running.

It needs sign-in — a notification is addressed to a person, not to a visitor.

Method & path Body Returns
POST /api/_push/send {"title":"...","body?":"...","url?":"/somewhere","to?":"<user id or email>"} — "to" defaults to the caller {"sent":2} — devices the push service accepted; NOT proof one arrived
GET /api/_push/status {"subscribed":true} — is this signed-in user reachable on ANY device

Nothing is delivered until the user grants permission, and the browser only asks when a click asks it to. So push begins as a control in the app — a switch in settings, a “Notify me” button.

The app imports a blessed push module for that. It handles the permission prompt, the service worker, the key encoding and the subscribe call:

import { enable, disable, pushState } from "push";
let state = $state(await pushState()); // "unsupported" | "denied" | "off" | "on"
async function toggle() {
state = state === "on" ? await disable() : await enable();
}

enable() must run from a click. Browsers refuse a permission prompt no gesture asked for, and a refusal is permanent for that site — the app can’t ask again, only the user can undo it in browser settings. So ask once, in context, next to a sentence saying what you’ll send.

Don’t write navigator.serviceWorker.register or call Notification.requestPermission by hand, and don’t add a sw.js of your own — the platform serves /sw.js, and a file of yours at that path is refused by the build.

await fetch("/api/_push/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
to: order.owner,
title: "Your order shipped",
url: "/#/orders/" + order.id,
}),
});

url is where the app opens when the notification is tapped — an in-app route, not an outside link. to takes a user id or an email address and defaults to the caller.

A user with no device subscribed isn’t an error: it answers {"sent":0}. That’s the normal case for most of your users, so never block a flow on the result.

sent counts the devices whose push service accepted the request — not the notifications anyone saw. A subscription the browser revoked can still be accepted for a while, so a 1 is a handoff, not a delivery receipt. There is no delivery receipt in Web Push; if something must be confirmed, confirm it in your own app.

Any signed-in user, exactly like email — so a send is only ever as trustworthy as the caller.

If a notification should come from the app rather than from a user — an order shipped, a nightly digest — send it from a /tasks/ route and let Scheduled jobs call that route. Those run with the platform’s own key and can’t be reached from a browser.

The payload is delivered by a third-party push service — Apple’s, Google’s, or Mozilla’s, depending on the browser. It’s encrypted end-to-end (RFC 8291, with a per-message key), so the service can’t read it. It still leaves your app, though, so keep secrets, tokens and full personal detail out of the title and body. Send enough to make someone tap, and put the detail behind the url.

Titles are capped at 120 characters and bodies at 400. A phone shows about two lines anyway.

  • On iPhone it only works once the user has added your app to their Home Screen. That’s Apple’s rule, not ours, and there’s nothing to configure — pushState() reports "unsupported" until then, so show your switch with an honest line rather than a broken button.
  • A device that goes stale — the browser was uninstalled, the subscription expired — is dropped the first time a send to it comes back gone. That can lag: a push service may keep accepting sends to a subscription the browser has already revoked, and the device is only dropped once the service starts refusing it.
  • One send addresses one person. to takes a single user id or email, never a list, and reaches up to 10 of that user’s devices. To notify many people, send once per person.
  • Each project gets its own signing key, minted once in your Cloudflare account. Replacing it would sign every user out of notifications, so it’s never rotated automatically.
  • The service worker the platform serves does exactly two things: show a notification, and open the app when one is tapped. It intercepts no requests and caches nothing.