I ship a WordPress plugin that real estate agents use to send postcards. When somebody scans the QR code on a card and lands on the agent's page, the agent should know within seconds — on their phone, with a Call button — because the first agent to ring usually gets the listing.
That is a push notification. And I did not want Firebase, OneSignal, or a Node sidecar. The whole product is one PHP plugin on a shared host, and it should stay that way.
It turns out the Web Push protocol is small enough to implement in a few hundred lines of PHP with OpenSSL. The crypto took an afternoon. Getting a notification to actually appear on a phone took a week, and none of that week was crypto. This post is the week.
Web push has three parties: your server, the browser's push service (Google's FCM for Chrome, Mozilla's autopush for Firefox, Apple's for Safari), and the service worker running in the user's browser. Your page asks Notification.requestPermission(), then registration.pushManager.subscribe() with your VAPID public key. The browser hands back a subscription: an endpoint URL on the push service, plus two keys. You store the endpoint. To notify, your server does an HTTP POST to that endpoint, signed with a VAPID JWT so the push service knows it's you. The push service wakes the service worker, which shows a notification.
VAPID is "Voluntary Application Server Identification." It's an ECDSA P-256 keypair. The public key goes to the browser; the private key signs a JWT on every send.
Wait — that public key is 64 bytes and the browser wants 65. The uncompressed point format is 0x04 || X || Y. Missing that leading byte was my first silent failure: subscribe() throws InvalidAccessError, which your promise chain probably swallows.
Second silent failure: aud must be the origin of the push service, not the subscription endpoint. https://fcm.googleapis.com, not https://fcm.googleapis.com/fcm/send/abc123. Get it wrong and FCM returns 403 with a body you will never read because you only logged the status code.
Third: OpenSSL gives you a DER-encoded signature. JWS wants raw R || S, each exactly 32 bytes, zero-padded. DER can be 70, 71 or 72 bytes depending on whether R or S has a high bit set. If you just base64 the DER, roughly one in four signatures will verify and the rest will 401, which is a wonderful thing to debug.
Encrypting a payload for web push means ECDH against the subscription's p256dh key, HKDF, AES-128-GCM, and the aes128gcm content encoding with its salt and record framing. It's all doable in PHP. It's also all unnecessary for my case.
An empty push still wakes the service worker. The worker can then fetch /wp-json/myplugin/v1/push/latest — over its normal session cookie — and ask the server what happened. The server knows exactly what's new for that subscription and answers with a title, a body and a URL.
This has a property I've come to like: the push service never sees anything but "wake up." No lead's name, no address, nothing to encrypt because nothing is sent. And the payload-size limit (4 KB) stops mattering.
The one gotcha: service workers don't have your REST nonce. WordPress's cookie auth for REST requires X-WP-Nonce, and the worker has no page to read it from. So /push/latest authenticates by the subscription endpoint the worker sends in the query string, matched against what's stored. The endpoint is a 200-character unguessable URL; treating it as a bearer token for this one read-only route is fine.
Everything above verified in tests before the first real send. Then the real sends did nothing, and the tests kept passing. Here is the list, in the order I found them. The subscription was never stored
The page called subscribe(), got a subscription, and POSTed it to /wp-json/myplugin/v1/push/subscribe — which returned 401 because I'd forgotten X-WP-Nonce on the fetch. The promise chain had a .catch that removed the "turn on" bar so it wouldn't nag. From the user's side: tap Turn on, bar disappears, done. From the server's side: nothing arrived, ever.
Fix: send the nonce; and on failure, say so on screen rather than tidying up. The audience was almost right
I built aud from the full endpoint on the first pass. FCM's 403 body says the aud claim is invalid. I was logging wpremoteretrieveresponsecode() and not the body.
Fix: log the body on any non-2xx. Every push service returns a readable reason. The wrong people
Notifications go to "every seated agent on this account." My audience query fell through to an empty set when the account had a single user with no team under them — which was every solo account. The send loop ran zero times and reported success.
