Skip to main content

Web Push & Service Workers

This guide walks you through the entire Web Push integration — from VAPID setup to a complete service worker implementation with notification click handling.

Prerequisites

  • A Zyphr account with push notifications enabled
  • VAPID provider configured in Zyphr (see Provider Setup)
  • Your site served over HTTPS (required for service workers)
  • A modern browser (Chrome 50+, Firefox 44+, Edge 17+, Safari 16+)

How Web Push Works

1. Register service worker        → Browser
2. Request notification permission → User grants
3. Subscribe to push (VAPID key) → Browser returns PushSubscription
4. Send subscription to Zyphr → Zyphr stores device
5. Zyphr sends push via provider → Browser wakes service worker
6. Service worker shows notification → User sees and taps

The key difference from mobile: Web Push requires a service worker — a background script that handles push events even when your site isn't open.

1. VAPID Key Setup

VAPID (Voluntary Application Server Identification) keys authenticate your server with the push service.

Get Your VAPID Public Key

Your VAPID public key is configured in Zyphr under Push → Providers → Web Push (VAPID).

You'll need the public key in your frontend code to create push subscriptions.

Generate VAPID Keys

If you don't have VAPID keys yet, generate them:

# Using the web-push library
npx web-push generate-vapid-keys

This outputs a public/private key pair. Enter both in Zyphr's VAPID provider configuration along with a subject (a mailto: or https: URL identifying the sender).

2. Create the Service Worker

Create a file called sw.js (or service-worker.js) in your site's public root directory:

// sw.js — must be at the root of your domain (e.g., /sw.js)

self.addEventListener('push', function (event) {
if (!event.data) return;

const payload = event.data.json();

const title = payload.title || 'Notification';
const options = {
body: payload.body || '',
icon: payload.icon || '/icons/notification-icon.png',
badge: payload.badge || '/icons/badge-icon.png',
image: payload.image_url || undefined,
data: payload.data || {},
tag: payload.tag || undefined,
requireInteraction: payload.require_interaction || false,
actions: (payload.action_buttons || []).map(function (btn) {
return {
action: btn.id,
title: btn.title,
icon: btn.icon,
};
}),
};

event.waitUntil(
self.registration.showNotification(title, options)
);
});

self.addEventListener('notificationclick', function (event) {
event.notification.close();

var data = event.notification.data || {};
var targetUrl = data.url || data.deep_link || '/';

// Handle action button clicks
if (event.action) {
switch (event.action) {
case 'view':
targetUrl = data.url || '/';
break;
case 'dismiss':
return; // Just close the notification
}
}

// Focus existing tab or open new one
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (windowClients) {
// Check if there's already a tab with the target URL
for (var i = 0; i < windowClients.length; i++) {
var client = windowClients[i];
if (client.url === targetUrl && 'focus' in client) {
return client.focus();
}
}
// Open a new tab
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});

self.addEventListener('notificationclose', function (event) {
// Optional: track dismissals
var data = event.notification.data || {};
// You could send an analytics event here
});

// Handle push subscription change (browser rotates keys)
self.addEventListener('pushsubscriptionchange', function (event) {
event.waitUntil(
self.registration.pushManager.subscribe(event.oldSubscription.options)
.then(function (subscription) {
// Re-register the new subscription with Zyphr
return fetch('/api/push/resubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
})
);
});

3. Register the Service Worker

In your main application JavaScript:

async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('Service workers not supported');
return null;
}

try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service worker registered:', registration.scope);
return registration;
} catch (error) {
console.error('Service worker registration failed:', error);
return null;
}
}

4. Request Notification Permission

async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.warn('Notifications not supported');
return false;
}

if (Notification.permission === 'granted') {
return true;
}

if (Notification.permission === 'denied') {
console.warn('Notifications blocked by user');
return false;
}

const permission = await Notification.requestPermission();
return permission === 'granted';
}

Permission UX Best Practices

Don't request permission immediately on page load. Instead:

  1. Show a pre-prompt UI explaining the value of notifications
  2. Only request on user action (e.g., "Enable notifications" button click)
  3. Explain what they'll receive before the browser prompt appears
// Example: button-triggered permission request
document.getElementById('enable-notifications').addEventListener('click', async () => {
const granted = await requestNotificationPermission();
if (granted) {
await subscribeToPush();
showSuccess('Notifications enabled!');
} else {
showInfo('You can enable notifications later in your browser settings.');
}
});

5. Subscribe to Push

Create a push subscription using your VAPID public key:

// Your VAPID public key from Zyphr dashboard
const VAPID_PUBLIC_KEY = 'BEl62iUYgU...your_vapid_public_key';

function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
}

async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;

// Check for existing subscription
let subscription = await registration.pushManager.getSubscription();

if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
}

// Register the subscription with Zyphr
await registerSubscriptionWithZyphr(subscription);

return subscription;
}

6. Register Subscription with Zyphr

Send the PushSubscription object to Zyphr as a device registration:

async function registerSubscriptionWithZyphr(subscription) {
// The subscription contains endpoint, keys.p256dh, and keys.auth
const subscriptionJSON = subscription.toJSON();

const response = await fetch('https://api.zyphr.dev/v1/devices', {
method: 'POST',
headers: {
'X-API-Key': 'zy_live_xxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: 'user_123', // Your authenticated user's ID
token: JSON.stringify(subscriptionJSON), // Full subscription as token
platform: 'web',
}),
});

