Push

Web push for browsers

Send the same push to your users’ browsers as to their phones: Chrome, Edge, Firefox and Safari, through the browser’s own push service.

VAPID keys

Browsers only accept pushes signed with the key a site subscribed with (VAPID). honkio makes a key pair for every push app when the app is created. The public key is on the app as web.vapid_public_key and on the app’s page in the dashboard; your site passes it to the browser when it subscribes. The private key is encrypted at rest and never shown or returned.

Moving from another provider? Import the pair your browsers already subscribed with, and they keep receiving without subscribing again. Generating a new pair, or importing a different one, retires every browser registered on the app, in live and test mode: each is deactivated with the reason VAPID_KEY_REPLACED, because its push service would refuse the new signature, and one push_app.vapid_key_replaced webhook gives how many in each mode (there is no push_device.deactivated for each one). A browser comes back when it subscribes again with the new public key (the helper does this when your page calls subscribe()). Importing the pair already in place retires nothing. Both need a live key, and a step-up in the dashboard.

Node.js
// No keys: generate a new pair. Both keys: import the pair your browsers subscribed with.
const { data: pushApp, error } = await honkio.push.web.setVapidKeys('APP_ID', {
  publicKey: process.env.VAPID_PUBLIC_KEY ?? '',
  privateKey: process.env.VAPID_PRIVATE_KEY ?? '',
})
if (error) throw new Error(error.message)
console.log(pushApp.web?.vapid_public_key)

Each push carries a contact for the push services (the VAPID subject): https://honkio.ca unless you set your own with web_push_subject on PATCH /v1/push/apps/:id. It must be yours: a mailto: of your account’s verified email, or a mailto: address or https: URL on one of your verified sending domains (a name under one counts too). Anything else is refused with 422 VALIDATION_ERROR. If the domain stops being verified, pushes go out with the default until it is again. Changing it keeps the key pair.

The browser side: @honkio/web-push

The @honkio/web-push package registers your service worker, asks the user, subscribes the browser and hands the subscription to your backend. It never calls honkio and holds no API key: your key stays on your server.

bash
npm install @honkio/web-push

In your service worker, installHonkioPush shows each notification and opens data.url (a path on your site) when it is clicked.

javascript
// public/sw.js (bundled, or with an import map)
import { installHonkioPush } from '@honkio/web-push/sw';

installHonkioPush(self, {
  icon: '/icon-192.png',
  defaultTitle: 'Acme',
});

On your page, call subscribe() from a click, with your app’s public key and a function that sends the subscription to your backend.

javascript
import { subscribe, unsubscribe, isSupported } from '@honkio/web-push';

button.addEventListener('click', async () => {
  if (!isSupported()) return;
  await subscribe({
    vapidPublicKey: 'BEl62iUY...',      // your push app's public key
    serviceWorker: '/sw.js',
    sendSubscription: (subscription) =>
      fetch('/api/push-subscriptions', {   // your backend, not HonkIO
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(subscription),
      }),
  });
});

Safari and Firefox only ask for permission after a click or a tap. On iPhone and iPad, web push works for sites added to the Home Screen (iOS and iPadOS 16.4 and later).

Browsers sometimes replace a subscription on their own. Pass onSubscriptionChange to installHonkioPush to receive the new one in your service worker and send it to your backend, which registers it again; the helper never calls honkio itself.

Register the browser

Your backend registers the subscription under the user’s id with PUT /v1/push/devices, platform web and subscription exactly as the browser gave it. A push to that user_id then reaches their browsers as well as their phones, and so does a Notify push. As for a phone, the key needs the push_devices permission (w to register, d to remove); a browser sends no encryption_public_key.

// Your backend, with the subscription your page sent it (@honkio/web-push).
const { data: device, error } = await honkio.push.devices.register({
  appId: 'APP_ID',
  platform: 'web',
  userId: 'user_1042',
  subscription: {
    endpoint: 'https://fcm.googleapis.com/fcm/send/SUBSCRIPTION_ID',
    expirationTime: null,
    keys: { p256dh: 'BROWSER_P256DH_KEY', auth: 'BROWSER_AUTH_SECRET' },
  },
})
if (error) throw new Error(error.message)
console.log(device.id, device.platform)

honkio only sends to the browser vendors’ push services, over HTTPS. A subscription whose endpoint is anywhere else is refused with 422 VALIDATION_ERROR, and the endpoint is checked again before each send.

Push serviceEndpoint host
Google (Chrome, Edge on Android, Opera, Samsung Internet)fcm.googleapis.com
Mozilla (Firefox)*.push.services.mozilla.com
Apple (Safari)web.push.apple.com
Microsoft (Edge on Windows)*.notify.windows.com

The endpoint is never returned by the API: a browser shows up in GET /v1/push/devices with platform web, like any other device. Unsubscribe in the browser with unsubscribe(), then remove the device with DELETE /v1/push/devices/:id, so keep the device id the registration answers next to the endpoint. Your own routes that take a subscription from the browser should require the signed-in session, register under that user, and change or remove only a subscription that user registered.

Delivery and pricing

Each browser is one delivery, with provider webpush. What the push service answers decides what happens:

Push service answerWhat honkio does
201Accepted. Counted and billed like an app push. Only 201 counts: any other 2xx answer is treated as not delivered (PROVIDER_ERROR) and refunded.
404, 410The subscription is gone (the user unsubscribed, or it expired). The browser is retired with INVALID_TOKEN, push_device.deactivated fires and the delivery is refunded.
413Too large. The delivery fails with PAYLOAD_REJECTED, at once and with no retry. A browser takes up to about 3,990 bytes of title, body and data; a send that would not fit is refused with 422 PUSH_PAYLOAD_TOO_LARGE before anything is charged.
429, 5xxBusy. Retried within the push retry window, as for Apple and Google, waiting for the push service’s Retry-After when it gives one; a Retry-After past the window ends the delivery with PROVIDER_UNAVAILABLE.
401, 403The push service refused the signature (a subscription made with another key, or a refused subject). The delivery fails with CREDENTIAL_INVALID and is refunded, and the browser is kept. A browser refused five times in a row, over at least a day, is retired with VAPID_KEY_MISMATCH and push_device.deactivated fires; an accepted push in between starts the count again.

Web push costs the same as app push: browser deliveries count against the same 2,500 free pushes a month, then $5.00 per 1,000 accepted. A delivery that is not accepted is refunded.

In test mode nothing goes to a push service: the last part of the endpoint is the magic token, so a test subscription to https://fcm.googleapis.com/fcm/send/test_unregistered behaves like the test_unregistered device token.

Data-only pushes

A browser shows every push it receives: there is no silent background push on the web. A push with only data would make the browser show a notice of its own, and Safari revokes subscriptions that receive pushes without a notification. So honkio never sends a data-only push to a browser: a send to a user_id leaves that user’s browsers out (they are neither sent to nor charged), and a send to one browser’s device_id without a title or body is refused with 422 VALIDATION_ERROR.

Encryption

Every web push is encrypted end to end to the browser (RFC 8291): the push service carries content it cannot read, and only the browser that subscribed can decrypt it. honkio’s per-device encryption for iOS and Android (the key the HonkIO push package makes on each phone) isn’t used for browsers, because it isn’t needed: a browser registers no device key, and an app that requires encryption still sends to its browsers.

The push services still see the endpoint, the time of each push, its size, and the TTL, urgency and topic. The topic is always a keyed hash of your collapse key, never the key itself: the push service can’t work the key back out or confirm a guess, and the same key gives a different topic in each of your apps. The browser gets the key inside the encrypted payload. They hold a push for an offline browser until it is delivered or expires.