Web Push
Channels

Web Push

Send push notifications to browsers via VAPID

The web push channel is provided by @betternotify/webpush. It defines slots for title, body, icon, badge, image, tag, data payload, and action buttons. Notifications are delivered through the Push API using the VAPID protocol (RFC 8291 + RFC 8292).

When to use web push

Web push is the right channel when you need to reach users in the browser without a native app. Notifications appear at the OS level even when the tab is closed, as long as the service worker is registered. Use it for time-sensitive alerts, new messages, price drops, or activity updates where the value is in the immediate interruption.

For native mobile push (FCM, APNs), use the push channel instead.

npm install @betternotify/webpush

Setup

import { createNotify } from '@betternotify/core';
import { webPushChannel } from '@betternotify/webpush';

const webpush = webPushChannel();

const rpc = createNotify({ channels: { webpush } });

Slots

Prop

Type

Send arguments

Prop

Type

Generating VAPID keys

The VAPID protocol requires a P-256 ECDSA key pair to sign requests to push services. Generate one with generateVapidKeys() and store both values as environment variables:

import { generateVapidKeys } from '@betternotify/webpush';

const { publicKey, privateKey } = await generateVapidKeys();
// publicKey  → 65-byte uncompressed point, base64url-encoded
// privateKey → 32-byte d parameter, base64url-encoded

The public key is also needed on the client side when calling PushManager.subscribe() as the applicationServerKey. Regenerating keys invalidates all existing subscriptions.

Full example

import { createNotify, createClient } from '@betternotify/core';
import { webPushChannel } from '@betternotify/webpush';
import { vapidTransport } from '@betternotify/webpush/transports';
import { z } from 'zod';

const webpush = webPushChannel();
const rpc = createNotify({ channels: { webpush } });

const catalog = rpc.catalog({
  newMessage: rpc
    .webpush()
    .input(z.object({ from: z.string(), preview: z.string() }))
    .title(({ input }) => `New message from ${input.from}`)
    .body(({ input }) => input.preview)
    .icon('/icons/message.png')
    .tag('new-message'),
});

const notify = createClient({
  catalog,
  transportsByChannel: {
    webpush: vapidTransport({
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
      subject: 'mailto:hello@example.com',
    }),
  },
});

await notify.newMessage.send({
  to: {
    endpoint: 'https://push.example.com/v1/...',
    keys: { p256dh: '...', auth: '...' },
  },
  input: { from: 'Alice', preview: 'See you tomorrow' },
});

For a working Hono server with subscription management and a browser client, see the web-push example.

Transport

The web push channel produces a RenderedWebPush with all resolved slots plus the to field. Use vapidTransport for direct delivery via the VAPID protocol, or build a custom transport with createTransport:

import { createTransport } from '@betternotify/webpush/transports';
import type { RenderedWebPush } from '@betternotify/webpush';

const myTransport = createTransport({
  name: 'my-push-provider',
  send: async (rendered) => {
    // rendered.title, rendered.body, rendered.to, etc.
    return { ok: true, data: { results: [] } };
  },
});

On this page