if (!response.ok) {
throw new Error(`Registration failed: ${response.status}`);
}

console.log('Web push subscription registered with Zyphr');
}

Using the Node.js SDK (server-side registration)

If you'd rather register from your backend after your frontend sends the subscription:

import { Zyphr } from '@zyphr-dev/node-sdk';

const zyphr = new Zyphr({ apiKey: process.env.ZYPHR_API_KEY });

app.post('/api/push/subscribe', async (req, res) => {
const { userId, subscription } = req.body;

await zyphr.devices.register({
subscriber_id: userId,
token: JSON.stringify(subscription),
platform: 'web',
});

res.json({ success: true });
});

7. Complete Integration Example

Here's a full, copy-paste-ready integration:

push-manager.js

const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY';
const ZYPHR_API_KEY = 'zy_live_xxx';

function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
}

export async function initPushNotifications(userId) {
// 1. Register service worker
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifications not supported');
return null;
}

const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;

// 2. Check permission
if (Notification.permission === 'denied') {
console.warn('Notifications are blocked');
return null;
}

if (Notification.permission !== 'granted') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
}

// 3. Subscribe
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
}

// 4. Register with Zyphr
await fetch('https://api.zyphr.dev/v1/devices', {
method: 'POST',
headers: {
'X-API-Key': ZYPHR_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: userId,
token: JSON.stringify(subscription.toJSON()),
platform: 'web',
}),
});

return subscription;
}

export async function unsubscribeFromPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
}

export function getPermissionState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission; // 'default', 'granted', or 'denied'
}

sw.js

self.addEventListener('push', function (event) {
if (!event.data) return;

var payload = event.data.json();

event.waitUntil(
self.registration.showNotification(payload.title || 'Notification', {
body: payload.body || '',
icon: payload.icon || '/icons/icon-192.png',
badge: '/icons/badge-72.png',
image: payload.image_url || undefined,
data: payload.data || {},
actions: (payload.action_buttons || []).map(function (btn) {
return { action: btn.id, title: btn.title };
}),
})
);
});

self.addEventListener('notificationclick', function (event) {
event.notification.close();

var url = (event.notification.data || {}).url || '/';

event.waitUntil(
clients.matchAll({ type: 'window' }).then(function (windowClients) {
for (var i = 0; i < windowClients.length; i++) {
if (windowClients[i].url === url && 'focus' in windowClients[i]) {
return windowClients[i].focus();
}
}
return clients.openWindow(url);
})
);
});

Usage in React

import { useEffect, useState } from 'react';
import { initPushNotifications, unsubscribeFromPush, getPermissionState } from './push-manager';

function NotificationToggle({ userId }: { userId: string }) {
const [permission, setPermission] = useState(getPermissionState());
const [subscribed, setSubscribed] = useState(false);

useEffect(() => {
// Check existing subscription on mount
navigator.serviceWorker?.ready.then((reg) =>
reg.pushManager.getSubscription().then((sub) => setSubscribed(!!sub))
);
}, []);

const handleEnable = async () => {
const subscription = await initPushNotifications(userId);
setSubscribed(!!subscription);
setPermission(getPermissionState());
};

const handleDisable = async () => {
await unsubscribeFromPush();
setSubscribed(false);
};

if (permission === 'unsupported') return <p>Push not supported in this browser.</p>;
if (permission === 'denied') return <p>Notifications blocked. Enable in browser settings.</p>;

return (
<button onClick={subscribed ? handleDisable : handleEnable}>
{subscribed ? 'Disable Notifications' : 'Enable Notifications'}
</button>
);
}

8. Sending Web Push from Zyphr

Once the device is registered, send notifications the same way as mobile:

import { Zyphr } from '@zyphr-dev/node-sdk';

const zyphr = new Zyphr({ apiKey: process.env.ZYPHR_API_KEY });

await zyphr.push.send({
user_id: 'user_123',
title: 'New Comment',
body: 'John replied to your post',
image_url: 'https://yourapp.com/images/notification.png',
data: {
url: 'https://yourapp.com/posts/456#comment-789',
type: 'comment',
},
action_buttons: [
{ id: 'view', title: 'View' },
{ id: 'dismiss', title: 'Dismiss' },
],
});

Browser Support

BrowserPush APINotificationsService WorkersNotes
Chrome 50+YesYesYesFull support
Firefox 44+YesYesYesFull support
Edge 17+YesYesYesFull support
Safari 16+YesYesYesmacOS Ventura+ and iOS 16.4+
Opera 42+YesYesYesChromium-based
Safari

Safari 16+ on macOS supports Web Push with some differences:

  • Requires VAPID (APNs website push certificates are deprecated)
  • Push events may be delayed when Safari is not running
  • iOS Safari 16.4+ supports Web Push for installed PWAs only

Troubleshooting

IssueCauseSolution
ServiceWorker not availableNot served over HTTPSUse HTTPS (localhost is exempt)
PushManager not availableBrowser doesn't support Push APICheck browser compatibility
Subscription failsInvalid VAPID keyVerify key format and encoding
Notifications don't showPermission not grantedCheck Notification.permission
Service worker not updatingCached old versionUse registration.update() or hard refresh
Push events not receivedSubscription expiredRe-subscribe and re-register with Zyphr
Click handler doesn't workevent.waitUntil missingAlways wrap async operations in waitUntil

Next Steps