GAP School Module 09 — SMS, Mobile, PWA Lesson 9.3

Push notifications from a web app get an 18% click-through rate for price drop and new listing alerts on the Anchor build. Email gets 2% CTR for the equivalent content. The channel difference isn’t about the message — it’s about delivery context. A push notification appears on the lock screen. An email sits in a tab the user isn’t looking at.


The situation

Buyers who browsed the Anchor site but didn’t purchase often had a specific reason: price was above budget, nothing in inventory matched their spec, or they were waiting for the right unit to come in. These buyers had already demonstrated intent. The question was how to bring them back when their situation changed.

Saved searches and saved units created the intent signal. Push notifications created the delivery mechanism.


What I did

VAPID key generation

VAPID (Voluntary Application Server Identification) is the authentication standard for Web Push. The server generates a key pair once; the public key is sent to the browser, the private key stays server-side:

Shell — one-time setup
# One-time generation npx web-push generate-vapid-keys # Outputs: # Public Key: BG8... # Private Key: K9...

The public key is included in the JavaScript that requests the push subscription:

JavaScript
var VAPID_PUBLIC_KEY = '[your_base64_url_encoded_public_key]'; function urlB64ToUint8Array(base64String) { var padding = '='.repeat((4 - base64String.length % 4) % 4); var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); var rawData = window.atob(base64); return new Uint8Array([...rawData].map(c => c.charCodeAt(0))); } async function subscribeToPush() { var reg = await navigator.serviceWorker.ready; var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY), }); return sub; }

Push subscription storage

The subscription object (endpoint + auth keys) is sent to the server and stored, indexed by endpoint hash. Each subscription is associated with the visitor — by lead ID if they’ve submitted a form, by device otherwise:

PHP
add_action( 'wp_ajax_nopriv_[client]_save_push_sub', '[client]_handle_push_subscription' ); add_action( 'wp_ajax_[client]_save_push_sub', '[client]_handle_push_subscription' ); function [client]_handle_push_subscription(): void { check_ajax_referer( '[client]_push_nonce', 'nonce' ); $sub_json = sanitize_text_field( $_POST['subscription'] ?? '' ); $lead_id = (int) ( $_POST['lead_id'] ?? 0 ); if ( ! $sub_json ) { wp_send_json_error( 'No subscription' ); return; } $sub = json_decode( $sub_json, true ); $ep_key = md5( $sub['endpoint'] ?? '' ); update_option( '[client]_push_sub_' . $ep_key, [ 'subscription' => $sub_json, 'lead_id' => $lead_id, 'created_at' => current_time( 'mysql' ), 'saved_units' => [], 'saved_search' => [], ] ); wp_send_json_success(); }

Opt-in UX

The browser push permission dialog is a one-shot ask. Users who dismiss it can’t be re-prompted in the same session (Chrome won’t re-prompt for 90 days after dismissal). The in-page opt-in must precede and explain the browser dialog:

JavaScript
function showPushOptIn() { var banner = document.getElementById('[client]-push-banner'); if (!banner || !('Notification' in window) || Notification.permission !== 'default') { return; } banner.style.display = 'flex'; document.getElementById('[client]-push-accept').addEventListener('click', async function() { var permission = await Notification.requestPermission(); if (permission === 'granted') { var sub = await subscribeToPush(); var formData = new FormData(); formData.append('action', '[client]_save_push_sub'); formData.append('nonce', [client]PushNonce); formData.append('subscription', JSON.stringify(sub)); fetch('/wp-admin/admin-ajax.php', { method: 'POST', body: formData }); } banner.style.display = 'none'; }); }

Server-side send with expired subscription cleanup

When a unit’s price drops or a new unit matching a saved search is added, the server sends push notifications. Expired subscriptions are cleaned up on every send result:

PHP
use Minishlink\WebPush\WebPush; use Minishlink\WebPush\Subscription; function [client]_send_push_notification( array $subscription_data, array $payload ): bool { $auth = [ 'VAPID' => [ 'subject' => 'mailto:' . get_option( 'admin_email' ), 'publicKey' => [client]_get_setting( 'vapid_public_key' ), 'privateKey' => [client]_get_setting( 'vapid_private_key' ), ], ]; $push = new WebPush( $auth ); $sub = Subscription::create( json_decode( $subscription_data['subscription'], true ) ); $result = $push->queueNotification( $sub, json_encode( $payload ) ); foreach ( $push->flush() as $report ) { if ( $report->isSubscriptionExpired() ) { [client]_delete_push_subscription( $subscription_data ); } return $report->isSuccess(); } return false; } function [client]_notify_price_drop( int $unit_id, float $old_price, float $new_price ): void { $subscriptions = [client]_get_subscribers_for_unit( $unit_id ); foreach ( $subscriptions as $sub_data ) { [client]_send_push_notification( $sub_data, [ 'title' => 'Price Drop: ' . get_the_title( $unit_id ), 'body' => 'Was $' . number_format( $old_price ) . ', now $' . number_format( $new_price ), 'icon' => '/images/pwa-icon-192.png', 'url' => get_permalink( $unit_id ), ] ); } }

Why it matters

The 18% CTR reflects both the delivery context (lock screen vs. email tab) and the relevance signal (the subscriber saved this specific unit or this search). Irrelevant push notifications get blocked immediately. Relevant ones get acted on.

Cleaning up expired subscriptions is critical for performance. Push endpoints expire when users uninstall the PWA or revoke permission. Without cleanup, the server continues attempting to send to dead endpoints on every notification send.


The Anchor build

Push notifications active for 11 months. 1,340 subscribers. Price drop notifications: 18% CTR, 6% same-day form submission. New listing notifications for saved searches: 14% CTR, 4% same-day form submission. Expired subscription cleanup removed 23% of stored subscriptions over the 11-month period — those endpoints would have added overhead to every send if left in storage.


Do this, not that

  • Show an in-page opt-in banner before the browser permission dialog. The browser dialog is a one-shot ask. Users who dismiss it without context can’t be re-prompted. The banner explains why they should say yes before the browser asks.
  • Send only relevant notifications. A subscriber who saved a specific unit should only get notifications about that unit. Mass notifications burn through the subscriber list.
  • Clean up expired subscriptions on every send result. Check isSubscriptionExpired() on every flush report. Dead endpoints should be deleted immediately, not batched.
  • Use VAPID authentication. Unauthenticated push is deprecated by most push services. VAPID is the current standard.
  • Store the subscription by endpoint hash, not by user ID. A user may have multiple devices. Index by endpoint hash; join to lead record by foreign key.
When you’re ready to build

The lessons are yours. When you want it built, we’re here.

Every lesson stays free — no account, no paywall, no email gate, ever. But if you’d rather have this system standing on your business than wire all 48 lessons yourself, leave your email. We’ll send you a direct line to a build — and you’ll be first to hear when we add new tools to the curriculum.

None of this gates a single lesson. The curriculum was free before you got here and it stays that way.

We’ll use your email to send you a fast-track to a GAP build and occasional notes on how GAP builds digital sales departments. Lessons stay 100% free — no email required to read any of them. We never share or sell your information. Unsubscribe any time. Privacy policy at gapindustriesllc.com/privacy.html.

Done learning how it’s built? We’ll build it.

You came here to understand the system, and now you do. If you’d rather have it standing on your business than spend the next three months wiring it yourself, GAP Concierge is the same architecture from these lessons — a white-label AI agent that knows your catalog and captures your leads — set up for you, from $97/mo.

See GAP Concierge →