Notix
Guides

Send email from FastAPI without blocking the loop.

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.

What you need

An API key, a verified domain, and pip.

An API key.

Created in the dashboard, shown once. A sending access key is enough for this guide.

A verified domain.

Publish the records Notix gives you and any address on it is a valid from. The quickstart walks through it.

The packages.

Python 3.9 or later:

terminal
pip install notix "fastapi[standard]"
Steps

Dependency, route, webhook, test.

  1. Create the client once and inject it.

    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
    # 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)]
    
  2. Send from a route.

    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.

    acme/main.py
    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}
    
  3. Receive delivery events.

    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
    # 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}
    
  4. Prove it without the network.

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

In production

The same three lines as every Python send.

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.

FAQ

Questions, answered.

How do I send email from FastAPI with Notix?
Install the notix package, create one client in the lifespan handler and expose it with Depends, then call notix.emails.send with a template id and variables, or your own html and text, and an idempotency key. Put the send in a BackgroundTasks task so the request returns 202 immediately, or send inline from a plain def handler when the caller needs the emailId.
Is there an async client?
No. The package is synchronous and uses requests. FastAPI handles that cleanly: a plain def path operation runs in a threadpool, a BackgroundTasks task runs after the response, and inside an async def you can await run_in_threadpool(notix.emails.send, payload, options). None of these block the event loop.
Why a background task instead of sending in the request?
The send is a network call to the API, typically well under a second, but it is still time your caller waits for. A background task returns 202 at once and sends after the response is written. The idempotency key makes this safe: if the task fails and you retry later, the same key returns the original message rather than a second one.
Where does the API key live?
In the environment, read once in the lifespan handler. Never in source. Use a sending-access key for the web process and a full-access key only for jobs that manage domains or contacts. The webhook secret is a separate value, NOTIX_WEBHOOK_SECRET, shown when you register the endpoint.
How does the webhook endpoint verify the request?
construct_event checks the HMAC-SHA256 signature over the timestamp and the raw body and rejects anything older than five minutes, so a forged or replayed request raises WebhookVerificationError before your code runs. Pass await request.body(), the raw bytes, and the headers as a dict. Answer quickly; a non-2xx reply is retried with backoff.
How do I test the route without sending?
Replace app.state.notix with a MagicMock whose emails.send returns ({"emailId": "eml_1"}, None) and call the route with TestClient. TestClient runs background tasks before it returns, so the test can assert the payload and the idempotency key that reached the client.

One dependency, one background task.

Verify a domain, copy an API key, add the lifespan handler. The free plan does not ask for a card.