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.p256dh early).
  • RFC 8292 subscriber validation (mailto:, https://, or bare user@host).
  • Defaults for TTL (60s) and Urgency (normal), overridable via options.
  • Status mapping: 200/201/202StatusDelivered; 410 GoneErrSubscriptionGone; everything else → StatusFailed with 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.pem
openssl 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
// 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

subscribe.ts
// 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