An API key.
Created in the dashboard, shown once. A sending access key is enough for this guide.
By the end of this guide a FastAPI app has one Notix client provided through Depends, a Pydantic request model, a route that queues the send as a background task and returns 202, a variant that waits for the emailId from a threadpool, a webhook endpoint that verifies the signature, and a test that proves the idempotency key without touching the network. Every send goes through the email API.
Created in the dashboard, shown once. A sending access key is enough for this guide.
Publish the records Notix gives you and any address on it is a valid from. The quickstart walks through it.
Python 3.9 or later:
pip install notix "fastapi[standard]"
The lifespan handler builds one client on app.state before the first request. get_notix reads it back, and the NotixDep alias makes every handler that needs it one parameter long.
# acme/main.py
import os
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, EmailStr
from notix import Notix
from notix.webhooks import WebhookVerificationError
@asynccontextmanager
async def lifespan(app: FastAPI):
# One client for the process; it keeps a requests.Session for connection reuse.
# raise_on_error=False returns (None, error) instead of raising.
app.state.notix = Notix(os.environ["NOTIX_API_KEY"], raise_on_error=False)
# Pointing at a different Notix base URL (a staging environment, say)? Pass url=.
yield
app = FastAPI(lifespan=lifespan)
def get_notix(request: Request) -> Notix:
return request.app.state.notix
NotixDep = Annotated[Notix, Depends(get_notix)]
The package is synchronous, so keep it off the event loop: hand the send to BackgroundTasks and answer 202, or declare the handler with plain def so FastAPI runs it in the threadpool and the response can carry the emailId. Either way the idempotency key comes from the business event, never from a random value.
class ReceiptRequest(BaseModel):
email: EmailStr
total: str
def send_receipt(notix: Notix, order_id: int, body: ReceiptRequest) -> None:
# A plain function passed to add_task; it runs after the response is sent.
data, err = notix.emails.send(
{
"from": "Acme <receipts@acme.com>",
"to": body.email,
"templateId": "tpl_receipt_v4",
"variables": {"orderId": str(order_id), "total": body.total},
},
# One key per business event: a retried request can never send twice.
options={"idempotency_key": f"order-{order_id}-receipt"},
)
if err:
# Log it; the client already got 202. Retrying later is safe because of the key.
log.warning("receipt %s failed: %s %s", order_id, err["code"], err["message"])
return
store_email_id(order_id, data["emailId"]) # the key on every webhook event
@app.post("/orders/{order_id}/receipt", status_code=202)
async def queue_receipt(
order_id: int, body: ReceiptRequest, notix: NotixDep, background: BackgroundTasks
):
background.add_task(send_receipt, notix, order_id, body)
return {"queued": True}
# When the caller needs the emailId in the response, send inline. Declare the
# handler with plain def: FastAPI runs it in a threadpool, so the synchronous
# client does not block the event loop.
@app.post("/orders/{order_id}/receipt")
def send_receipt_now(order_id: int, body: ReceiptRequest, notix: NotixDep):
data, err = notix.emails.send(
{
"from": "Acme <receipts@acme.com>",
"to": body.email,
"templateId": "tpl_receipt_v4",
"variables": {"orderId": str(order_id), "total": body.total},
},
options={"idempotency_key": f"order-{order_id}-receipt"},
)
if err:
status = 429 if err["code"] == "RATE_LIMITED" else 502
raise HTTPException(status_code=status, detail=err)
return {"emailId": data["emailId"]}
# Inside an async def handler, wrap the call instead:
# from fastapi.concurrency import run_in_threadpool
# data, err = await run_in_threadpool(notix.emails.send, payload, options)
Delivered, bounced and complained arrive later as signed email.* events. Verify with the raw bytes from await request.body(); a re-serialised model no longer matches the signature.
# acme/main.py
@app.post("/webhooks/notix")
async def notix_webhook(request: Request, notix: NotixDep):
webhooks = notix.webhooks(os.environ["NOTIX_WEBHOOK_SECRET"])
try:
# await request.body() is the raw bytes; the signature covers exactly those.
event = webhooks.construct_event(await request.body(), headers=dict(request.headers))
except WebhookVerificationError as error:
raise HTTPException(status_code=400, detail=str(error))
email_id = event["data"]["id"]
if event["type"] == "email.delivered":
mark_receipt(email_id, "delivered")
elif event["type"] == "email.bounced" and event["data"]["bounce"]["type"] == "Permanent":
mark_receipt(email_id, "bounced") # the address is now suppressed by Notix
return {"ok": True}
Swap the client on app.state for a mock and assert the payload and key that reached it. TestClient runs background tasks before it returns, so the assertion sees the call.
# tests/test_receipt.py
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from acme.main import app
def test_receipt_is_queued_with_an_idempotency_key():
fake = MagicMock()
fake.emails.send.return_value = ({"emailId": "eml_1"}, None)
app.state.notix = fake
with TestClient(app) as client: # runs lifespan; state set above survives
response = client.post("/orders/4471/receipt", json={"email": "a@example.com", "total": "₦12,400"})
assert response.status_code == 202
# TestClient runs background tasks before returning.
_, kwargs = fake.emails.send.call_args
assert kwargs["options"] == {"idempotency_key": "order-4471-receipt"}
Register the webhook URL in the dashboard and copy its secret into NOTIX_WEBHOOK_SECRET. The scheme is on the webhook signature page.
An idempotency key per business event, a look at the error envelope, and a webhook for delivery status. The Python guide explains each in full, including the error codes you will meet first and how batch sends, scheduling and attachments follow the same (data, err) shape. Sends that fan out to many recipients belong in a worker with notix.emails.batch, 100 messages per call, rather than in a request. Sending one-time codes? The OTP use case uses the verification API instead of a template of your own.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
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 casesSend one-time codes by email or SMS with two API calls, with expiry, length and risk scoring handled for you.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, add the lifespan handler. The free plan does not ask for a card.