Manual contentsDigging DeeperBrowse 103 chapters
Manual 16 min read

Web Push

Web Push delivers a short message to a browser even when your site is closed - the Service Worker wakes up, decrypts the payload, and shows an OS-level notification. Suprnova ships the protocol end-to-end: VAPID key generation, AES128GCM payload encryption, the HTTP transport, and a WebPushChannel that plugs into the notifications subsystem so the same Notification you send to mail or database also lands as a push.

Reach for this when you want to alert users in real time without an open WebSocket - order shipped, friend request, mention, balance posted. If the user is on a desktop browser with the site closed, web push is the only mechanism that reaches them; if they're on the site, Broadcasting is usually a better fit.

The API is behind the web-push Cargo feature, which is enabled by default. Applications using default-features = false must enable web-push explicitly.

The four pieces

Web Push has more moving parts than mail or database, because the spec (RFC 8030 + RFC 8291 + RFC 8292) splits identity, encryption, and transport across three contracts:

Piece What it is
VapidKey / VapidSigner A P-256 ECDSA keypair used to sign JWTs that prove your server is who it claims to be
WebPushClient The HTTP client that encrypts a payload, signs a VAPID JWT, and POSTs to the subscription's endpoint
WebPushChannel The notifications-subsystem adapter that turns a Notification into a WebPushClient::send call
SubscriptionInfo The opaque (endpoint, p256dh, auth) triple the browser hands you when a user subscribes - you store it; you don't generate it

The bottom three layers - VapidKey, WebPushClient, the encrypted POST - are re-exported from suprnova::web_push so applications never need to depend on the underlying suprnova-web-push crate directly.

Generate a VAPID keypair

Web Push uses VAPID (Voluntary Application Server Identification) to let push services rate-limit and contact misbehaving senders. You need one P-256 keypair per application; the public key goes into your frontend so the browser can pin subscriptions to your server, and the private key stays on the server signing JWTs.

Generate one once, persist it, and reuse it forever:

use suprnova::VapidKey;

let key = VapidKey::generate();

// Save the PEM somewhere durable - a secrets manager, a file the deploy
// pipeline mounts, an env-vars-as-files volume. You CANNOT regenerate
// this without invalidating every existing subscription.
let pem = key.to_pem()?;
std::fs::write("vapid_private.pem", &pem)?;

// The frontend needs the base64url-no-padding uncompressed public key.
// Hand this to your JS so `pushManager.subscribe()` can use it as
// `applicationServerKey`.
println!("PUBLIC_VAPID_KEY={}", key.public_key_uncompressed_b64url());

At boot, load the saved PEM:

use suprnova::{VapidKey, VapidSigner};

let pem = std::fs::read_to_string("vapid_private.pem")?;
let key = VapidKey::from_pem(&pem)?;
let signer = VapidSigner::new(key);

A VapidSigner produces JWTs but does not send anything - it's purely a signing primitive. The next layer wraps it.

Build a WebPushClient

WebPushClient is the HTTP-side primitive: feed it a signer and a contact URI ("how the push service can reach you if you misbehave"), get back an object whose send method encrypts a payload, signs a JWT, and POSTs to the subscription endpoint.

use std::sync::Arc;
use suprnova::{VapidKey, VapidSigner, WebPushClient};

let signer = VapidSigner::new(VapidKey::from_pem(&pem)?);

// The subject MUST be a mailto: URI or an https: URL per RFC 8292 §2.1.
// Anything else is rejected at construction so a misconfigured deploy
// fails fast at boot - not silently after the first failed dispatch.
let client = WebPushClient::new(signer, "mailto:ops@example.org")?;

let client = Arc::new(client);

Why Arc<WebPushClient>? WebPushClient wraps a VapidSigner which wraps a private ES256KeyPair. None of those are Clone - private keys shouldn't be casually duplicated - and constructing a fresh signer for every channel registration would mean N independent VAPID identities for the same application. Wrapping in Arc lets a single signed identity back every registration and every concurrent delivery.

Endpoint policy

Subscription endpoints are user-derived data: the browser receives the URL from a remote push service when a user subscribes, and your server stores whatever the browser handed back. A maliciously stored subscription can point the HTTP POST anywhere reachable, turning the push sender into an SSRF gadget.

WebPushClient defaults to EndpointPolicy::Strict:

  • Scheme must be https
  • Host must be a named domain, not an IP literal
  • Cloud-metadata hostnames and RFC 2606 reserved TLDs (.localhost, .local, .internal, .test, .example, .invalid) are rejected

