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.
The endpoints
Section titled “The endpoints”| 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 |
Turning it on is the user’s choice
Section titled “Turning it on is the user’s choice”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.
Sending
Section titled “Sending”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.
Who can send
Section titled “Who can send”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.
What not to send
Section titled “What not to send”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.
Limits and caveats
Section titled “Limits and caveats”- 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.
totakes 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.