Skip to content

Push

Live reaches someone who is looking at your app. Email reaches someone whose reply can wait. Push is 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} — how many of that user's devices took it
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.

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 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 automatically the first time a send to it comes back gone.
  • A user can be reached on up to 10 devices; one send reaches at most 200 users.
  • 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.