WebPush end-to-end — from the server to the browser 🔔¶
The PWA install + WebPush example covered the browser side: asking for permission and creating the push subscription. But a subscription alone does nothing — someone has to actually send the notification. That someone is your server, holding a VAPID key that proves to the browser's push service that the send is legitimate.
This page closes the loop: you generate the keys, mount a ready-made FastAPI
router, the browser subscribes against your public key, and the server fires the
notification. All with the pieces in tempestweb.server.
What you'll build¶
A minimal FastAPI app that:
- Generates a VAPID keypair (once) and reads it from the environment.
- Mounts the
webpush_router— it already exposes the subscribe/send endpoints. - Subscribes the browser against the public key and stores the subscription.
- Sends a notification to every stored subscription.
sequenceDiagram
participant B as Browser
participant S as FastAPI server
participant P as Push service
Note over S: 1. VAPID keys (env)
B->>S: GET /webpush/vapid-public-key
S-->>B: { public_key }
Note over B: 2. pushManager.subscribe(public_key)
B->>S: POST /webpush/subscribe (subscription JSON)
Note over B,S: 3. subscription stored under "owner"
B->>S: POST /webpush/send { title, body }
S->>P: signed send (VAPID)
P-->>B: notification delivered
S-->>B: { sent, total }
Who does what
The server owns the VAPID keys, the subscription store and the send path.
The browser owns the subscribe flow (service worker + PushManager). The
push service (Google, Mozilla, Apple…) is the intermediary that actually
delivers the message to the device.
Prerequisites¶
Server-side WebPush needs the [webpush] extra (it brings cryptography for the
keys and pywebpush for sending):
Step 1 — Generate the VAPID keys 🔑¶
VAPID (Voluntary Application Server Identification) is a P-256 keypair. The public key goes to the browser; the private key signs every send and stays on the server. Generate a pair with the CLI:
public_key: BEl62iUYgUiv...kr3qBUYIHBQFLXYp5Nksh8U
private_key: 3Kw...redacted...s0
Keep the private key secret (export as VAPID_PRIVATE_KEY); share the public key with the browser client.
To get lines ready to export as environment variables, use --env:
You can load them straight into your shell:
The private key is a secret ⚠️
Never commit the private key. Treat it like any credential: pass it through an
environment variable (VAPID_PRIVATE_KEY), a secret manager or a .env kept
out of version control. Anyone holding that key can send push on your app's
behalf.
Generating the keys in code
The CLI is a shortcut over generate_vapid_keys(). You can call it directly —
for example, in a setup script:
from tempestweb.server import generate_vapid_keys
keys = generate_vapid_keys()
print(keys.public_key) # base64url, unpadded
print(keys.private_key) # base64url, unpadded — keep it secret
VapidKeys has just two fields: .public_key and .private_key.
Step 2 — Mount the webpush_router 🚏¶
webpush_router(service) returns a ready APIRouter. Include it on your FastAPI
app and you get four JSON endpoints for free:
| Method | Route | Body | Response |
|---|---|---|---|
GET |
/webpush/vapid-public-key |
— | {"public_key": ...} |
POST |
/webpush/subscribe |
the browser subscription | {"ok": true}, or 400 without endpoint |
POST |
/webpush/unsubscribe |
{"endpoint": ...} |
{"removed": true} / {"removed": false}, or 400 without endpoint |
POST |
/webpush/send |
payload ({"title","body"}) |
{"sent": N, "total": M} |
A malformed body answers 400, and removal is scoped to the owner
Two router rules worth knowing before you write the client:
- A body with no
endpointis the caller's mistake. Bothsubscribeandunsubscribeanswer 400 naming the missing field.unsubscribeused to answer{"removed": false}to an empty body — the same answer as "that subscription was already gone", so the client bug stayed invisible. unsubscribeonly removes what the router'sownerholds. The store is keyed byendpointalone, and the signature invites two routers over one service (webpush_router(SERVICE, owner="alice", prefix="/webpush/alice")and the same for"bob"): unscoped, aPOST /webpush/alice/unsubscribecarrying bob's endpoint deleted bob's subscription and answered{"removed": true}. An endpoint thisownerdoes not hold now answers{"removed": false}— the same answer as one already gone, so the route never reveals that anotherownerholds it.
Here's the complete app — copy and run it:
from __future__ import annotations
from fastapi import FastAPI
from tempestweb.server import (
InMemorySubscriptionStore,
VapidConfig,
WebPushService,
generate_vapid_keys,
webpush_router,
)
def _vapid() -> VapidConfig:
"""Resolve the VAPID config from the env, or an ephemeral dev keypair."""
config = VapidConfig.from_env() # reads VAPID_PUBLIC_KEY / _PRIVATE_KEY / _SUBJECT
if config.enabled:
return config
keys = generate_vapid_keys()
return VapidConfig(public_key=keys.public_key, private_key=keys.private_key)
VAPID = _vapid()
SERVICE = WebPushService(VAPID, store=InMemorySubscriptionStore())
app = FastAPI(title="my app with webpush")
app.include_router(webpush_router(SERVICE))
Piece by piece:
VapidConfig.from_env()readsVAPID_PUBLIC_KEY,VAPID_PRIVATE_KEYandVAPID_SUBJECTfrom the environment. If you exported the keys in Step 1, this is where they land.WebPushService(vapid, store=...)ties the VAPID config to a subscription store.InMemorySubscriptionStorecovers dev and tests; in production you supply your own (SQLAlchemy, Redis…) implementing theSubscriptionStoreprotocol.WebPushService(vapid, ..., timeout=10.0)bounds each HTTP send, in seconds. The 10 s default is deliberate:pywebpushdeclarestimeout=Noneand forwards thatNonetorequests.post, so without a value here an endpoint that accepts the TCP connection and never answers hangs the send forever. Tune it for your push service — FCM answered in ~1.0 s in the device measurement, which leaves 10x of headroom.webpush_router(SERVICE)builds the router andinclude_routerplugs it into the app.
POST /webpush/send does not block the event loop 💡
The pywebpush send is blocking (it posts with requests), and the
route is async on the same loop that serves the WebSocket patch stream.
That is why the router runs the fan-out in a worker thread. Measured with a
1 s sender and three subscriptions: called inline, the request took 3.00 s
and a 10 ms heartbeat on the same loop got zero ticks — every connected
app frozen for the whole send. Off the loop, the same 3.01 s request and a
heartbeat with 296 ticks, worst lateness 0.01 s.
An empty private key disables sending 💡
If VAPID_PRIVATE_KEY is empty, VapidConfig.enabled is False and every
send becomes a no-op that reports ok=False (no network dependency is
touched). It's the same "empty secret disables auth" pattern as the rest of the
framework — great for running in dev with zero config. In the app above we
generate an ephemeral pair in that case, so subscriptions simply reset on
each restart.
A single owner keeps the router simple
The router files every subscription under one owner (default "default"),
so it's multi-tenant-free by design. An app with real users writes its own
routes around the same WebPushService, resolving the owner from auth.
The building blocks are reusable; only the "who owns this subscription" policy
changes.
Step 3 — The browser subscribes and sends the subscription 📮¶
In the browser the flow is: register a service worker, ask for permission, fetch
the server's public key, call pushManager.subscribe(...) and POST the
resulting subscription to /webpush/subscribe.
const reg = await navigator.serviceWorker.register("/sw.js", { type: "classic" });
await navigator.serviceWorker.ready;
const perm = await Notification.requestPermission();
if (perm !== "granted") throw new Error("permission: " + perm);
// 1. get the server's public key
const { public_key } = await (await fetch("/webpush/vapid-public-key")).json();
// 2. subscribe against it (b64ToU8 turns the base64url key into a Uint8Array)
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64ToU8(public_key),
});
// 3. send the subscription for the server to store
await fetch("/webpush/subscribe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
On the Python side, use the native module
In a tempestweb app you don't write this JS by hand. The
transpile guide shows
native.notifications.subscribe(vapid_public_key) — it returns exactly the
subscription JSON you POST to /webpush/subscribe — and
native.notifications.push_state(), which reports {supported, permission}
without triggering the prompt. The JS block above is what runs underneath.
Step 4 — The server fires the notification 🚀¶
With at least one subscription stored, a POST to /webpush/send pushes the
payload to every subscription of the owner:
curl -X POST http://127.0.0.1:8000/webpush/send \
-H "content-type: application/json" \
-d '{"title": "Hello", "body": "from tempestweb"}'
sent is how many the push service accepted; total is how many subscriptions
the owner had. A dead endpoint — the push service replies 410 Gone or
404 Not Found — is pruned from the store on that very send, so the next send
does not even try it.
What counts as a dead endpoint (and what does not)
Only 410 and 404. A 403 is the push service refusing the VAPID
signature, not the user's subscription: measured against FCM with a rotated
key, the subscription was still good, and pruning it would drop a live
subscriber over a server-side key mistake.
Grouping 404 with 410 carries a known risk: a proxy or a rewrite in front
of the endpoint answering its own 404 (wrong path, not a dead
subscription) makes the prune drop a live subscriber, who then has to
subscribe again. The trade is deliberate — a stale row costs every send
forever, a re-subscribe costs one prompt.
And a store that fails while pruning (the database connection dropped)
does not cancel the batch: the dead endpoint is reported with gone=True and
the live subscriptions of the same send_to_owner still get their push.
Sending from inside your own code
The router is a thin shell over WebPushService. From anywhere in your app —
a background task, an event handler — you call the service directly:
outcomes = SERVICE.send_to_owner("default", {"title": "Hello", "body": "world"})
for outcome in outcomes:
print(outcome.endpoint, outcome.ok, outcome.status_code)
send_to_owner returns a list of SendOutcome (one per subscription, []
when the owner has none). There's also broadcast(payload) to reach every
stored subscription, and send(subscription, payload) for a single one.
outcome.status_code is the status the push service answered: 200,
201 or 202 on an accepted send, 410/404/403 on a rejection, and
None when there was no response to read — an injected sender that returns
nothing, or pywebpush.webpush(curl=True), which returns a str. It is no
longer the constant 201: an accepted send used to report 201 even when
nobody had answered that.
The full example, running ▶¶
A ready-made app lives at examples/webpush-server/server.py: it resolves VAPID
from the environment (falling back to an ephemeral dev keypair), mounts the
webpush_router and serves a demo page with a minimal push service worker.
# optional: pin a keypair so subscriptions survive restarts
eval "$(tempestweb vapid --env)"
uv run uvicorn server:app --app-dir examples/webpush-server --reload
Open http://127.0.0.1:8000, click Enable notifications (grant permission), then Send test — a system notification appears, delivered by the browser's push service from the server's signed send.
Real delivery needs HTTPS + permission + (on iOS) the app installed
Real push delivery does not work in every context:
- The page must be served over HTTPS (or
localhostin dev). - The user must have granted notification permission.
- On iOS (16.4+), WebPush only works with the PWA installed to the home screen — see the PWA install example to generate the installable manifest.
What's automatable — and what isn't ✅¶
Be honest about what the tests cover:
- ✅ The server path is unit-tested. Key generation, the router
(subscribe/unsubscribe/send) and the
WebPushService(with an injected sender) all have green tests — no network touched. - ⚠️ The browser subscribe/permission + real push delivery are device-, gesture- and external-service-dependent. That is not automatable in CI and needs manual verification in a real browser.
No false promises
This example does not claim automated end-to-end delivery. The server signs and dispatches the request; from there, the push service and the device decide whether and when the notification appears.
Recap¶
In this guide you:
- ✅ Generated a VAPID keypair with
tempestweb vapid --env(and sawgenerate_vapid_keys()underneath). - ✅ Mounted the
webpush_routeron a FastAPI app withapp.include_router(...). - ✅ Learned the four endpoints:
vapid-public-key,subscribe,unsubscribe(scoped to theowner, 400 withoutendpoint) andsend. - ✅ Subscribed the browser against the public key and sent the subscription for the server to store.
- ✅ Fired a notification with
POST /webpush/send(and sawsend_to_owner/broadcaston the service, running off the event loop). - ✅ Read what a send reports:
status_codeis the real status (orNonewhen there is no response),410/404prune the dead subscription,403does not, andtimeout=bounds each send. - ✅ Understood that the private key is a secret, that an empty key disables sending, and that real delivery needs HTTPS + permission + (iOS) the app installed.
Next steps¶
- 💡 Go back to PWA install + WebPush for the browser-side
consent flow (permission +
subscribe) written in pure Python. - 💡 Swap
InMemorySubscriptionStorefor a persistentSubscriptionStoreimplementation (SQLAlchemy, Redis) and resolve theownerfrom your auth. - 💡 Read the PWA docs (Track P) for the service worker (P1) and the offline-first mode (P2).