3 ways to send push notifications with Python

Send push from Python three ways: FCM for mobile, VAPID web push for browsers, or one notification API for both. Working code, plus how to pick the right one.

Updated Sep 11, 2026

Last updated: September 2026. Package versions verified against PyPI on 2026-09-11.

Push notifications reach users when they are not in your app, which makes them the most direct channel you have and the easiest to overuse. Python cannot deliver one on its own: every path here ends with handing your message to Apple, Google, or a browser's push service.

There are three ways to do that, and they differ mostly in how much of the fan-out you own.

Which option should you pick?

  • firebase-admin if you need mobile push and nothing else. About a dozen lines to a delivered notification, and you manage device tokens.
  • pywebpush if you need browser push. A separate protocol with its own keys and its own subscription objects, so it is a second integration rather than a variation on the first.
  • A notification API if you need both, or if push is one channel among several. This is where owning token lifecycle, per-platform payloads, and two unrelated integrations stops being worth it.

Mobile push and web push have almost nothing in common technically. If you need both, you are writing two integrations plus a fan-out layer, which is the argument for option three.

How push notifications work

A push notification is a message your server sends to a device through an intermediary the device already trusts. On iOS that is APNs, on Android it is FCM, and in a browser it is a push service run by the browser vendor. Your server never talks to the device.

Three consequences follow, and they shape every implementation:

  • The user must opt in, and on browsers a meaningful share will decline. Permission handling is not something you add later.
  • You address a token, not a person. The intermediary hands your app an opaque token per install. Tokens rotate, die on reinstall, and go stale, so a token store needs pruning or your error rate climbs quietly.
  • Delivery is best-effort. If a device is offline the intermediary holds the message briefly, then drops it. There is no guaranteed delivery and no read receipt.

1. Send mobile push with Firebase

Firebase Cloud Messaging delivers to Android directly and to iOS by forwarding to APNs, so one integration covers both platforms.

Read this before copying an older tutorial. Google retired the legacy FCM HTTP and XMPP APIs in mid-2024. Any Python example that builds a requests.post to https://fcm.googleapis.com/fcm/send with an Authorization: key=YOUR_SERVER_KEY header no longer delivers. The current API is FCM HTTP v1, which authenticates with a service account. firebase-admin handles that, which is the main reason to use it instead of writing the HTTP call.

On library choice: many tutorials point at pyfcm, a third-party wrapper. It is still published, but firebase-admin is Google's own SDK and tracks FCM's auth and payload changes directly, so that is what this guide uses.

What you need first

  • A Firebase project, and a service account JSON key from Project settings > Service accounts.
  • For iOS delivery, your APNs auth key uploaded to Firebase.
  • Python 3.9 or newer.
  • A device token collected by your client app.

Install

pip install firebase-admin

Verified against firebase-admin 7.5.0.

Send the notification

import json
import os
import firebase_admin
from firebase_admin import credentials, messaging
cred = credentials.Certificate(json.loads(os.environ["FIREBASE_SERVICE_ACCOUNT"]))
firebase_admin.initialize_app(cred)
message = messaging.Message(
token=device_token,
notification=messaging.Notification(
title="Your order shipped",
body="Track it anytime in the app.",
),
android=messaging.AndroidConfig(
notification=messaging.AndroidNotification(click_action="OPEN_ORDER"),
),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(sound="default", badge=1),
),
),
)
message_id = messaging.send(message)
print(message_id)

credentials.Certificate accepts a parsed dict as well as a file path, which is what makes this deployable to a container or a serverless runtime without shipping a key file.

The android and apns blocks exist because the two platforms take genuinely different payloads. Anything platform-specific (sounds, badges, tap actions, channel IDs) goes in its own block, and this is where the real work lives once the first send works.

Sending to many devices

messaging.send() takes one message with one token. For a user with several devices, use send_each_for_multicast with up to 500 tokens and then walk the per-token responses to prune the dead ones:

response = messaging.send_each_for_multicast(
messaging.MulticastMessage(
tokens=user_device_tokens,
notification=messaging.Notification(
title="Your order shipped",
body="Track it in the app.",
),
)
)
for token, result in zip(user_device_tokens, response.responses):
if not result.success and isinstance(
result.exception, messaging.UnregisteredError
):
# The token is dead. Delete it, or your failure rate climbs forever.
remove_token(token)

That pruning step is not optional. Skipping it is the most common reason a push integration degrades over months.

Where it gets thin: mobile only, no browser support, and you own the token store, the fan-out, the pruning, and the per-platform payload differences. Nothing here helps with user preferences or a second channel.

2. Send web push with VAPID

Browser push uses the Web Push protocol, a different standard from FCM with different keys and a different recipient shape. Chrome, Firefox, Edge, and Safari all support it.

Instead of a token you get a subscription object containing an endpoint URL and two encryption keys. You store the whole object, and payloads are encrypted so the browser vendor's push service cannot read them.

What you need first

  • A VAPID key pair, generated once and kept secret on the private side.
  • A service worker on your site that listens for push events and calls showNotification.
  • A subscription object obtained in the browser via registration.pushManager.subscribe() and posted to your backend.
  • Python 3.8 or newer.

Install

pip install pywebpush

Verified against pywebpush 2.5.0.

Send the notification

import json
import os
from pywebpush import WebPushException, webpush
subscription = get_subscription_for_user("user_123")
try:
webpush(
subscription_info=subscription,
data=json.dumps(
{"title": "Your order shipped", "body": "Track it in the app."}
),
vapid_private_key=os.environ["VAPID_PRIVATE_KEY"],
vapid_claims={"sub": "mailto:you@example.com"},
)
except WebPushException as err:
if err.response is not None and err.response.status_code in (404, 410):
# The subscription is gone. Delete it.
remove_subscription(subscription)
else:
raise

