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 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.
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 flask
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
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
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.
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
from unittest.mock import MagicMock
from acme import create_app
def test_receipt_route_sends_once():
app = create_app({"NOTIX_API_KEY": "test", "TESTING": True})
fake = MagicMock()
fake.emails.send.return_value = ({"emailId": "eml_1"}, None)
app.extensions["notix"] = fake
client = app.test_client()
response = client.post("/orders/4471/receipt", json={"email": "a@example.com", "total": "₦12,400"})
assert response.status_code == 202
assert response.get_json() == {"emailId": "eml_1"}
_, kwargs = fake.emails.send.call_args
assert kwargs["options"] == {"idempotency_key": "order-4471-receipt"}
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
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.
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.
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.
GuidesAn async route that sends, dependency injection for the client, a background task, and a webhook endpoint with a verified 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, register a blueprint. The free plan does not ask for a card.