Notix
Guides

Send email from Python with one API call.

By the end of this guide you will have sent a real email from a Python script through the email API, seen the same send made with plain requests and with smtplib through the SMTP relay, and added the three things a production send needs: an idempotency key, error handling, and a webhook for delivery status.

What you need

Three things, none of them a card.

An API key.

Created in the dashboard under API keys, shown once. A sending access key is enough for this guide and cannot read contacts or delete domains if it leaks.

A verified domain.

Add your domain, publish the SPF, DKIM and DMARC records Notix gives you, and the from address on it becomes valid. The quickstart walks through it.

Python 3.9 or later.

The package is one install from PyPI:

terminal
pip install notix
# or: uv add notix / poetry add notix

The free plan covers 5,000 emails a month and 200 a day, and the free plan does not ask for a card.

Steps

From an empty file to a queued message.

  1. Initialise the client.

    Notix takes the API key and, optionally, a url for a different Notix base URL. Read the key from the environment; the client sends it as a Bearer token and never logs it.

    notix_client.py
    import os
    from notix import Notix
    
    # Keep the key in the environment, never in source.
    client = Notix(os.environ["NOTIX_API_KEY"])
    
    # Pointing at a different Notix base URL (a staging environment, say)? Pass url=.
    # client = Notix(os.environ["NOTIX_API_KEY"], url="https://notix.example.com")
    
  2. Send one email.

    client.emails.send takes a dict with from, to, subject and html or text (send both; some clients only show one). types.EmailCreate is a TypedDict for editor hints; at runtime it is a plain dict, and from_ is accepted as a synonym for from. Custom headers are forwarded as they are; Notix manages only X-Notix-Email-ID and References. The requests tab is the same call without the package: one POST to /api/v1/emails with a Bearer token.

    send.py
    import os
    from notix import Notix, types
    
    client = Notix(os.environ["NOTIX_API_KEY"])
    
    payload: types.EmailCreate = {
        "from": "receipts@acme.com",
        "to": "customer@example.com",
        "subject": "Your receipt for order 4471",
        "html": "<p>Thanks for your order.</p>",
        "text": "Thanks for your order.",
    }
    
    data, err = client.emails.send(payload)
    
    if err:
        print(err["code"], err["message"])
    else:
        print("queued", data["emailId"])
    
  3. Keep the id, check the error.

    Every call returns (data, err). When err is None, data["emailId"] is the message’s id: store it next to the order, the user or whatever caused the send, because it is the key you read the message back with (client.emails.get(email_id)) and the data.id every webhook event for that message carries. When err is set, it is the API’s own envelope, {code, message}, and nothing was sent.

  4. Or send through smtplib.

    Already on the standard library? Point smtplib at the SMTP relay. Host smtp.usenotix.dev, port 465 with implicit TLS (SMTP_SSL) or 587 for STARTTLS, username notix, and your API key as the password. The relay posts the parsed message to the same endpoint the package calls, so suppression, tracking and webhooks apply either way.

    send_smtp.py
    import os
    import smtplib
    from email.message import EmailMessage
    
    message = EmailMessage()
    message["From"] = "receipts@acme.com"
    message["To"] = "customer@example.com"
    message["Subject"] = "Your receipt for order 4471"
    message.set_content("Thanks for your order.")
    message.add_alternative("<p>Thanks for your order.</p>", subtype="html")
    
    # Implicit TLS on 465. For STARTTLS use smtplib.SMTP("smtp.usenotix.dev", 587)
    # and call starttls() before login().
    with smtplib.SMTP_SSL("smtp.usenotix.dev", 465) as smtp:
        # The SMTP password is a Notix API key.
        smtp.login("notix", os.environ["NOTIX_API_KEY"])
        smtp.send_message(message)
    
In production

The three lines that separate a demo from a system.

A send that works once is easy. A send that survives a retry, a bad address and a network blip needs an idempotency key, a look at the error envelope, and a webhook.

Idempotency key.

Pass {"idempotency_key": ...} as the second argument (the package sends it as the Idempotency-Key header; with requests, set that header yourself). Retrying the same key and body returns the original message instead of sending a second one; the same key with a different body is refused. Use the id of the business event: the order, the reset, the invoice.

The error envelope.

Every failure is { error: { code, message } }. The codes you will meet first: BAD_REQUEST for an unverified from domain or a malformed address, FORBIDDEN when a sending-access key reaches an endpoint it cannot use, and RATE_LIMITED when the per-second limit or the plan’s send limit is reached. The package returns the envelope as err rather than raising.

Delivery status by webhook.

