An API key.
Created in the dashboard, shown once. It is the SMTP password for path A and the Bearer token for path B. A sending access key is enough for both.
Django already knows how to send mail. This guide points its SMTP backend at the Notix relay so send_mail and the password-reset flow work unchanged, then adds the API through the notix package for the sends that need a template, an idempotency key or a batch, and finishes with a webhook view that updates your models when a message is delivered or bounces.
Created in the dashboard, shown once. It is the SMTP password for path A and the Bearer token for path B. A sending access key is enough for both.
Publish the records Notix gives you and any address on the domain becomes a valid DEFAULT_FROM_EMAIL. The quickstart walks through it.
Python 3.9 or later:
pip install notix
Six settings. After this, every send_mail, EmailMessage, admin error email and password-reset message leaves through the relay, lands in the Emails log, honours the suppression list and fires your webhooks.
# settings.py
import os
# Path A: the SMTP backend. Every existing send_mail(), EmailMessage and the
# built-in password-reset mail goes through the relay with no other change.
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.usenotix.dev"
EMAIL_PORT = 587
EMAIL_USE_TLS = True # STARTTLS on 587; or 465 with EMAIL_USE_SSL = True
EMAIL_HOST_USER = "notix"
EMAIL_HOST_PASSWORD = os.environ["NOTIX_API_KEY"] # an API key is the SMTP password
DEFAULT_FROM_EMAIL = "Acme <receipts@acme.com>" # on a domain you verified in Notix
# Path B: the API, for templates, idempotency keys and batch sends.
NOTIX_API_KEY = os.environ["NOTIX_API_KEY"]
NOTIX_WEBHOOK_SECRET = os.environ["NOTIX_WEBHOOK_SECRET"]
# anywhere in your app: unchanged Django
from django.core.mail import send_mail
send_mail(
"Your receipt for order 4471",
"Thanks for your order.",
None, # falls back to DEFAULT_FROM_EMAIL
["customer@example.com"],
html_message="<p>Thanks for your order.</p>",
)
# The relay hands the parsed message to the same endpoint the API uses, so it
# appears in the Emails log, honours the suppression list and fires webhooks.
Port 465 with EMAIL_USE_SSL = True works too; pick one of the two, never both. fail_silently stays False so an unverified from-address surfaces as an SMTPException instead of vanishing.
What SMTP cannot carry: a template rendered on the platform, an idempotency key so a retry never sends twice, batch sends and scheduling, and an emailId to match against webhook events.
# myapp/notix_client.py
from django.conf import settings
from notix import Notix
# One client per process; requests.Session reuse is built in.
# raise_on_error=False returns (None, error) instead of raising NotixHTTPError.
notix = Notix(settings.NOTIX_API_KEY, raise_on_error=False)
# Pointing at a different Notix base URL (a staging environment, say)?
# notix = Notix(settings.NOTIX_API_KEY, url="https://notix.example.com", raise_on_error=False)
from myapp.notix_client import notix
def send_receipt(order):
"""Send the receipt for one order, at most once."""
data, err = notix.emails.send(
{
"from": "Acme <receipts@acme.com>",
"to": order.customer_email,
"templateId": "tpl_receipt_v4",
"variables": {"orderId": str(order.id), "total": order.total_display},
},
options={"idempotency_key": f"order-{order.id}-receipt"},
)
if err:
# {"code": "BAD_REQUEST" | "RATE_LIMITED" | ..., "message": "..."}
raise RuntimeError(f"{err['code']}: {err['message']}")
# Keep the id: it is the key on every webhook event for this message.
order.receipt_email_id = data["emailId"]
order.save(update_fields=["receipt_email_id"])
return data["emailId"]
from django.db.models.signals import post_save
from django.dispatch import receiver
from myapp.models import Order
from myapp.services import send_receipt
@receiver(post_save, sender=Order)
def receipt_on_paid(sender, instance, created, **kwargs):
# The idempotency key inside send_receipt makes a double save harmless.
if instance.status == "paid" and not instance.receipt_email_id:
send_receipt(instance)
from django.core.management.base import BaseCommand, CommandError
from myapp.models import Order
from myapp.services import send_receipt
class Command(BaseCommand):
help = "Sends the receipt for the given order ids"
def add_arguments(self, parser):
parser.add_argument("order_ids", nargs="+", type=int)
def handle(self, *args, **options):
for order_id in options["order_ids"]:
try:
order = Order.objects.get(pk=order_id)
except Order.DoesNotExist:
raise CommandError(f"Order {order_id} does not exist")
email_id = send_receipt(order)
self.stdout.write(self.style.SUCCESS(f"Order {order_id}: queued {email_id}"))
# python manage.py send_receipts 4471 4472
Every call returns (data, err) when the client is built with raise_on_error=False; leave the default and it raises NotixHTTPError instead. The error codes, the envelope and the rest of the production surface are on the Python guide; nothing about them is Django-specific.
The API answers when the message is queued. Delivered, bounced and complained arrive later as signed email.* events. The view is CSRF-exempt because the signature, not a token, is the check.
# myapp/views.py
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from notix.webhooks import WebhookVerificationError
from myapp.models import Order
from myapp.notix_client import notix
webhooks = notix.webhooks(settings.NOTIX_WEBHOOK_SECRET)
@csrf_exempt # Notix does not carry a CSRF token; the signature is the check.
@require_POST
def notix_webhook(request):
try:
# request.body is the raw bytes; the signature covers exactly those.
event = webhooks.construct_event(request.body, headers=request.headers)
except WebhookVerificationError as error:
return HttpResponseBadRequest(str(error))
email_id = event["data"]["id"]
if event["type"] == "email.delivered":
Order.objects.filter(receipt_email_id=email_id).update(receipt_state="delivered")
elif event["type"] == "email.bounced" and event["data"]["bounce"]["type"] == "Permanent":
Order.objects.filter(receipt_email_id=email_id).update(receipt_state="bounced")
return HttpResponse("ok")
# urls.py
# path("webhooks/notix/", views.notix_webhook)
Register the URL in the dashboard under Webhooks and copy the secret into NOTIX_WEBHOOK_SECRET. Answer 200 quickly and do the slow work in a task; a non-2xx reply is retried with backoff. The signature scheme is on the webhook signature page.
Point any framework's mailer at one host and port; the relay turns SMTP into the same tracked, suppressed send.
GuidesThe notix package or plain requests, then smtplib pointed at the relay: three ways to send from Python.
GuidesA route that sends with the Python SDK, the config pattern for the key, and a webhook route that verifies the signature.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
ProductOne JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Six settings, one API key, and every Django email is logged and tracked. The free plan does not ask for a card.