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.
The shape of it
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(), thenregistration.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.
The crypto, in PHP
Generate the keypair once and store it:
$key = openssl_pkey_new( array(
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
) );
openssl_pkey_export( $key, $pem );
$details = openssl_pkey_get_details( $key );
// The browser wants the raw uncompressed point, base64url, no padding.
$public = base64url( $details['ec']['x'] . $details['ec']['y'] );
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.
$public = base64url( "\x04" . $details['ec']['x'] . $details['ec']['y'] );
The JWT is standard ES256. The claims matter:
$audience = parse_url( $endpoint, PHP_URL_SCHEME ) . '://' . parse_url( $endpoint, PHP_URL_HOST );
$header = base64url( json_encode( array( 'typ' => 'JWT', 'alg' => 'ES256' ) ) );
$claims = base64url( json_encode( array(
'aud' => $audience, // scheme://host of the push service, NOT the full endpoint
'exp' => time() + 12 * 3600, // at most 24h; I use 12
'sub' => 'mailto:you@example.com',) ) );
openssl_sign( "$header.$claims", $der, $pem, OPENSSL_ALGO_SHA256 );
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.
function der_to_raw( $der ) {
// SEQUENCE { INTEGER r, INTEGER s }
$pos = 2;
$out = '';
foreach ( array( 'r', 's' ) as $part ) {
$pos++; // 0x02
$len = ord( $der[ $pos++ ] );
$val = substr( $der, $pos, $len );
$pos += $len;
$val = ltrim( $val, "\x00" ); // strip the sign byte
$out .= str_pad( $val, 32, "\x00", STR_PAD_LEFT );
}
return $out;
}
$jwt = "$header.$claims." . base64url( der_to_raw( $der ) );
Then the request:
wp_remote_post( $endpoint, array(
'headers' => array(
'Authorization' => 'vapid t=' . $jwt . ', k=' . $public,
'TTL' => 86400,
'Content-Length'=> 0,
),
'body' => '',
) );
Note the empty body. I send no payload at all.
Why no payload
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.
self.addEventListener('push', function (event) {
event.waitUntil(
fetch('/wp-json/myplugin/v1/push/latest', { credentials: 'include' })
.then(r => r.json())
.then(n => self.registration.showNotification(n.title, {
body: n.body,
icon: n.icon,
data: { url: n.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.
Where the week went
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.
1. 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.
2. 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 wp_remote_retrieve_response_code() and not the body.
Fix: log the body on any non-2xx. Every push service returns a readable reason.
3. 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.
Fix: the audience for a solo account is the owner. Obvious afterwards.
4. The right people, the wrong app
This one cost the most and is the most useful.
On Android, a web push notification is attributed to whatever registered the subscription. If the user tapped Turn on in a Chrome tab, the notification shows Chrome's icon, "Chrome • yourdomain" as the sender, and Chrome's own "Unsubscribe" button. If they tapped it inside the installed PWA (added to home screen), it shows your icon and your app's name.
Worse: subscribe from both and you have two subscriptions, and the user gets every notification twice.
Fix: only offer the subscribe control when window.matchMedia('(display-mode: standalone)').matches — i.e. inside the installed app. In a tab, tell them to install it first.
5. The PWA scope was the whole site
My manifest had "scope": "/". That means every URL on the domain opens inside the app once it's installed. So when an agent tested by scanning their own postcard, the homeowner's landing page opened inside the agent's app, full-screen, no address bar. Not a push bug, but I found it while chasing one.
Fix: scope the manifest to the app's path. And because scope is baked in at install time, users must remove and re-add the app for the change to take.
6. Stale subscriptions look like failures
The push service returns 410 Gone for a subscription the browser has dropped — the user cleared site data, or revoked permission, or subscribed from a tab and then installed the app. My first send loop counted these as errors and reported "1 of 2 failed."
Fix: treat 404 and 410 as "delete this subscription and move on," and report them separately from real failures. Now the diagnostic reads "1 of 2 accepted it — the other had expired and was dropped."
The diagnostic that ended it
The single most valuable thing I built was a Test notifications button in the app's account menu that sends one push to the current user and prints, in plain English, what happened:
1 of 2 accepted it. If nothing appears within a few seconds,
the phone is holding it back rather than the server: check
notifications are allowed for the app in your phone settings,
and that it is not in a focus or do-not-disturb mode.
fcm.googleapis.com: 410 — the subscription has expired
fcm.googleapis.com: accepted (201)
It reports per endpoint: the HTTP status, and a one-line reason. It distinguishes "never subscribed" from "the server sent it and the phone ate it" — which, once the server side works, is where every remaining support question lives.
If you build one thing from this post, build that.
What I'd tell myself at the start
- The
0x04byte, theaudorigin, and DER→raw are the three crypto bugs. They're each one line. - Log the response body. Every push service tells you why.
- Don't encrypt a payload you don't need. Wake the worker and let it ask.
- Only subscribe from the installed app. Tabs produce Chrome-branded notifications and duplicates.
- Treat 410 as cleanup, not failure.
- A diagnostic button that reports per endpoint in plain words will save you more time than any of the above.
The full plugin is closed, but everything here is the generic shape of it — the same code that runs in production, with the product-specific bits taken out. It's about 300 lines of PHP and 60 of JavaScript. No dependencies.
I run Market My Casa, postcard and lead-page software for real estate agents. The push notifications go to a phone app that's a PWA, because I did not want an App Store listing for a tool only existing customers use — that's a post for another day.