Send Push Notifications From a Django App
Django signals already fire at the moment something important happens. Hang a push notification off one and stop refreshing the admin to find out.

Your Django app knows the moment it matters. A user signs up, a payment fails, an order goes through, a background job dies. The ORM writes the row and the request ends, and the only trace is a line in a log and a record in the admin nobody has open.
Django's signals exist precisely to hang behaviour off those moments. Hanging a push notification off one takes about fifteen minutes and covers the handful of events you would otherwise check for by refreshing a page.
Step 1: The setting
Create an application in TheNotificationApp, call it after your project, and copy the app_key. Read it from the environment in settings.py:
import os
TNA_APP_KEY = os.environ.get("TNA_APP_KEY", "")
TNA_NOTIFY_ENABLED = bool(TNA_APP_KEY) and not DEBUGThe not DEBUG is worth having from the start. Without it, every local test that creates a user sends a real notification to your phone, and you will disable the whole feature by Thursday.
Step 2: The helper
Put this in notifications/utils.py:
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
ENDPOINT = "https://thenotification.app/api/sendNotification"
def notify(title: str, body: str, link: str | None = None) -> None:
"""Send a push notification. Never raises into a request cycle."""
if not settings.TNA_NOTIFY_ENABLED:
logger.debug("notify skipped (disabled): %s", title)
return
payload = {"title": title, "body": body[:180]}
if link:
payload["link"] = link
try:
response = requests.post(
ENDPOINT,
headers={"app_key": settings.TNA_APP_KEY, "Content-Type": "application/json"},
json=payload,
timeout=5,
)
if response.status_code >= 400:
logger.warning("notify failed: %s %s", response.status_code, response.text[:200])
except requests.RequestException as exc:
logger.warning("notify failed: %s", exc)The five second timeout matters more than it looks. This function is about to be called from a signal, which runs inside the request cycle, which means without a timeout a slow response adds that time to a user's page load.
Step 3: Hang it off a signal
In notifications/signals.py:
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from .utils import notify
User = get_user_model()
@receiver(post_save, sender=User)
def notify_on_signup(sender, instance, created, **kwargs):
if not created:
return
notify(
"New signup",
f"{instance.email or instance.username} just created an account.",
link="https://yourapp.example.com/admin/auth/user/",
)The if not created guard is the line everyone forgets. Without it, every password change, every last-login update, every admin edit fires the signal, and "new signup" notifications arrive for users who signed up in 2023.
Connect it in the app config, which is the part that silently does nothing if you skip it:
class NotificationsConfig(AppConfig):
name = "notifications"
def ready(self):
from . import signals # noqa: F401Step 4: Get it off the request path
The version above blocks the response for as long as the API takes. That is usually milliseconds and occasionally is not, and the user who is waiting did nothing to deserve it.
If you already run Celery, move it:
from celery import shared_task
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def send_notification(self, title: str, body: str, link: str | None = None):
from .utils import notify_raising # a version that does raise
try:
notify_raising(title, body, link)
except Exception as exc:
raise self.retry(exc=exc)@receiver(post_save, sender=User)
def notify_on_signup(sender, instance, created, **kwargs):
if not created:
return
send_notification.delay("New signup", f"{instance.email} just signed up.")Note that the task uses a raising version of the helper. Inside a Celery task you want the exception, because that is what triggers the retry. Inside a request you do not. Same call, opposite error handling, which is the whole reason to have two functions rather than a flag.
No Celery? transaction.on_commit is a decent middle ground: it at least means you never notify about a row that then got rolled back.
from django.db import transaction
transaction.on_commit(lambda: notify("New signup", f"{instance.email} signed up."))What is actually worth a notification
Signals make this too easy, which is the trap. Every model in your project could fire one and then none of them mean anything.
- The first few signups. Genuinely motivating early, and you turn it off when it stops being rare.
- A payment failed. Money leaving is worth interrupting you for.
- A background job died. Especially the nightly ones nobody watches.
- A contact form or support request. Reply speed is the whole product for small teams.
Not worth it: page views, logins, ordinary object updates, anything that happens more than a few times a day.
Reporting from management commands
Signals cover things that happen because a user did something. The other half of a Django project is the scheduled work: a management command run from cron or a container, which fails in a way nobody sees.
A small base class covers all of them at once:
from django.core.management.base import BaseCommand
from notifications.utils import notify
class NotifyingCommand(BaseCommand):
"""Base command that reports failures, and successes worth mentioning."""
notify_on_success = False
def handle(self, *args, **options):
label = self.__class__.__module__.rsplit(".", 1)[-1]
try:
result = self.run(*args, **options)
except Exception as exc:
notify(f"{label} failed", f"{type(exc).__name__}: {exc}")
raise
if self.notify_on_success and result:
notify(f"{label} finished", str(result))
return result
def run(self, *args, **options):
raise NotImplementedErrorclass Command(NotifyingCommand):
help = "Reconcile payments against the provider"
def run(self, *args, **options):
mismatches = reconcile()
if mismatches:
notify("Payment mismatches found", f"{len(mismatches)} to review")
return NoneSubclasses implement run instead of handle, and every command gets failure reporting without remembering to add it. notify_on_success defaults to off on purpose: a nightly command that reports success every night is a notification you stop reading within a fortnight.
Keeping it out of your tests
The TNA_NOTIFY_ENABLED flag covers local development. For the test suite, be explicit:
# settings/test.py
TNA_NOTIFY_ENABLED = FalseAnd where a test needs to assert the notification happened, patch it rather than letting it fly:
from unittest.mock import patch
@patch("notifications.utils.requests.post")
def test_signup_notifies(mock_post, db):
User.objects.create_user("alex", "alex@acme.com", "pw")
assert mock_post.calledThe honest part
The free tier is 100 notifications for the lifetime of the account, not per month. For a new project where a signup is an event, that is a genuinely good fit and a nice problem to outgrow. For an app with steady traffic it is an afternoon, so put a condition in front of anything that fires more than a few times a day.
Pro is $2.99 a month for 1,000. And this reaches iPhone only, because it rides Apple's push service.
Where this fits
The underlying call is the same one from any Python code, covered in sending a push notification from Python. The field list is in the API reference.
Grab a free key at thenotification.app and stop refreshing the admin to see if anything happened.
New to this? Start with what a push notification API actually is.



