VAPID
Send web push notifications via the VAPID protocol
The VAPID transport ships with @betternotify/webpush. It encrypts and delivers push messages directly to browser push services (RFC 8291 + RFC 8292). Uses WebCrypto for encryption and signing; no native dependencies. Works in Node 22+ and Cloudflare Workers.
Install
npm install @betternotify/webpush @betternotify/coreGenerating VAPID keys
VAPID uses a P-256 ECDSA key pair to identify your application server to push services. The public key is shared with the browser when subscribing (applicationServerKey in PushManager.subscribe()). The private key stays on your server and signs each push request so the push service can verify it came from you.
1. Generate the key pair
Run this once to create your keys. generateVapidKeys() uses the Web Crypto API internally, so it works in Node.js and Cloudflare Workers without extra dependencies.
import { generateVapidKeys } from '@betternotify/webpush';
const { publicKey, privateKey } = await generateVapidKeys();
console.log('VAPID_PUBLIC_KEY=' + publicKey);
console.log('VAPID_PRIVATE_KEY=' + privateKey);npx tsx scripts/generate-vapid-keys.ts2. Store the keys
Copy the output into your .env file (or your hosting provider's environment settings):
VAPID_PUBLIC_KEY=BNq9r...base64url...
VAPID_PRIVATE_KEY=abc12...base64url...Both values are base64url-encoded strings. The public key is 88 characters long; the private key is 43.
3. Pass them to the transport
vapidTransport({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: 'mailto:hello@example.com',
});The subject field is a contact URI (either mailto: or https://) that push services can use to reach you if something goes wrong.
4. Use the public key on the client
When subscribing a browser, pass the same public key as applicationServerKey:
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'BNq9r...same public key...',
});The returned subscription object contains the endpoint and keys your server needs in the to field when sending.
Regenerating keys invalidates every existing browser subscription. Users would need to re-subscribe through your app. Generate once, reuse across deploys.
Usage
import { createNotify, createClient } from '@betternotify/core';
import { webPushChannel } from '@betternotify/webpush';
import { vapidTransport } from '@betternotify/webpush/transports';
const webpush = webPushChannel();
const rpc = createNotify({ channels: { webpush } });
const catalog = rpc.catalog({
/* routes */
});
const notify = createClient({
catalog,
transportsByChannel: {
webpush: vapidTransport({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: 'mailto:hello@example.com',
}),
},
});For a working Hono server with subscription management and a browser client, see the web-push example.
Options
Prop
Type
Subscription lifecycle
When a push service returns 404 or 410, the transport marks that subscription as gone in the per-subscription results. Remove these subscriptions from storage to avoid repeated delivery failures.
Batch delivery
The transport sends to all subscriptions in parallel. If every subscription fails, the result is a non-retriable error. Partial success is reported as ok: true with per-subscription details in data.results:
const result = await notify.alert.send({
to: [subscription1, subscription2],
input: { title: 'Heads up', body: 'New deploy incoming' },
});
// result.data.results: Array<{ endpoint, ok, statusCode?, gone? }>Key format
VAPID keys are an ECDSA P-256 key pair. The publicKey is the 65-byte uncompressed point encoded as base64url; the privateKey is the 32-byte d parameter encoded as base64url. Reuse the same key pair across deploys.