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:
- Show a pre-prompt UI explaining the value of notifications
- Only request on user action (e.g., "Enable notifications" button click)
- 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
| Browser | Push API | Notifications | Service Workers | Notes |
|---|---|---|---|---|
| Chrome 50+ | Yes | Yes | Yes | Full support |
| Firefox 44+ | Yes | Yes | Yes | Full support |
| Edge 17+ | Yes | Yes | Yes | Full support |
| Safari 16+ | Yes | Yes | Yes | macOS Ventura+ and iOS 16.4+ |
| Opera 42+ | Yes | Yes | Yes | Chromium-based |
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
| Issue | Cause | Solution |
|---|---|---|
ServiceWorker not available | Not served over HTTPS | Use HTTPS (localhost is exempt) |
PushManager not available | Browser doesn't support Push API | Check browser compatibility |
| Subscription fails | Invalid VAPID key | Verify key format and encoding |
| Notifications don't show | Permission not granted | Check Notification.permission |
| Service worker not updating | Cached old version | Use registration.update() or hard refresh |
| Push events not received | Subscription expired | Re-subscribe and re-register with Zyphr |
| Click handler doesn't work | event.waitUntil missing | Always wrap async operations in waitUntil |
Next Steps
- Push Notifications API Reference — Full API parameters and options
- Node.js SDK Reference — Server-side SDK documentation
- Device Management — Managing registered devices
- Topics — Subscribe devices to notification topics