Web push in Python with pywebpush
Python has a mature, standards-based web push library: pywebpush, maintained under the Mozilla web-push-libs organisation. This guide builds a minimal end-to-end sender — keys, subscription storage, sending and cleanup — with Flask for the HTTP bits.
- Install pywebpush (it uses py-vapid and http_ece under the hood).
- Generate VAPID keys once with the vapid command; keep the private key secret.
- Store each subscription’s endpoint and keys; send with webpush() and a mailto: subject.
- Catch WebPushException and delete subscriptions that return 404 or 410.
1. Install and generate keys
pip install pywebpush py-vapid
vapid --gen # writes private_key.pem and public_key.pem
vapid --applicationServerKey # prints the base64url public key for the browserGenerate keys once per application and keep them stable: every subscription is bound to the public key it was created with (why VAPID keys matter). Store the private key in a secret manager or environment, not in Git.
2. Subscribe in the browser
// browser: ask on a click, subscribe, send the subscription to your server
const VAPID_PUBLIC = 'YOUR_PUBLIC_KEY_BASE64URL';
document.querySelector('#notify').addEventListener('click', async () => {
const reg = await navigator.serviceWorker.register('/sw.js');
if (await Notification.requestPermission() !== 'granted') return;
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC });
await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) });
});// sw.js — show the notification and open the link on click
self.addEventListener('push', e => {
const d = e.data ? e.data.json() : {};
e.waitUntil(self.registration.showNotification(d.title || 'Update', { body: d.body, icon: '/icon-192.png', data: { url: d.url || '/' } }));
});
self.addEventListener('notificationclick', e => {
e.notification.close();
e.waitUntil(clients.openWindow(e.notification.data.url));
});Ask on a user gesture, not on page load — browsers increasingly quiet automatic prompts (opt-in guide).
3. Store subscriptions (Flask)
from flask import Flask, request, jsonify
import sqlite3, json
app = Flask(__name__)
db = sqlite3.connect('push.db', check_same_thread=False)
db.execute('CREATE TABLE IF NOT EXISTS subs (endpoint TEXT PRIMARY KEY, body TEXT)')
@app.post('/push/subscribe')
def subscribe():
sub = request.get_json()
db.execute('INSERT OR REPLACE INTO subs VALUES (?, ?)', (sub['endpoint'], json.dumps(sub)))
db.commit()
return jsonify(ok=True), 201The endpoint is the natural primary key. Upsert on every page load where a subscription exists, so key changes are captured.
4. Send and clean up
import os, json
from pywebpush import webpush, WebPushException
def send_all(payload: dict):
for endpoint, body in db.execute('SELECT endpoint, body FROM subs').fetchall():
try:
webpush(subscription_info=json.loads(body),
data=json.dumps(payload),
vapid_private_key='private_key.pem', # path or key string
vapid_claims={'sub': 'mailto:ops@example.com'},
ttl=24 * 3600,
headers={'Urgency': 'normal'})
except WebPushException as e:
if e.response is not None and e.response.status_code in (404, 410):
db.execute('DELETE FROM subs WHERE endpoint = ?', (endpoint,))
else:
print('push failed', e)
db.commit()
send_all({'title': 'Release 2.4 is out', 'body': 'Dark mode and faster sync.', 'url': '/changelog'})Push services answer 201 when they accept a message. 404 or 410 means the subscription is gone — delete it. 413 means the payload is too large (keep it under about 4 KB), 429 means slow down. Details in the Web Push protocol.
Sending to many subscribers
- Run sends in a worker (Celery, RQ, Dramatiq) rather than in a web request.
- Use a thread pool or asyncio wrapper for concurrency; each send is one HTTPS request.
- Set a sensible TTL: short for time-sensitive alerts, longer for digests.
- Use the
Topicheader to replace an undelivered older message with a newer one. - Log status codes per push service host to spot problems with one browser.
FastAPI version of the endpoint
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Keys(BaseModel): p256dh: str; auth: str
class Sub(BaseModel): endpoint: str; keys: Keys
@app.post('/push/subscribe', status_code=201)
async def subscribe(sub: Sub):
await repo.upsert(sub.endpoint, sub.keys.p256dh, sub.keys.auth)
return {'ok': True}pywebpush’s webpush() call is blocking; in async apps run sends in a worker process or with asyncio.to_thread() so they don’t block the event loop.
Choosing TTL and urgency
| Message | TTL | Urgency |
|---|---|---|
| Breaking news | 1–4 hours | high |
| Flash sale ending tonight | Until the sale ends | normal |
| Weekly digest | 1–3 days | low |
| Transactional (“order shipped”) | 1 day | normal |
A TTL of 0 means “deliver now or never”, which drops the message for offline devices.
When not to build it
Everything above is the do-it-yourself route: you own the keys, storage, sending queue and cleanup. If what you need is marketing-style notifications to anonymous visitors in your Python app — a branded prompt, segments, schedules, A/B tests, automations, iPhone Home Screen handling — Pusharoo does that for free: add the snippet to your base template and upload sw.js. You can still call Pusharoo’s REST API from Python for transactional sends (POST /api/v1/notify) and receive signed webhooks when people subscribe, so the two approaches mix well.
Questions
Is pywebpush maintained?
It lives in the web-push-libs organisation on GitHub alongside libraries for other languages. Check the repository for the latest release before you pin a version.
Do I need Firebase to send web push from Python?
No. pywebpush sends standard VAPID-signed requests to every browser’s push service, including Chrome’s (Firebase vs. VAPID).
What payload size can I send?
Keep the encrypted payload under about 4 KB; push services reject larger ones with 413.
How do I convert a VAPID private key for pywebpush?
pywebpush accepts a path to a PEM file or the key as a string. If another tool gave you a raw base64url private key, py-vapid can load it; keep one canonical format in your secret store to avoid mismatches.
Can pywebpush send to Safari?
Yes. Safari on macOS and Home Screen web apps on iOS 16.4+ use standard Web Push at web.push.apple.com; pywebpush sends to them like any other endpoint. Use a real mailto: or https: subject.
Keep reading
Django push notifications to the browser
A Django model, CSRF-safe subscribe view, /sw.js route and a pywebpush task — or django-webpush.
The Web Push protocol, explained
RFC 8030 delivery headers, RFC 8291 encryption, RFC 8292 VAPID, status codes and payload size.
VAPID keys for web push, explained
What the key pair does, how the signed JWT works, how to generate keys and why rotation breaks lists.
Web push notifications with Node.js
Install web-push, generate keys, save subscriptions with Express, send with TTL and clean up dead endpoints.
More in Developers: The Web Push API in JavaScript: a working example · Service worker push events, done right · Web push notifications in PHP · Laravel browser push notifications (no Firebase) · Web push notifications in React and Next.js · Declarative Web Push, explained · Firebase web push vs. standard web push · Web push notifications in Ruby on Rails · Web push in Go with webpush-go · Angular push notifications with SwPush · Push notifications in Vue 3 and Nuxt · Push notifications in SvelteKit · All guides
Free for life. No limits.
Unlimited subscribers, campaigns and automations. No badges. About five minutes to install.