This blocks the obvious SSRF probes without breaking real push services (FCM, Mozilla Autopush, Apple's web.push.apple.com).

For local integration tests against a wiremock mock server you have to opt out:

use suprnova::{EndpointPolicy, WebPushClient};

let client = WebPushClient::new(signer, "mailto:test@example.org")?
    .with_endpoint_policy(EndpointPolicy::AllowAny);

Do not use AllowAny in production. The strict checks exist to keep a tampered subscriptions table from being weaponised.

Custom transport

WebPushClient::new applies a 30-second per-request timeout. If you need a different transport policy - corporate proxy, pinned TLS, shorter timeout - build a reqwest::Client and use WebPushClient::with_client:

use reqwest::Client;
use std::time::Duration;
use suprnova::WebPushClient;

let http = Client::builder()
    .timeout(Duration::from_secs(10))
    .build()?;

let client = WebPushClient::with_client(http, signer, "mailto:ops@example.org")?;

Wire WebPushChannel into notifications

The raw WebPushClient::send works - but the way you actually send push notifications in Suprnova is through the Notifications subsystem. A Notification declares vec!["webpush"] in its channels(), a Notifiable recipient returns a JSON-encoded SubscriptionInfo from route_for("webpush"), and the bound NotificationDispatcher does the fan-out.

use std::sync::Arc;
use suprnova::{
    NotificationDispatcher, WebPushChannel, WebPushClient,
    notifications::set_dispatcher,
};

let client: Arc<WebPushClient> = Arc::new(
    WebPushClient::new(signer, "mailto:ops@example.org")?
);

// ttl_secs: how long the push service holds an undelivered message.
// 86_400 (24h) is a reasonable default for non-urgent notifications;
// drop to 60 for "act right now" alerts where a stale message is
// worse than no message.
let webpush = Arc::new(WebPushChannel::new(client, 86_400));

let dispatcher = NotificationDispatcher::new()
    .register_channel(webpush);

set_dispatcher(Arc::new(dispatcher))?;

register_channel is last-write-wins on the channel's name(), so tests can swap in a stub without affecting the production binding.

Define a notification

A push-bound notification is the same shape as any other Suprnova notification - declare "webpush" in channels() and put whatever JSON you want delivered into data():

use serde::{Deserialize, Serialize};
use suprnova::Notification;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OrderShipped {
    pub order_id: i64,
    pub tracking_url: String,
}

impl Notification for OrderShipped {
    fn notification_name() -> &'static str {
        "OrderShipped"
    }

    fn channels(&self) -> Vec<&'static str> {
        vec!["webpush"]
    }

    fn data(&self) -> serde_json::Value {
        serde_json::json!({
            "title":   "Your order has shipped",
            "body":    format!("Track order #{}", self.order_id),
            "url":     self.tracking_url,
        })
    }
}

The data() JSON is what your Service Worker receives. Pick a stable shape and document it for the frontend - Suprnova doesn't impose one, because notification UI is a frontend concern.

Route the recipient

A Notifiable returns the route for each channel it supports. For Web Push, that route is the JSON-encoded SubscriptionInfo - exactly what the browser produced via PushSubscription.toJSON(), stored verbatim:

use suprnova::Notifiable;

pub struct User {
    pub id: i64,
    pub push_subscription_json: Option<String>,
}

impl Notifiable for User {
    fn route_for(&self, channel: &str) -> Option<String> {
        match channel {
            "webpush" => self.push_subscription_json.clone(),
            _ => None,
        }
    }
}

Returning None causes the dispatcher to skip the channel silently - useful for users who haven't subscribed to push but still get email.

Send it

Synchronous:

use suprnova::Notify;

let user = User::find(42).await?.unwrap();
Notify::send(&user, &OrderShipped {
    order_id: 1234,
    tracking_url: "https://ship.example.org/o/1234".into(),
}).await?;

Queued - pre-resolves the subscription route at queue time so the worker doesn't need to re-load the user:

Notify::queue(&user, OrderShipped {
    order_id: 1234,
    tracking_url: "https://ship.example.org/o/1234".into(),
}).await?;

For Notify::queue to work, register the notification's factory at boot so the worker can rebuild the JSON payload into the typed notification:

suprnova::notifications::register_notification_factory::<OrderShipped>()?;
suprnova::queue::worker::register_job::<suprnova::SendNotificationJob>();

