Last updated 2026-05-28
Web Push channel
Web Push delivers notifications to a browser even when your tab is
closed, via the W3C Push API (RFC 8030) with VAPID (RFC 8292) for
application-server identification and the aes128gcm
content encoding (RFC 8291) for payload encryption.
When you'd use this
Anything you'd push to a desktop or mobile-web user who isn't currently looking at your app. Common cases: chat messages while the tab is in the background, new email/comment alerts, urgent system notifications.
Provider: VAPID
The provider implementation lives in
channels/webpush and wraps
github.com/SherClockHolmes/webpush-go for the on-wire
encryption work. notify owns:
- Subscription JSON parsing + validation (rejects missing
endpoint/keys.auth/keys.p256dhearly). - RFC 8292 subscriber validation (
mailto:,https://, or bareuser@host). - Defaults for
TTL(60s) andUrgency(normal), overridable via options. - Status mapping:
200/201/202→StatusDelivered;410 Gone→ErrSubscriptionGone; everything else →StatusFailedwith the body text.
Generating VAPID keys
# One-off: generate a VAPID key pair.# npm: npx web-push generate-vapid-keys# Or via openssl:openssl ecparam -name prime256v1 -genkey -noout -out vapid-private.pemopenssl ec -in vapid-private.pem -pubout -out vapid-public.pem
# Convert to base64url (RFC 8292 wire form). web-push-libs/web-push-go# accepts either base64 or base64url; configure base64url for the# server-side and the browser PushManager call.Configuration
-e NOTIFY_WEBPUSH_PROVIDER=vapid \-e NOTIFY_WEBPUSH_VAPID_PUBLIC=BNcL... \-e NOTIFY_WEBPUSH_VAPID_PRIVATE=Yt8L... \-e NOTIFY_WEBPUSH_CONTACT_EMAIL=mailto:ops@example.com
The contact email is required by RFC 8292; push services may reject
anonymous senders. mailto: and https://
URLs are both accepted; a bare user@host string is
auto-prefixed with mailto:.
Browser side — service worker registration
// public/sw.js — the service worker that surfaces incoming pushes.self.addEventListener("push", (event) => { const data = event.data?.json() ?? {}; event.waitUntil( self.registration.showNotification(data.title || "Notification", { body: data.body, data: data.data, // your deep-link key/values icon: "/icon-192.png", }) );});
self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil( clients.openWindow(event.notification.data?.url || "/") );});Browser side — subscribe + register
// Convert base64url VAPID public key to a Uint8Array PushManager wants.function urlBase64ToUint8Array(b64) { const padded = (b64 + "=".repeat((4 - b64.length % 4) % 4)) .replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(padded); return Uint8Array.from(raw, (c) => c.charCodeAt(0));}
async function subscribePush(vapidPublicKey) { const reg = await navigator.serviceWorker.register("/sw.js"); await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription(); if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); }
// Register with notify. await client.registerPushToken({ deviceType: "DEVICE_TYPE_BROWSER", token: JSON.stringify(sub), // the whole PushSubscription JSON });}Sending a web push
The provider expects the JSON-stringified PushSubscription
as the address. In production this comes from the device store
(RegisterPushToken writes it); in a one-off call you can
inline it in the Addresses map.
curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \ -H 'Content-Type: application/json' \ -H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \ -d '{ "tenantId": "acme", "notificationId": "chat-msg-9001", "userIds": ["user-alice"], "channels": ["DELIVERY_CHANNEL_WEB_PUSH"], "title": "Bob", "body": "Are you around?", "addresses": { "user-alice": { "byChannel": { "web_push": "{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/...\",\"keys\":{\"auth\":\"...\",\"p256dh\":\"...\"}}" } } } }'Handling ErrSubscriptionGone
HTTP 410 from the push service means the user has unsubscribed or
uninstalled the browser. The provider surfaces this as a sentinel
you can match with errors.Is:
import "github.com/elloloop/notify/channels/webpush"
receipt, err := provider.Send(ctx, msg)if err != nil { if errors.Is(err, webpush.ErrSubscriptionGone) { // Purge the device row in your store. notify itself never // auto-purges — the device store is yours. deviceStore.Delete(ctx, msg.Notification.TenantID, msg.Notification.UserID, "browser") } else { log.Warn("webpush_send_failed", "err", err) }}Why the provider does not auto-purge
Why this design. Both web push (ErrSubscriptionGone) and FCM (ErrUnregisteredToken) expose token-is-dead as a typed error. The provider never deletes the corresponding device row itself because (a) the device store is owned by the orchestrator, not the provider, and (b) a future caller may want to keep the row for audit or to attempt re-registration. Providers stay stateless; purge logic lives at the call site.
Receipts
Push services don't return a message id, so successful receipts
have an empty ProviderMessageID. The status is
StatusDelivered on 200/201/202.
Related
- Mobile push channel — sibling channel for native apps
- Register a push token — full round-trip example
- Channels & Providers