Python backends — FastAPI, Flask, Django, plain scripts — all need the same transactional email pattern: keep the API key on the server, send HTML you own, observe delivery. This guide covers a reusable requests helper and a complete FastAPI welcome route using Notify.
Notify’s contract is intentionally small:
POST https://notify.cx/api/email/send- Header:
x-api-key - Body:
to,from,subject,message(plain text or HTML)
Sandbox rehearsal: POST https://notify.cx/api/email/send/test. Longer stack notes live in Notify’s docs: Python and FastAPI.
Prerequisites
- Python 3.9+
- Notify API key
- Verified domain for production
from(domain verification)
Plans: Free 1,000 emails/mo, Pro $10 / 10,000, Scale $50 / 100,000 — pricing.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install requests httpx fastapi "uvicorn[standard]" pydantic[email]
export NOTIFY_API_KEY=your_api_key_here
Use a secrets manager or .env loader in real deploys — never commit the key.
requests helper (sync scripts & workers)
# notify_client.py
from __future__ import annotations
import os
from typing import Any
import requests
NOTIFY_URL = "https://notify.cx/api/email/send"
NOTIFY_TEST_URL = "https://notify.cx/api/email/send/test"
def send_email(
*,
to: str,
subject: str,
message: str,
from_addr: str | None = None,
sandbox: bool = False,
) -> dict[str, Any]:
api_key = os.environ.get("NOTIFY_API_KEY")
if not api_key:
raise RuntimeError("NOTIFY_API_KEY is not set")
response = requests.post(
NOTIFY_TEST_URL if sandbox else NOTIFY_URL,
headers={
"Content-Type": "application/json",
"x-api-key": api_key,
},
json={
"from": from_addr or "noreply@your-verified-domain.com",
"to": to,
"subject": subject,
"message": message,
},
timeout=30,
)
response.raise_for_status()
return response.json()
# example_script.py
from notify_client import send_email
if __name__ == "__main__":
result = send_email(
to="you@example.com",
subject="Hello from Python",
message="<h1>It works</h1><p>Transactional email from a script.</p>",
sandbox=True, # flip to False after domain verification
)
print(result)
Sandbox behavior: sandbox vs production.
FastAPI app (complete welcome route)
# main.py
from __future__ import annotations
import os
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
NOTIFY_URL = "https://notify.cx/api/email/send"
class WelcomeIn(BaseModel):
to: EmailStr
name: str | None = None
async def require_internal_token(
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""
Demo gate. Replace with your real auth (JWT, session, API gateway).
Set INTERNAL_API_TOKEN in the environment.
"""
expected = os.environ.get("INTERNAL_API_TOKEN")
if not expected:
raise HTTPException(status_code=500, detail="INTERNAL_API_TOKEN not configured")
if not authorization or authorization != f"Bearer {expected}":
raise HTTPException(status_code=401, detail="Unauthorized")
@app.post("/api/send-welcome")
async def send_welcome(
body: WelcomeIn,
_: None = Depends(require_internal_token),
):
api_key = os.environ.get("NOTIFY_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="NOTIFY_API_KEY not configured")
name_bit = f", {body.name}" if body.name else ""
message = (
f"<h1>Welcome{name_bit}</h1>"
"<p>Thanks for joining.</p>"
'<p><a href="https://yourapp.com/dashboard">Open your dashboard</a></p>'
)
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
NOTIFY_URL,
headers={
"Content-Type": "application/json",
"x-api-key": api_key,
},
json={
"from": "noreply@your-verified-domain.com",
"to": str(body.to),
"subject": "Welcome",
"message": message,
},
)
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=r.text)
return r.json()
export INTERNAL_API_TOKEN=change-me-in-production
uvicorn main:app --reload
curl -X POST http://127.0.0.1:8000/api/send-welcome \
-H "Content-Type: application/json" \
-H "Authorization: Bearer change-me-in-production" \
-d '{"to":"you@example.com","name":"Ada"}'
Password reset stub (same send path)
Tokens stay in your database; Notify only delivers. Implement find_user_by_email / save_reset_token against SQLAlchemy, Prisma-like ORMs, or raw SQL.
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from notify_client import send_email
# Stubs — wire to your DB:
def find_user_by_email(email: str) -> dict | None:
raise NotImplementedError("Implement find_user_by_email")
def save_reset_token(*, user_id: str, token_hash: str, expires_at: datetime) -> None:
raise NotImplementedError("Implement save_reset_token")
def request_password_reset(email: str) -> None:
"""Always behave the same whether or not the user exists (caller's response)."""
user = find_user_by_email(email.strip().lower())
if not user:
return
token = secrets.token_hex(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
save_reset_token(
user_id=user["id"],
token_hash=token_hash,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
reset_url = f"https://yourapp.com/reset-password?token={token}"
send_email(
to=email,
subject="Reset your password",
message=(
f'<p><a href="{reset_url}">Choose a new password</a></p>'
"<p>This link expires in one hour.</p>"
),
)
Full security checklist: password reset emails. Rate-limit the HTTP route (e.g. slowapi) by IP and email — five requests per fifteen minutes is a reasonable starting policy.
Async vs sync
Use the requests helper in Celery workers, cron jobs, and management commands. Use httpx.AsyncClient inside FastAPI route handlers so you do not block the event loop. Both hit the same Notify endpoint and share the same body shape — pick based on your runtime, not on the email provider.
If you already use httpx everywhere, you can drop requests and keep one client module. The important invariant: one place constructs headers and JSON so field renames (when migrating providers) stay local.
Testing
# Sandbox rehearsal (non-delivering)
python -c "
from notify_client import send_email
print(send_email(to='you@example.com', subject='Test', message='<p>hi</p>', sandbox=True))
"
Then run FastAPI with uvicorn, authenticate with INTERNAL_API_TOKEN, and confirm production sends only after domain verification. Check email logs for the delivered message.
Common pitfalls
- Putting
NOTIFY_API_KEYin frontend / mobile builds - Open FastAPI routes that accept arbitrary recipients without auth
- Skipping domain verification in production
- Using marketing ESPs for transactional resets (reputation contamination)
- Not raising on non-2xx Notify responses (
raise_for_status/ status checks) - Blocking the event loop with sync
requestsinside async routes
Next steps
- Verify your domain and turn off sandbox
- Reuse the helper for magic links and receipts (receipts)
- Add webhooks when bounce handling matters
- Read the quick start if you are new to Notify
- Keep transactional HTML on this domain — put newsletters elsewhere
Resources
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…