Notix
Guides

Send email from Django, two ways.

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.

What you need

An API key, a verified domain, and pip.

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.

A verified domain.

Publish the records Notix gives you and any address on the domain becomes a valid DEFAULT_FROM_EMAIL. The quickstart walks through it.

The package, for path B.

Python 3.9 or later:

terminal
pip install notix
Path A

The SMTP backend: change settings, change nothing else.

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
# 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
# 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.

Path B

The API: templates, idempotency keys, an id back.

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
# 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)
myapp/services.py
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"]

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.

Delivery status

A webhook view that updates your models.

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
# 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.

FAQ

Questions, answered.

How do I send email from Django with Notix?
Two ways. The fastest is the SMTP backend: set EMAIL_HOST to smtp.usenotix.dev, EMAIL_PORT to 587 with EMAIL_USE_TLS, EMAIL_HOST_USER to notix and EMAIL_HOST_PASSWORD to an API key, and every send_mail, EmailMessage and password-reset email goes through the relay unchanged. The second is the notix package for the API, which adds templates, idempotency keys, batch sends and scheduling. Many apps use both: the backend for Django's own mail, the API for the sends that must never repeat.
Does Django's password reset email work through the relay?
Yes. PasswordResetView uses the configured email backend, so once the SMTP settings point at the relay the reset mail is sent, logged and tracked like any other message. Set DEFAULT_FROM_EMAIL to an address on a domain you have verified in Notix, or the relay refuses the send.
Should I put the API key in settings.py?
Read it from the environment, as the settings sample does, and never commit it. EMAIL_HOST_PASSWORD and NOTIX_API_KEY can be the same key. Give the web process a sending-access key and keep a full-access key for management commands that touch domains or contacts.
How do I stop a retried request from sending twice?
Pass options={"idempotency_key": ...} with a key made from the business event, for example order-4471-receipt. A retry with the same key and body within 24 hours returns the original email id instead of sending again; the same key with a different body is refused with 409. That is what makes the post_save signal in the sample safe to fire more than once.
Why does the webhook view need csrf_exempt?
Django's CSRF middleware rejects any POST without its token, and Notix cannot carry one. The view is exempt from CSRF and instead relies on the webhook signature: construct_event checks the HMAC-SHA256 over the timestamp and the raw body and rejects anything older than five minutes, so a forged request is refused before your code runs.
Is the notix package synchronous?
Yes. It uses a requests.Session, so it fits Django's synchronous views, signals and management commands directly. In an async view, run the call with asgiref's sync_to_async, or hand the send to a task queue such as Celery, which is also the right place for sends that fan out to many recipients.

Point the backend at the relay tonight.

Six settings, one API key, and every Django email is logged and tracked. The free plan does not ask for a card.