Skip to content
Developers · 9 min read

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.

Updated By the Pusharoo team
In short
  • 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 browser

Generate 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), 201

The 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 Topic header 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

MessageTTLUrgency
Breaking news1–4 hourshigh
Flash sale ending tonightUntil the sale endsnormal
Weekly digest1–3 dayslow
Transactional (“order shipped”)1 daynormal

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.

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.