The API answers as soon as the message is queued. Delivery, bounces, complaints, opens and clicks arrive as email.* events on a webhook you register in the dashboard. Each request is signed with HMAC-SHA256 and carries X-Notix-Signature and X-Notix-Timestamp; webhooks.construct_event verifies both, rejects timestamps older than five minutes, and raises WebhookVerificationError on anything it cannot trust.

Environment and keys.

NOTIX_API_KEY for sending, NOTIX_WEBHOOK_SECRET for verifying. Give the service that only sends a sending-access key, and keep a full-access key for the jobs that manage domains and contacts.

send_with_idempotency.py
import os
from notix import Notix, types

client = Notix(os.environ["NOTIX_API_KEY"])

payload: types.EmailCreate = {
    "from": "receipts@acme.com",
    "to": "customer@example.com",
    "subject": "Your receipt for order 4471",
    "html": "<p>Thanks for your order.</p>",
    "text": "Thanks for your order.",
}

# One key per business event, so a retry can never send twice.
data, err = client.emails.send(payload, {"idempotency_key": "order-4471-receipt"})

if err:
    # {"code": "RATE_LIMITED" | "BAD_REQUEST" | "FORBIDDEN" | ..., "message": ...}
    raise RuntimeError(f"{err['code']}: {err['message']}")

# Delivery status arrives on your webhook as email.delivered, email.bounced, ...
print("queued", data["emailId"])
webhook.py
import os
from flask import Flask, request
from notix import Notix
from notix.webhooks import WebhookVerificationError

app = Flask(__name__)
client = Notix(os.environ["NOTIX_API_KEY"])
webhooks = client.webhooks(os.environ["NOTIX_WEBHOOK_SECRET"])


@app.post("/webhooks/notix")
def notix_webhook():
    try:
        # Pass the raw body: the signature is computed over the exact bytes.
        event = webhooks.construct_event(request.data, headers=request.headers)
    except WebhookVerificationError as error:
        return str(error), 400

    if event["type"] in ("email.delivered", "email.bounced", "email.complained"):
        # event["data"]["id"] is the emailId you were given at send time.
        pass

    return "ok", 200

# FastAPI: event = webhooks.construct_event(await request.body(), headers=dict(request.headers))

That is the whole production surface for a single send. Batch sends with client.emails.batch, scheduling with scheduledAt, attachments and cancelling a scheduled message all follow the same (data, err) shape; the Node.js guide and its production companion cover the same endpoints from the other side of the fence.

Using SMTP instead

When the mailer is already wired, keep it.

If your app already sends through smtplib, Django’s EMAIL_HOST settings or a framework mailer, the relay is the shortest path: change the host and credentials and nothing else. You give up the idempotency key and the batch endpoint, which only the API exposes, but every message still lands in the same log, honours the same suppression list and fires the same webhooks. The smtplib step above is the complete configuration; the same four values work in any SMTP client. Sending one-time codes? The OTP use case uses the verification API rather than a template of your own, and the deliverability check can run on any message before it goes out.

FAQ

Questions, answered.

Is this an alternative to smtplib?
It works alongside it. smtplib keeps working when you point it at smtp.usenotix.dev with the username notix and an API key as the password, and the relay turns that session into the same tracked, suppressed send the API makes. The API and the notix package add what SMTP cannot carry: an idempotency key per send, batch sends, scheduling, and an id back for every message.
Do I need the SDK, or can I call the API with requests?
Either. The notix package is a thin wrapper over the same JSON endpoints, so the requests example on this page sends exactly what client.emails.send sends. Use the package for the TypedDict hints, the idempotency_key option and the webhook verifier; use requests when you would rather not add a dependency.
What does the response look like?
Every SDK call returns a (data, err) tuple. On success data carries emailId, which you keep to read the message back with client.emails.get and to match webhook events. On failure err is the API's own error envelope, a dict with code and message, and data is None, so one if statement covers both. With requests, the same envelope arrives as the JSON body of a non-2xx response.
Can I schedule a send or add attachments from Python?
Yes. Pass scheduledAt as a datetime (the package converts it to ISO 8601) and attachments as a list of {filename, content} with the content base64-encoded, both on client.emails.create. A scheduled message can be moved with client.emails.update and cancelled with client.emails.cancel before it goes out.
Does the package work with type checkers?
The package ships py.typed and TypedDict shapes for every payload (types.EmailCreate, types.EmailBatchItem and so on), so mypy and pyright check the keys you pass. At runtime you pass plain dicts. The client accepts from or from_ as the sender key and normalises the second to the first, which keeps a keyword-argument style out of trouble with Python's reserved word.

Send the first one now.

Verify a domain, copy an API key, run the script. The free plan does not ask for a card.