<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Patrick Flaven</title>
    <description>The latest articles on DEV Community by Patrick Flaven (@marketmy_casa).</description>
    <link>https://dev.to/marketmy_casa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4127948%2F5e73d50e-bb69-4c1d-bc64-31ac55b11c3c.jpg</url>
      <title>DEV Community: Patrick Flaven</title>
      <link>https://dev.to/marketmy_casa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marketmy_casa"/>
    <language>en</language>
    <item>
      <title>Web push from a PHP backend with no vendor: VAPID, ES256, and the six ways it fails silently</title>
      <dc:creator>Patrick Flaven</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:32:02 +0000</pubDate>
      <link>https://dev.to/marketmy_casa/web-push-from-a-php-backend-with-no-vendor-vapid-es256-and-the-six-ways-it-fails-silently-34a5</link>
      <guid>https://dev.to/marketmy_casa/web-push-from-a-php-backend-with-no-vendor-vapid-es256-and-the-six-ways-it-fails-silently-34a5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your page asks &lt;code&gt;Notification.requestPermission()&lt;/code&gt;, then &lt;code&gt;registration.pushManager.subscribe()&lt;/code&gt; with your &lt;strong&gt;VAPID public key&lt;/strong&gt;. The browser hands back a subscription: an endpoint URL on the push service, plus two keys.&lt;/li&gt;
&lt;li&gt;You store the endpoint.&lt;/li&gt;
&lt;li&gt;To notify, your server does an HTTP POST to that endpoint, signed with a &lt;strong&gt;VAPID JWT&lt;/strong&gt; so the push service knows it's you.&lt;/li&gt;
&lt;li&gt;The push service wakes the service worker, which shows a notification.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crypto, in PHP
&lt;/h2&gt;

&lt;p&gt;Generate the keypair once and store it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_pkey_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'private_key_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_KEYTYPE_EC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'curve_name'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'prime256v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;openssl_pkey_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pem&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_pkey_get_details&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// The browser wants the raw uncompressed point, base64url, no padding.&lt;/span&gt;
&lt;span class="nv"&gt;$public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$details&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ec'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$details&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ec'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — that public key is 64 bytes and the browser wants 65. The uncompressed point format is &lt;code&gt;0x04 || X || Y&lt;/code&gt;. Missing that leading byte was my first silent failure: &lt;code&gt;subscribe()&lt;/code&gt; throws &lt;code&gt;InvalidAccessError&lt;/code&gt;, which your promise chain probably swallows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x04&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$details&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ec'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$details&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ec'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JWT is standard ES256. The claims matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$audience&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_SCHEME&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'://'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'typ'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'JWT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alg'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ES256'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'aud'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// scheme://host of the push service, NOT the full endpoint&lt;/span&gt;
    &lt;span class="s1"&gt;'exp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// at most 24h; I use 12&lt;/span&gt;
    &lt;span class="s1"&gt;'sub'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mailto:you@example.com'&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;openssl_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$claims&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$der&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_ALGO_SHA256&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second silent failure: &lt;code&gt;aud&lt;/code&gt; must be the origin of the push service, not the subscription endpoint. &lt;code&gt;https://fcm.googleapis.com&lt;/code&gt;, not &lt;code&gt;https://fcm.googleapis.com/fcm/send/abc123&lt;/code&gt;. Get it wrong and FCM returns 403 with a body you will never read because you only logged the status code.&lt;/p&gt;

&lt;p&gt;Third: OpenSSL gives you a DER-encoded signature. JWS wants raw &lt;code&gt;R || S&lt;/code&gt;, 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;der_to_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$der&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// SEQUENCE { INTEGER r, INTEGER s }&lt;/span&gt;
    &lt;span class="nv"&gt;$pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'s'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$part&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$pos&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                               &lt;span class="c1"&gt;// 0x02&lt;/span&gt;
        &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$der&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$pos&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$der&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$pos&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ltrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// strip the sign byte&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;STR_PAD_LEFT&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$claims&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;der_to_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$der&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'vapid t='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$jwt&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;', k='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'TTL'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Content-Length'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the empty body. I send &lt;strong&gt;no payload at all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no payload
&lt;/h2&gt;

