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
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.
VAPID_SUBJECT is required by RFC 8292 so a push service operator can reach you about
misbehaving traffic. mailto: or an https URL.
Subscribing, in the browser
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.
Two questions a UI wants answered before it offers the button, neither of which prompts: subscribe.supported() and subscribe.permission() ("granted", "denied", "default" or "unsupported"). unsubscribe() removes the subscription and hands back the copy you
stored, so you know which row to delete.
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:
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, which postboi doesn’t provide — it’s your file, at your scope. The minimum:
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.
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:
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:
(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
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:
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.
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:
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.