Skip to main content

Channels

Push

Web Push, FCM, APNs and Huawei with push() — the only channel that costs nothing per message, and the only one where the address has to be registered first.


import { push } from "postboi"

await push({ to: subscription, title: "Order shipped", message: "On its way" })
import { push } from "postboi"

await push({ to: subscription, title: "Order shipped", message: "On its way" })

Push is the odd one out in two ways, and both shape how you use it.

It costs nothing. FCM, APNs, Push Kit and Web Push are free from Google, Apple, Huawei and the browser vendors — there is no carrier and no termination fee anywhere in the chain. That’s why push sits first in send()’s cost ordering: routing a message to push instead of SMS doesn’t save a percentage, it saves the entire cost.

The address has to be registered first. An email address or a phone number is something you can be told. A push target only exists once the device has subscribed and handed it to you — so you have to store it, and it will expire.

Setup

# .env
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
# .env
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com

No POSTBOI_PUSH_PROVIDER — a full VAPID trio can only mean Web Push, so postboi infers it. Set it when you carry another push provider’s credentials in the same deploy, where the credentials stop answering the question on their own; the same inference covers FCM, APNs and HMS from theirs.

The VAPID key pair identifies you to the push service. No dashboard hands one out — bunx postboi init --push generates it for you. The public half is also what the browser subscribes with, so the two must match — mismatched keys are rejected on every send with a 401 that explains nothing.

When the secrets don’t belong in a .env — a Worker’s wrangler secret put, a CI secret store, a password manager — bunx postboi vapid prints a pair to stdout instead of writing one, and generate_vapid_keys() from postboi/webpush mints the same pair in code. Mint once: a second pair orphans every subscription collected under the first, silently.

bunx postboi vapid
bunx postboi vapid

On Cloudflare Workers the three vars are read straight off your bindings, so a push Worker is three wrangler secret puts and no config file at all — see Web Push on Workers.

VAPID_SUBJECT is required by RFC 8292 so a push service operator can reach you about misbehaving traffic. mailto: or an https URL — your address on its own is fine too and becomes a mailto:. Anything else throws when the provider is built, rather than reaching a push service that answers 401 without saying why.

Provider Import Reaches
Web Push postboi/webpush Every modern browser, desktop and mobile
FCM postboi/fcm Android apps — the only route to them, and it reaches iOS too
APNs postboi/apns Apple apps, direct — no Firebase in the middle
HMS postboi/hms Huawei phones, which have no Play Services

Those are the server imports — the senders. The browser half is a different import on purpose: postboi/push, next, carries no provider and no private key, so neither can end up in a client bundle by accident.

Subscribing, in the browser

import { subscribe } from "postboi/push"

async function enable() {
	const subscription = await subscribe()
	await fetch("/api/push/register", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify(subscription),
	})
}
import { subscribe } from "postboi/push"

async function enable() {
	const subscription = await subscribe()
	await fetch("/api/push/register", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify(subscription),
	})
}

No key in sight because bunx postboi sync bakes VAPID_PUBLIC_KEY from your env into the package — the same trick that makes <Captcha /> prop-free. That kills the whole per-framework ceremony of smuggling the public key to the browser (server-component props, loaders, runtime config, data attributes). Pass { key } to override, and rerun sync after rotating the pair.

One import for every framework: postboi/push is plain DOM, so Svelte, React, Vue and no framework at all get the same line.

Call it from a click. Browsers auto-deny a permission prompt that isn’t tied to a user gesture, and once denied you cannot ask again — the user has to change it in site settings. That’s the single most common way to permanently lose a subscriber.

The helper requests permission if needed, registers your service worker (/sw.js by default), waits for it to be active rather than merely registered, and reuses an existing subscription — so calling it on every page load is safe.

Three questions a UI wants answered before it can render the button, none of which prompt:

Call Answers
subscribe.supported() Is there any Web Push in this browser?
subscribe.permission() "granted", "denied", "default" or "unsupported"
subscribe.current() The subscription this browser already has, or null

The last one is the difference between a toggle that’s right and one that lies: permission stays "granted" after someone unsubscribes, so a browser that is granted-but-not-subscribed looks identical to a subscribed one until you ask.

const on = Boolean(await subscribe.current())
const on = Boolean(await subscribe.current())

unsubscribe() removes the subscription and hands back the copy you stored, so you know which row to delete.

The toggle, without the choreography

Every settings page that offers a push switch re-implements the same state machine: current() on mount, busy and error state, subscribe-then-register, rollback when the server never learned the address. subscription is that machine written once, with one wrapper per framework so the state is reactive in each one’s own idiom.

Svelte’s is reactive properties — on, busy, supported and reason read plainly, no $ prefix and nothing to unwrap, and they only start watching when something actually renders them:

<script lang="ts">
	import { subscription } from "postboi/svelte"

	const push = subscription({ register: "/push/subscriptions" })
</script>

<button onclick={push.toggle} disabled={push.busy}>
	{push.on ? "Unsubscribe" : "Subscribe"}
</button>
<script lang="ts">
	import { subscription } from "postboi/svelte"

	const push = subscription({ register: "/push/subscriptions" })
</script>

<button onclick={push.toggle} disabled={push.busy}>
	{push.on ? "Unsubscribe" : "Subscribe"}
</button>

React gets it as usePush from postboi/react — camelCase because the hooks linter and the React Compiler recognize hooks by the /^use[A-Z]/ name pattern, and a hook they can’t see is a hook they can’t protect. Vue has no such enforcement, so postboi/vue keeps house style with use_push:

const push = usePush({ register: "/push/subscriptions" })

<button onClick={push.toggle} disabled={push.busy}>
	{push.on ? "Unsubscribe" : "Subscribe"}
</button>
const push = usePush({ register: "/push/subscriptions" })

<button onClick={push.toggle} disabled={push.busy}>
	{push.on ? "Unsubscribe" : "Subscribe"}
</button>

toggle() is the one a switch wants — subscribe if this browser isn’t, unsubscribe if it is — and handing it to a click handler by name is safe. enable() and disable() are there when the UI is two buttons rather than one. Call whichever from a click, for the same reason subscribe() has to be.

register is a URL the subscription is POSTed to (or a function, for anything beyond that); unregister, when given, unfiles it on disable. A register call that fails rolls the browser subscription back, so there’s never an address the server doesn’t know.

Framework Import Shape
Svelte subscription from postboi/svelte Reactive — push.on
React usePush from postboi/react Hook — push.on
Vue use_push from postboi/vue Composable — on.value
Anything else subscription from postboi/push Store contract — push.subscribe(fn), plain push.on reads

The last row is the machine the other three wrap, framework-neutral and plain DOM. Reach for it from vanilla JS, or from a framework we don’t ship a wrapper for.

Runnable example: examples/sveltekit-provider-postboi does Web Push end to end — the toggle above, the endpoint it registers with, the send, and the service worker that shows the notification. Nuxt, Next.js, Astro and Remix carry the same page in their own idiom.

When a subscribe fails

subscribe.reason(error) says which wall was hit, the way push.expired(error) does on the server — null for anything that didn’t come from the subscribe call, so it’s safe on a bare catch:

import { subscribe } from "postboi/push"

try {
	await subscribe({ key })
} catch (error) {
	switch (subscribe.reason(error)) {
		case "permission_denied": show_settings_hint(); break
		case "unsupported": hide_the_button(); break
		default: throw error
	}
}
import { subscribe } from "postboi/push"