Behind the scenes, queued dispatch builds a SendNotificationJob carrying (notification_name, payload, per_channel_routes, channels). The worker re-hydrates the notification, looks up WebPushChannel by name on the bound dispatcher, and calls deliver(route, &notification) - the same code path as the synchronous Notify::send.

The browser side

Suprnova does not ship a JavaScript SDK - the browser side is plain Web Push API. The flow your frontend needs to implement:

  1. Register a Service Worker.
  2. Ask the user for permission.
  3. Subscribe via pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: <your VAPID public key> }).
  4. POST subscription.toJSON() to a Suprnova endpoint that stores it on the user row.
// Service Worker registration (somewhere in your app entrypoint)
const registration = await navigator.serviceWorker.register('/sw.js');

if (Notification.permission === 'default') {
    await Notification.requestPermission();
}

if (Notification.permission === 'granted') {
    const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: window.PUBLIC_VAPID_KEY,
    });

    await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription.toJSON()),
    });
}

Your Suprnova endpoint receives the JSON, validates the shape, and stores it on the user - the string is opaque to your server, but it must be the exact JSON the browser produced (the SubscriptionInfo type uses Deserialize to parse it later):

use suprnova::{Auth, Request, Response, SubscriptionInfo, attrs, json_response};

pub async fn subscribe(req: Request) -> Response {
    let user_id = Auth::id().expect("auth middleware");

    let (_parts, bytes) = match req.body_bytes().await {
        Ok(b) => b,
        Err(e) => return json_response!({ "error": e.to_string() }).map(|r| r.status(400)),
    };
    let raw = match std::str::from_utf8(&bytes) {
        Ok(s) => s.to_string(),
        Err(_) => return json_response!({ "error": "body not utf-8" }).map(|r| r.status(400)),
    };

    // Parse to validate the shape - endpoint, keys.p256dh, keys.auth.
    // If parsing fails, the browser handed us something malformed.
    let sub: SubscriptionInfo = match serde_json::from_str(&raw) {
        Ok(s) => s,
        Err(e) => return json_response!({ "error": e.to_string() }).map(|r| r.status(400)),
    };

    // Persist `raw` verbatim - that's the exact string WebPushChannel
    // will hand to serde_json::from_str on dispatch.
    User::query()
        .db_where_op("id", "=", user_id)
        .update_all(attrs! { push_subscription_json: raw })
        .await
        .unwrap();

    json_response!({ "ok": true, "endpoint": sub.endpoint })
}

The Service Worker decrypts the push payload and renders the notification:

// /sw.js
self.addEventListener('push', (event) => {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            data: { url: data.url },
        }),
    );
});

self.addEventListener('notificationclick', (event) => {
    event.notification.close();
    event.waitUntil(clients.openWindow(event.notification.data.url));
});

Payload limits

The Web Push spec caps each encrypted payload at 4096 bytes total. Suprnova rejects plaintexts larger than 3992 bytes (the cap minus the ~85-byte AES128GCM encryption overhead) at encrypt time so the failure surfaces in your code, not in a 413 from the push service. A Notification whose serialized data() exceeds that limit returns WebPushError::Encryption from the channel's deliver.

For anything larger - a long message body, a thumbnail - send a short notification carrying a URL the Service Worker fetches on click. That's both faster (no encryption on a multi-KB payload) and more flexible (the fetch can return whatever shape you want).

Dead subscriptions

When the push service returns 404 or 410, the subscription is dead - the user uninstalled the browser, revoked the permission, or cleared storage. WebPushChannel treats this as a non-fatal warn:

WARN webpush subscription gone (404/410); caller should remove
     channel=webpush endpoint=https://fcm.googleapis.com/fcm/send/abc

Dispatch returns Ok(()) because the notification reached a terminal state - there's no recipient to retry against. Your application is expected to act on the warn: parse endpoint from the log (or hook a NotificationFailed listener that classifies via WebPushError) and remove the subscription row. Suprnova ships the warn; it does not auto-prune the subscriptions table for you.

Retries and Retry-After

When the push service returns a transient 5xx, 408, or 429, the underlying WebPushError::PushServiceRejected carries the parsed Retry-After hint (delta-seconds form only - HTTP-date form returns None):

use suprnova::WebPushError;

match client.send(&sub, payload, ContentEncoding::Aes128Gcm, 60).await {
    Ok(_) => (),
    Err(e) if e.is_retryable() => {
        let wait = e.retry_after().unwrap_or(Duration::from_secs(30));
        tokio::time::sleep(wait).await;
        // ...try again, or push back onto the queue with a delay
    }
    Err(WebPushError::SubscriptionGone) => {
        // remove the subscription
    }
    Err(e) => return Err(e.into()),
}

