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 VapidKey;
let key = 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?;
write?;
// The frontend needs the base64url-no-padding uncompressed public key.
// Hand this to your JS so `pushManager.subscribe()` can use it as
// `applicationServerKey`.
println!;
At boot, load the saved PEM:
use ;
let pem = read_to_string?;
let key = from_pem?;
let signer = new;
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 Arc;
use ;
let signer = new;
// 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 = new?;
let client = new;
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 ;
let client = new?
.with_endpoint_policy;
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 Client;
use Duration;
use WebPushClient;
let client = with_client_builder?;
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 Arc;
use ;
let client: = new;
// 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 = new;
let dispatcher = new
.register_channel;
set_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 ;
use Notification;
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 Notifiable;
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 Notify;
let user = find.await?.unwrap;
send.await?;
Queued - pre-resolves the subscription route at queue time so the worker doesn't need to re-load the user:
queue.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:
?;
;
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, ¬ification) -
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:
- Register a Service Worker.
- Ask the user for permission.
- Subscribe via
pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: <your VAPID public key> }). - 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..;
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 ;
pub async
The Service Worker decrypts the push payload and renders the notification:
// /sw.js
self.;
self.;
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 ;
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
Strictendpoint policy refuses it because it is nothttps, 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.localor.internal). - The keys.
p256dhis no base64url, does not decode to 65 bytes, is not in uncompressed form or is no point of the P-256 curve.authis 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.await
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 WebPushError;
match client.send.await
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 Arc;
use ;
use ;
use ;
async
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
WebPushChannelplugs into - Mail - the email-channel counterpart for users without push
- Broadcasting - real-time delivery for users who are on the site
- Queues - how
Notify::queuebacksSendNotificationJob - Events - listening for
NotificationSent/NotificationFailedto watch deliveries