try {
	await subscribe({ key })
} catch (error) {
	switch (subscribe.reason(error)) {
		case "permission_denied": show_settings_hint(); break
		case "unsupported": hide_the_button(); break
		default: throw error
	}
}
Reason What happened
permission_denied The user said no. The browser will not ask again.
permission_dismissed The prompt was closed without an answer. You can ask again later.
unsupported No Web Push in this browser. subscribe.supported() tells you first.
missing_key No { key } and nothing baked — run bunx postboi sync with VAPID_PUBLIC_KEY set.
no_service_worker Your worker file didn’t register — wrong path, or a 404.
failed The push service refused the subscription.

The reasons are a typed union, so a mistyped case is a compile error rather than a branch that never runs. PushSubscribeError is exported too if you prefer instanceof.

Your service worker

Push notifications are delivered to a service worker, and the handlers inside it are the same in every app. bunx postboi init --push offers to write them — it finds the worker your framework already has, or creates one where that framework expects it:

✓ created public/sw.js (handlers written out — this file is served as-is and can't import)

  On the page:
    subscription({ register: "/push/subscriptions" })
✓ created public/sw.js (handlers written out — this file is served as-is and can't import)

  On the page:
    subscription({ register: "/push/subscriptions" })

It never appends to a worker that already handles push itself — two handlers means two notifications for one send — and it tells you instead.

The two shapes

Which shape you get depends on whether your worker file is built or served verbatim, and that’s a per-framework fact rather than a preference:

Framework Worker file Shape
SvelteKit src/service-worker.ts Built — imports postboi/push/sw
Next.js, Nuxt, Astro, Remix public/sw.js Served as-is — handlers written out
Vite / webpack worker entry your entry Built — imports postboi/push/sw

A built worker is four lines:

// src/service-worker.ts
import { receive } from "postboi/push/sw"

receive({ register: "/push/subscriptions" })
// src/service-worker.ts
import { receive } from "postboi/push/sw"

receive({ register: "/push/subscriptions" })

A served-as-is file can’t import — an import statement there is a syntax error at worker startup, with nothing pointing at the cause — so the CLI writes the same handlers out instead, with your VAPID public key baked in. postboi’s test suite drives both through one fake worker and compares what they do, so the generated copy can’t drift from receive().

Either way you get push (show the notification), notificationclick (open the thing, or focus the tab already showing it) and pushsubscriptionchange — the one that only exists inside a worker, and the one everybody skips. Nothing else: no fetch handler, no caching, no claiming clients. A worker that intercepts requests is a different feature, and yours may already be one.

subscribe() looks for /sw.js. A worker served anywhere else needs service_worker passed, which is the most common way a fully wired setup still answers no_service_worker. The CLI prints the right line for where it put the file.

Adjusting the notification

notification returns the fields to override, merged over the defaults. The two things the payload can’t carry are an app-name fallback for a send with no title, and tag/renotify/actions:

receive({
	register: "/push/subscriptions",
	notification: (payload) => ({ title: payload.title ?? "Acme", tag: "orders" }),
})
receive({
	register: "/push/subscriptions",
	notification: (payload) => ({ title: payload.title ?? "Acme", tag: "orders" }),
})

In a generated worker the same thing is a literal edit — the file is yours from the moment it’s written, and the "" fallback title is commented where it sits.

Rotations, and why they matter

Subscriptions rotate. Browsers replace them on their own schedule, and when that happens the address you stored is dead while the browser holds a replacement nobody has told you about. pushsubscriptionchange fires only inside the worker, which is why a page-side helper can’t cover it and why this is the piece worth wiring.

Without a handler the gap does close on its own — the next send answers 410, and push.expired() deletes the row — but only after one notification has silently gone nowhere. receive re-subscribes and POSTs the replacement to register, carrying old_endpoint when the browser says which subscription it replaced:

{
	"endpoint": "https://push.example/new",
	"keys": { "p256dh": "…", "auth": "…" },
	"old_endpoint": "https://push.example/old"
}
{
	"endpoint": "https://push.example/new",
	"keys": { "p256dh": "…", "auth": "…" },
	"old_endpoint": "https://push.example/old"
}

Delete the row for old_endpoint, then store the rest — that’s a swap rather than a leak. The field is absent on browsers that don’t hand the old subscription over, and a register endpoint that ignores it still works.

Writing it yourself

Nothing stops you. The minimum is:

// public/sw.js
self.addEventListener("push", (event) => {
	const { title, body, icon, url } = event.data.json()
	event.waitUntil(
		self.registration.showNotification(title ?? "", { body, icon, data: { url } })
	)
})

self.addEventListener("notificationclick", (event) => {
	event.notification.close()
	if (event.notification.data?.url) event.waitUntil(clients.openWindow(event.notification.data.url))
})
// public/sw.js
self.addEventListener("push", (event) => {
	const { title, body, icon, url } = event.data.json()
	event.waitUntil(
		self.registration.showNotification(title ?? "", { body, icon, data: { url } })
	)
})

self.addEventListener("notificationclick", (event) => {
	event.notification.close()
	if (event.notification.data?.url) event.waitUntil(clients.openWindow(event.notification.data.url))
})

You must show a notification for every push. userVisibleOnly is mandatory in Chrome, and a browser that sees you receive pushes without showing anything will revoke the permission. That’s also why receive shows an empty notification rather than throwing on a payload it can’t parse — and why the version above, which throws on any payload that isn’t JSON, is a subscriber you lose eventually.

Subscriptions expire — plan for it

This is routine, not an error case. Users clear site data, reinstall browsers, and don’t open your app for months. The push service answers 410 Gone and the right response is to delete your stored copy — not to retry, and not to alert:

import { push } from "postboi"

try {
	await push({ to: subscription, message: "…" })
} catch (error) {
	if (push.expired(error)) await forget_subscription(subscription.endpoint)
	else throw error
}
import { push } from "postboi"

try {
	await push({ to: subscription, message: "…" })
} catch (error) {
	if (push.expired(error)) await forget_subscription(subscription.endpoint)
	else throw error
}

The check hangs off push itself, so the send and its routine failure are one import. Sending to many at once, the same check applies per result:

const results = await push(subscriptions.map((to) => ({ to, message: "…" })))

for (const [i, result] of results.entries()) {
	if (!result.ok && push.expired(result.error)) {
		await forget_subscription(subscriptions[i].endpoint)
	}
}
const results = await push(subscriptions.map((to) => ({ to, message: "…" })))

for (const [i, result] of results.entries()) {
	if (!result.ok && push.expired(result.error)) {
		await forget_subscription(subscriptions[i].endpoint)
	}
}

(Holding a provider instance directly? The same check is PushProvider.is_expired().)

Payload size

One encrypted record holds 3993 bytes of plaintext, and that’s the whole payload — title, body, icon URL and data together. Postboi checks before encrypting and tells you the real number, rather than letting the push service reject it with a bare 400.

If you’re near the limit, send an id and fetch the detail in the service worker. That’s better practice anyway: the payload is stored on someone else’s server until it’s delivered.

Urgency and TTL

await push({
	to: subscription,
	message: "Your code is 4291",
	urgency: "high", // ask the push service not to delay for battery
	ttl: 60, // give up after a minute — a stale code is worse than none
})
await push({
	to: subscription,
	message: "Your code is 4291",
	urgency: "high", // ask the push service not to delay for battery
	ttl: 60, // give up after a minute — a stale code is worse than none
})

ttl defaults to 28 days. For anything time-sensitive, set it low: a notification that arrives two days late is usually worse than one that never arrives.

Android

FCM (postboi/fcm) is the only way to reach an Android phone. Not the recommended way — the only one. Push on Android goes through Google Play Services, and nothing else has access to that transport.

Which leaves the phones that don’t have Play Services. Huawei has shipped without it since 2020, and on those devices FCM doesn’t fail loudly, it simply never arrives. Push Kit (postboi/hms) is the route to them:

# .env
POSTBOI_PUSH_PROVIDER=hms
HMS_APP_ID=
HMS_APP_SECRET=
# .env
POSTBOI_PUSH_PROVIDER=hms
HMS_APP_ID=
HMS_APP_SECRET=

Both from AppGallery Connect. Same push() call, same push.expired(error) check.

One quirk worth knowing about, though Postboi handles it for you: Push Kit answers HTTP 200 even when the send failed — the real outcome is a result code in the body. Anything that only checked the status code would report a silent non-delivery as a success. Postboi reads the code, so a failure throws like it does everywhere else.

Serving both? Store which one a device registered with, and pick the provider per device — a token from one is meaningless to the other.

iOS

Two routes, and the difference is whether Google sits in the middle.

APNs (postboi/apns) talks to Apple directly, with a .p8 key from your developer account and nothing else in the chain. FCM forwards to APNs on your behalf, which is worth it if you’re already sending to Android and would rather hold one credential than two. If iOS is all you ship, there’s no reason to route it through Firebase.

# .env
POSTBOI_PUSH_PROVIDER=apns
APNS_KEY_ID=ABC1234567
APNS_TEAM_ID=DEF1234567
APNS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT…\n-----END PRIVATE KEY-----"
APNS_TOPIC=com.example.app
# .env
POSTBOI_PUSH_PROVIDER=apns
APNS_KEY_ID=ABC1234567
APNS_TEAM_ID=DEF1234567
APNS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT…\n-----END PRIVATE KEY-----"
APNS_TOPIC=com.example.app

APNS_TOPIC is your app’s bundle ID. The key is the .p8 you download once — Apple won’t show it again — from Certificates, Identifiers & Profiles → Keys.

Or don’t type any of it. bunx postboi init --push looks for the AuthKey_*.p8 you just downloaded — in the current directory and in ~/Downloads — and offers it. Pick one and it fills the key and APNS_KEY_ID, because Apple puts the key ID in the filename. It writes the PEM’s newlines as \n inside double quotes too, since a .env value is a single line.

Then it checks the credentials against APNs before writing anything, by sending to a device token that can’t exist. Apple validates the key, team and topic before it looks at the device, so a rejected token means everything else was accepted — and if something is wrong, you’re told which thing:

✓ read the key, and took APNS_KEY_ID from its filename

Checking the credentials with APNs…
! APNs rejected the topic — is com.example.app really the app's bundle ID?
  Save them anyway? (y/N)
✓ read the key, and took APNS_KEY_ID from its filename

Checking the credentials with APNs…
! APNs rejected the topic — is com.example.app really the app's bundle ID?
  Save them anyway? (y/N)

There is no OAuth to offer here and there won’t be: Apple has no API that creates an APNs key, the App Store Connect API’s own credential is another .p8 you download by hand, and its terms forbid using it to provide services to third parties. Finding the file and checking what you typed is the ceiling.

Set APNS_ENVIRONMENT=sandbox while you’re testing against a development build. A token from a debug build is only valid against the sandbox and a TestFlight or App Store token is only valid against production; cross them and every send fails as BadDeviceToken, which reads like a broken token rather than a wrong setting. It’s the first thing to check.

push.expired(error) covers APNs too. Apple reports a dead token two ways — Unregistered as a 410, and BadDeviceToken as a 400 — and both mean the same thing: delete your stored copy.

One note on how this works, because it’s the reason most libraries hand you Firebase instead. APNs refuses HTTP/1.1, and Node’s built-in fetch only speaks HTTP/1.1 — so Postboi sends over node:http2 on Node and Bun, and over the global fetch on Workers and Deno, where it already negotiates HTTP/2. There’s nothing to configure, and no dependency either way.

Web Push also works on iOS 16.4+, but only for a home-screen web app, and the user has to add it to their home screen first. Worth knowing before you build a flow around it.