Notix
Guides

Send email from Flask with one route.

By the end of this guide a Flask app has one Notix client built in its app factory, a route that sends a templated email through the email API with an idempotency key, a webhook route that verifies the signature and updates your records, and a test that proves the send without touching the network.

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

Python 3.9 or later:

terminal
pip install notix flask
Steps

Factory, route, webhook.

  1. Build the client once, in the factory.

    from_prefixed_env() reads FLASK_NOTIX_API_KEY into app.config. The client goes on app.extensions, where every blueprint can reach it through current_app and a test can replace it.

    acme/__init__.py
    # acme/__init__.py
    import os
    
    from flask import Flask
    from notix import Notix
    
    
    def create_app(test_config=None):
        app = Flask(__name__)
    
        # FLASK_NOTIX_API_KEY and FLASK_NOTIX_WEBHOOK_SECRET in the environment
        # become app.config["NOTIX_API_KEY"] and app.config["NOTIX_WEBHOOK_SECRET"].
        app.config.from_prefixed_env()
        if test_config:
            app.config.from_mapping(test_config)
    
        # One client per app; requests.Session reuse is built in.
        # raise_on_error=False returns (None, error) instead of raising.
        app.extensions["notix"] = Notix(app.config["NOTIX_API_KEY"], raise_on_error=False)
    
        # Pointing at a different Notix base URL (a staging environment, say)?
        # Notix(app.config["NOTIX_API_KEY"], url=app.config["NOTIX_URL"], raise_on_error=False)
    
        from acme import mail, webhooks
        app.register_blueprint(mail.bp)
        app.register_blueprint(webhooks.bp)
        return app
    
  2. Send from a route.

    notix.emails.send takes the payload and, as the second argument, options with the idempotency key. It returns (data, err): on success keep emailId, on failure err is the API’s own {code, message} envelope and nothing was sent.

    acme/mail.py
    from flask import Blueprint, current_app, jsonify, request
    
    bp = Blueprint("mail", __name__)
    
    
    @bp.post("/orders/<int:order_id>/receipt")
    def send_receipt(order_id):
        notix = current_app.extensions["notix"]
        body = request.get_json(force=True)
    
        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 client retry can never send twice.
            options={"idempotency_key": f"order-{order_id}-receipt"},
        )
    
        if err:
            # err is the API's own envelope: {"code": ..., "message": ...}
            status = 429 if err["code"] == "RATE_LIMITED" else 502
            return jsonify(error=err), status
    
        # Keep data["emailId"]: it is the key on every webhook event for this message.
        return jsonify(emailId=data["emailId"]), 202
    
  3. Receive delivery events.

    The route above answers when the message is queued. Delivered, bounced and complained arrive later on a webhook. Verify with the raw body from request.get_data(); a re-serialised JSON body no longer matches the signature.

    acme/webhooks.py
    # acme/webhooks.py
    from flask import Blueprint, current_app, request
    from notix.webhooks import WebhookVerificationError
    
    bp = Blueprint("webhooks", __name__)
    
    
    @bp.post("/webhooks/notix")
    def notix_webhook():
        notix = current_app.extensions["notix"]
        webhooks = notix.webhooks(current_app.config["NOTIX_WEBHOOK_SECRET"])
    
        try:
            # get_data() is the raw body; the signature covers exactly those bytes.
            # Never re-serialise request.get_json(): it no longer matches.
            event = webhooks.construct_event(request.get_data(), headers=request.headers)
        except WebhookVerificationError as error:
            return str(error), 400
    
        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", 200
    

Register the webhook URL in the dashboard and copy its secret into FLASK_NOTIX_WEBHOOK_SECRET. Answer 200 fast; a non-2xx reply is retried with backoff. 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, scheduled sends and attachments follow the same (data, err) shape. If your app already sends through Flask-Mail, point it at the SMTP relay instead and keep the API for the sends that need a template or a key.

FAQ

Questions, answered.

How do I send email from Flask with Notix?
Install the notix package, build one client in the app factory from a config value, and call notix.emails.send from a route with the from-address, recipient and either a template id with variables or your own html and text. The call returns (data, err); on success data["emailId"] is the message id. Pass an idempotency key made from the business event so a retried request never sends twice.
Where should the API key live in a Flask app?
In the environment, loaded with app.config.from_prefixed_env(): FLASK_NOTIX_API_KEY becomes app.config["NOTIX_API_KEY"]. Never put it in source or in a config file that is committed. Give the web process a sending-access key and keep a full-access key for scripts that manage domains or contacts.
Why store the client on app.extensions?
So there is one client per application, reachable from any blueprint through current_app, and replaceable in tests. The client keeps a requests.Session for connection reuse, which is wasted if you build a new one per request. The test sample swaps it for a MagicMock and asserts the idempotency key without touching the network.
Can I keep using Flask-Mail?
Yes. Flask-Mail speaks SMTP, so point it at the relay: MAIL_SERVER smtp.usenotix.dev, MAIL_PORT 587 with MAIL_USE_TLS, MAIL_USERNAME notix and MAIL_PASSWORD an API key. Every message then goes through the same log and suppression list. Use the API for the sends that need a template, an idempotency key or a batch.
How does the webhook route stay safe?
construct_event verifies 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 request.get_data(), not a re-encoded JSON body. Answer 200 quickly; anything slow belongs in a queue.
Is the package synchronous?
Yes, it uses requests. That fits Flask's request cycle directly. For sends that fan out to many recipients, hand the work to a queue such as RQ or Celery and use notix.emails.batch there, 100 messages per call with one idempotency key per batch.

One factory line, one route.

Verify a domain, copy an API key, register a blueprint. The free plan does not ask for a card.