The 404 and 410 handling is the web-push equivalent of token pruning, and it matters more here because browser subscriptions expire routinely.

Note that data is an opaque string as far as pywebpush is concerned. Your service worker parses it and decides what to display, so the notification's appearance is defined in your front-end code rather than in this call.

If you are in an async framework, pywebpush also exposes webpush_async with the same parameters, which avoids blocking the event loop on the HTTP request.

Where it gets thin: browsers only, no mobile app support, and permission rates are markedly lower than on mobile.

3. Send push with a notification API

Options one and two are two separate integrations with two recipient stores, two payload formats, and two sets of pruning logic. If you need both, you also write the layer that decides which of a user's devices to target. That layer is what a notification API such as Courier replaces.

You send to a user, and it resolves the devices, renders content per platform, and records what happened.

What the extra layer gives you:

  • One call reaching every device a user has, across FCM, APNs, and Expo.
  • Token sync handled by the mobile SDKs, so your backend never stores tokens.
  • Templates edited outside your codebase, so copy changes are not deploys.
  • Per-user, per-channel preferences enforced before a send.
  • Journeys for multi-step sequences, with digest and throttling nodes so twenty events do not become twenty banners.
  • Fallback to email or SMS when push is not delivered.
  • One log across every channel.

Set it up

Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then configure a push provider on the Integrations page: Firebase FCM for Android, APNs for iOS, OneSignal for browser push, or Expo for React Native. These are the same Firebase and APNs credentials from section 1, so you are not replacing your provider.

Sync device tokens

Push goes to devices, so tokens still have to reach the platform, but the client SDK does it. Use the React Native, iOS, or Android SDK. Each signs the user in with a JWT your backend mints, then keeps FCM and APNs tokens current for that user_id. Your Python code addresses the user and never sees a token.

Install

pip install trycourier

Verified against trycourier 9.5.0, which requires Python 3.9 or newer. The distribution is trycourier but the module is courier, which is the most common first-line mistake.

Send the notification

from courier import Courier
# Reads COURIER_API_KEY from the environment.
client = Courier()
response = client.send.message(
message={
"to": {"user_id": "user_123"},
"content": {
"title": "Your order shipped",
"body": "Track it anytime in the app.",
},
"routing": {"method": "all", "channels": ["firebase-fcm", "apn"]},
"providers": {
"firebase-fcm": {
"override": {
"body": {"data": {"click_action": "https://example.com/orders"}}
}
},
"apn": {"override": {"body": {"aps": {"sound": "default", "badge": 1}}}},
},
}
)
print(response.request_id)

Three things worth knowing:

  • routing.channels takes provider keys, not a generic "push". The valid ones are firebase-fcm, apn, onesignal, and expo. Getting this wrong is the most common first-send failure.
  • method: "all" sends every listed channel. "single" tries them in order and stops at the first success, which is how you get failover.
  • Provider override blocks carry per-platform payloads, the same split you saw in the firebase-admin example.

For browser push, route the onesignal channel and store either oneSignalExternalUserId or oneSignalPlayerID on the user's profile so Courier can target the subscription:

client.send.message(
message={
"to": {"user_id": "user_123"},
"content": {"title": "New reply", "body": "You have a new message."},
"routing": {"method": "all", "channels": ["onesignal"]},
}
)

To send a template instead of inline content, replace content with "template": "YOUR_TEMPLATE_ID" and add a data object for your variables. Provider override fields such as sounds and badges are not compatible with a template on the same send.

If you need an async client, import AsyncCourier instead and await the same call.

If what you actually want is a notification feed inside your web app rather than a browser banner, Inbox renders one from the same send.

Where it gets thin: one more service in the path, and a concept (users and profiles) to learn before your first send. If you only ever need Android push, firebase-admin alone is less machinery.

Building this with an AI agent

If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Python that compiles and Python that does not.

The MCP server gives the agent typed tool calls. In Claude Code:

claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY

Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:

npx skills add trycourier/courier-skills

Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.

Then ask in plain language: "push a delivery update to every device a user has, and fall back to email." The agent picks the right primitive, and knows which channels need a recorded opt-in.

This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.

Comparing the three options

firebase-adminpywebpushNotification API
Packagefirebase-admin 7.5.0pywebpush 2.5.0trycourier 9.5.0
Reaches mobile appsyes, iOS and Androidnoyes
Reaches browsersnoyesyes, via OneSignal
Recipient shapedevice tokensubscription objectuser_id
Who stores recipientsyouyouplatform, via mobile SDKs
Stale-recipient cleanupyou, on UnregisteredErroryou, on 404 and 410handled
Per-platform payloadsAndroidConfig / APNSConfigyour service workerproviders overrides
Fan-out across devicesyou loop or multicastyou loopone call
Async supportnoyes, webpush_asyncyes, AsyncCourier
Other channelsnonenoneemail, SMS, in-app, chat
Preferences and opt-outsyou build ityou build itbuilt in
Best formobile-only appsbrowser-only sitesboth, or push plus other channels

If you need one platform, use its library directly and stop reading. The moment you need mobile and browser, you are maintaining two integrations that share nothing, and the notification layer stops being overhead and becomes the thing that saves you writing a fan-out service.

Next steps


FAQ

Frequently asked questions

For mobile, install `firebase-admin`, initialize it with a service account, and call `messaging.send(messaging.Message(token=..., notification=...))`. For browsers, install `pywebpush` and call `webpush(subscription_info, data, vapid_private_key, vapid_claims)`. Python cannot reach a device directly in either case: you always hand the message to Apple, Google, or a browser push service.

One API, every channel

Ship notifications without the boilerplate

Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.

Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.