18% click-through rate for price drop alerts vs. 2% for equivalent email. The channel difference isn’t the message — it’s delivery context. A push notification appears on the lock screen.
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.
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.
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:
# 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:
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;
}
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:
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();
}
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:
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';
});
}
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:
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 ),
] );
}
}
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.
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.
isSubscriptionExpired() on every flush report. Dead endpoints should be deleted immediately, not batched.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.
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 →