The Retry-After hint is capped at 24 hours so a hostile server can't park a worker on a multi-year sleep.

When using Notify::queue, the queue's own retry/backoff applies - a WebPushError that propagates out of WebPushChannel::deliver surfaces as a job error and the envelope handles re-queueing per the job's backoff policy. The Retry-After hint is logged but not (yet) fed back into the queue's delay computation; if you need that, hook a NotificationFailed listener that re-queues with the hinted delay.

Telemetry

The notifications dispatcher wraps the fan-out in a notification.dispatch info span tagged with the notification name and channel count. Each successful delivery emits a NotificationSent event; failures emit NotificationFailed carrying the channel name, route, and error string. Wire any of those into your metrics/log pipeline the same way you wire other framework events - see Events.

A dead subscription emits a structured WARN with channel="webpush", the endpoint, and the notification name. That's the signal to scrape for an automated subscription cleanup job.

Why Suprnova diverges

Laravel's WebPush driver is a community package (laravel-notification-channels/webpush) - not in core, separately versioned, opinionated about ORM. Suprnova bakes Web Push into the framework because the protocol is well-defined and the encrypted HTTP POST is too small a contract to wrap in a third-party abstraction. The notifications subsystem keeps the surface uniform: the same Notification you send to mail or database also lands as a push, no driver matrix, no separate config tree.

We also surface the strict-endpoint policy by default. The Laravel community package leaves SSRF protection to the application; we take the position that "the endpoint came from user data" is the shape of every Web Push subscription, and the safe default belongs in the framework, not in your code.

The retry classification (is_retryable, retry_after) is exposed as typed methods on WebPushError rather than as a magic constant table in the queue layer. The queue still owns retry policy - the error tells you whether a retry could succeed and how long to wait; the queue decides whether and when to dequeue again. Separating the two means your custom retry strategies (exponential backoff, jittered, capped) don't have to special-case Web Push.

Testing

Stand up a wiremock server, point a WebPushClient at it with EndpointPolicy::AllowAny, and assert on the requests it receives:

use std::sync::Arc;
use suprnova::{
    EndpointPolicy, NotificationDispatcher, Notify, VapidKey, VapidSigner,
    WebPushChannel, WebPushClient,
    notifications::set_dispatcher,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn order_shipped_pushes() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/push"))
        .respond_with(ResponseTemplate::new(201))
        .mount(&server)
        .await;

    let signer = VapidSigner::new(VapidKey::generate());
    let client = Arc::new(
        WebPushClient::new(signer, "mailto:test@example.org")
            .unwrap()
            .with_endpoint_policy(EndpointPolicy::AllowAny),
    );
    let channel = Arc::new(WebPushChannel::new(client, 60));

    let dispatcher = NotificationDispatcher::new().register_channel(channel);
    set_dispatcher(Arc::new(dispatcher)).unwrap();

    let user = test_user_with_subscription(&server.uri()).await;
    Notify::send(&user, &OrderShipped {
        order_id: 1,
        tracking_url: "https://ship.example.org/o/1".into(),
    }).await.unwrap();
    // server.received_requests() now contains the encrypted POST.
}

For end-to-end tests that don't care about the encrypted bytes, Notify::fake() (covered in Notifications) captures the dispatch without running the channel - faster, no mock server, no encryption round-trip.

Reference

  • Primitives: suprnova::VapidKey, suprnova::VapidSigner, suprnova::VapidClaims
  • Client: suprnova::WebPushClient, suprnova::EndpointPolicy, suprnova::PushResponse, suprnova::SubscriptionInfo
  • Error: suprnova::WebPushError - .is_retryable(), .retry_after(), WebPushError::SubscriptionGone
  • Encoding: suprnova::ContentEncoding (Aes128Gcm; 3992-byte plaintext cap)
  • Channel: suprnova::WebPushChannel
  • Facade: suprnova::Notify
  • Queue job: suprnova::SendNotificationJob
  • Factory registration: suprnova::notifications::register_notification_factory

Next

  • Notifications - the multi-channel dispatcher that WebPushChannel plugs into
  • Mail - the email-channel counterpart for users without push
  • Broadcasting - real-time delivery for users who are on the site
  • Queues - how Notify::queue backs SendNotificationJob
  • Events - listening for NotificationSent / NotificationFailed to drive dead-subscription cleanup