&lt;p&gt;Encrypting a payload for web push means ECDH against the subscription's &lt;code&gt;p256dh&lt;/code&gt; key, HKDF, AES-128-GCM, and the &lt;code&gt;aes128gcm&lt;/code&gt; content encoding with its salt and record framing. It's all doable in PHP. It's also all unnecessary for my case.&lt;/p&gt;

&lt;p&gt;An empty push still wakes the service worker. The worker can then fetch &lt;code&gt;/wp-json/myplugin/v1/push/latest&lt;/code&gt; — 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wp-json/myplugin/v1/push/latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The one gotcha: &lt;strong&gt;service workers don't have your REST nonce.&lt;/strong&gt; WordPress's cookie auth for REST requires &lt;code&gt;X-WP-Nonce&lt;/code&gt;, and the worker has no page to read it from. So &lt;code&gt;/push/latest&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the week went
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The subscription was never stored
&lt;/h3&gt;

&lt;p&gt;The page called &lt;code&gt;subscribe()&lt;/code&gt;, got a subscription, and POSTed it to &lt;code&gt;/wp-json/myplugin/v1/push/subscribe&lt;/code&gt; — which returned 401 because I'd forgotten &lt;code&gt;X-WP-Nonce&lt;/code&gt; on the fetch. The promise chain had a &lt;code&gt;.catch&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; send the nonce; and on failure, say so on screen rather than tidying up.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The audience was almost right
&lt;/h3&gt;

&lt;p&gt;I built &lt;code&gt;aud&lt;/code&gt; from the full endpoint on the first pass. FCM's 403 body says &lt;code&gt;the aud claim is invalid&lt;/code&gt;. I was logging &lt;code&gt;wp_remote_retrieve_response_code()&lt;/code&gt; and not the body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; log the body on any non-2xx. Every push service returns a readable reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The wrong people
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; the audience for a solo account is the owner. Obvious afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The right people, the wrong app
&lt;/h3&gt;

&lt;p&gt;This one cost the most and is the most useful.&lt;/p&gt;

&lt;p&gt;On Android, a web push notification is attributed to &lt;strong&gt;whatever registered the subscription&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Worse: subscribe from both and you have two subscriptions, and the user gets every notification twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; only offer the subscribe control when &lt;code&gt;window.matchMedia('(display-mode: standalone)').matches&lt;/code&gt; — i.e. inside the installed app. In a tab, tell them to install it first.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The PWA scope was the whole site
&lt;/h3&gt;

&lt;p&gt;My manifest had &lt;code&gt;"scope": "/"&lt;/code&gt;. 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 &lt;em&gt;homeowner's&lt;/em&gt; landing page opened inside the agent's app, full-screen, no address bar. Not a push bug, but I found it while chasing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Stale subscriptions look like failures
&lt;/h3&gt;

&lt;p&gt;The push service returns &lt;strong&gt;410 Gone&lt;/strong&gt; 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."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; treat 404 and 410 as "delete this subscription and move on," and report them separately from real failures. Now the diagnostic reads &lt;em&gt;"1 of 2 accepted it — the other had expired and was dropped."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic that ended it
&lt;/h2&gt;

&lt;p&gt;The single most valuable thing I built was a &lt;strong&gt;Test notifications&lt;/strong&gt; button in the app's account menu that sends one push to the current user and prints, in plain English, what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you build one thing from this post, build that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;0x04&lt;/code&gt; byte, the &lt;code&gt;aud&lt;/code&gt; origin, and DER→raw are the three crypto bugs. They're each one line.&lt;/li&gt;
&lt;li&gt;Log the response &lt;strong&gt;body&lt;/strong&gt;. Every push service tells you why.&lt;/li&gt;
&lt;li&gt;Don't encrypt a payload you don't need. Wake the worker and let it ask.&lt;/li&gt;
&lt;li&gt;Only subscribe from the installed app. Tabs produce Chrome-branded notifications and duplicates.&lt;/li&gt;
&lt;li&gt;Treat 410 as cleanup, not failure.&lt;/li&gt;
&lt;li&gt;A diagnostic button that reports per endpoint in plain words will save you more time than any of the above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I run &lt;a href="https://marketmy.casa" rel="noopener noreferrer"&gt;Market My Casa&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>php</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
