Manual contentsDigging DeeperBrowse 113 chapters
Manual 19 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 - pass a reqwest::ClientBuilder to WebPushClient::with_client_builder. Every builder option is honoured, but the redirect policy is forcibly disabled: a validated endpoint that answers 3xx must not bounce the POST to an unvalidated URL, so the library does not accept the caller's redirect setting.

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

let client = WebPushClient::with_client_builder(
    Client::builder().timeout(Duration::from_secs(10)),
    signer,
    "mailto:ops@example.org",
)?;

WebPushClient::with_client takes an already-built client whose redirect policy the library cannot inspect. Sends under the default Strict policy are refused for such a transport before any I/O - switch to with_client_builder, or explicitly accept the risk with .allow_unconfined_redirects() when the client is known to not follow redirects.

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 fails in the channel's deliver: the client returns WebPushError::Encryption, and the channel reports it as an internal error.

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 host=fcm.googleapis.com endpoint_sha256=4e9bdab8bbe7189c

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 and remove the subscription row. Suprnova ships the warn; it does not auto-prune the subscriptions table for you.

The warning has no endpoint field. The path of an endpoint is the token that reaches the browser, so the log keeps it out. host is the host of the endpoint, and endpoint_sha256 is the first 16 hexadecimal digits of the SHA-256 of the endpoint as you stored it. To find the row, compute the same digest of each stored endpoint and compare:

use sha2::{Digest, Sha256};

fn endpoint_digest(endpoint: &str) -> String {
    hex::encode(&Sha256::digest(endpoint.as_bytes())[..8])
}

Subscriptions that cannot be used

A stored subscription can be unusable before any request leaves your server. The client then returns WebPushError::InvalidSubscription, and nothing is sent. The text of the error names the rule that refused the subscription. Two groups of checks raise it:

  • The endpoint. It is no URL, or the Strict endpoint policy refuses it because it is not https, has no host, names an IP address in place of a host, or names a host that is no push service (localhost, a cloud metadata host, or a reserved name such as .local or .internal).
  • The keys. p256dh is no base64url, does not decode to 65 bytes, is not in uncompressed form or is no point of the P-256 curve. auth is no base64url or does not decode to 16 bytes.

The stored data is wrong, so a retry cannot help. is_retryable() returns false. WebPushChannel handles the error as it handles a gone subscription: it logs a warning and dispatch returns Ok(()), so a queue does not send the job again.

WARN webpush subscription cannot be used; caller should remove
     channel=webpush host=10.0.0.7 endpoint_sha256=533bb6dc756981d0 reason=subscription endpoint host '10.0.0.7' is an IP literal; real push services use named hosts

The warning carries the reason, the host and the digest of the endpoint, as the warning for a gone subscription does, and no endpoint field.

A stored route that is no subscription at all gets the same warning: text that is no JSON, or JSON without an endpoint or without keys. The reason is the kind of the mistake and its position, such as the value is not JSON (line 1, column 1). It never quotes the route, which holds the secret of the subscription. This warning has no host and no digest, because the route has no endpoint to take them from.

Your application removes the subscription row, as it does for a gone subscription. Dispatch succeeds, so no NotificationFailed event fires; you learn of it from the log. When you call WebPushClient::send yourself, match on WebPushError::InvalidSubscription:

match client.send(&sub, payload, ContentEncoding::Aes128Gcm, 60).await {
    Ok(_) => (),
    Err(WebPushError::SubscriptionGone | WebPushError::InvalidSubscription(_)) => {
        // remove the subscription
    }
    Err(e) => return Err(e.into()),
}

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 you use Notify::queue, the queue retries the job for you. A PushServiceRejected that carries a Retry-After hint leaves WebPushChannel::deliver as a FrameworkError::RateLimited with that hint. The worker then retries the job after the hinted time instead of the delay from the job's backoff schedule. A job that the push service refused with 429 goes back when the service said it would take it, not earlier and not later. The worker caps the wait at 24 hours (queue::retry::RETRY_HINT_CEILING).

The attempt still counts against Notification::max_tries, and the job dead-letters when the attempts run out. Every other failure, including a rejection that carries no Retry-After hint, keeps the delay from Notification::backoff.

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 host and the digest of the endpoint, and the notification name. A subscription that cannot be used emits a WARN with the same fields and the reason. Neither has the endpoint. 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, WebPushError::InvalidSubscription
  • 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 watch deliveries