This guide shows how to send email with Python through the api.email HTTP API. You will send a plain-text email, add HTML and an attachment, make retries safe, handle errors and verify delivery webhooks. The examples use the requests library, which most Python projects already have; the same JSON works with httpx or the standard library.
Prerequisites
- Python 3.9 or later and
pip install requests. - An API key stored in the
API_EMAIL_KEYenvironment variable. - A verified sending domain. Add the two CNAME records from the domains documentation to your DNS. Until they resolve, sending returns
domain_not_verified.
Send your first email
Sending is one POST to https://api.email/v1/emails with a JSON body. Pass a timeout on every request: without one, requests can wait forever on a stuck connection.
import os
import requests
API = "https://api.email/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['API_EMAIL_KEY']}"}
resp = requests.post(
f"{API}/emails",
headers=HEADERS,
json={
"from": "Acme <[email protected]>",
"to": "[email protected]",
"subject": "Welcome to Acme",
"text": "Thanks for signing up. Reply to this email if you have any questions.",
},
timeout=10,
)
resp.raise_for_status()
print(resp.json()) # {'id': 'em_8f2c1a', 'status': 'queued'}
The response contains the email’s id. Store it next to your own record, such as the user or the order, so you can look up what happened later in the dashboard or through GET /v1/emails/{id}.
HTML, a text fallback and attachments
Send both text and html. The text part is what screen readers, plain-text clients and many spam filters read, so a message with HTML only is worse on every front. Attachments are base64 strings with a filename and a content type.
import base64
from pathlib import Path
def send_receipt(to: str, order_id: str, pdf_path: str) -> str:
pdf = base64.b64encode(Path(pdf_path).read_bytes()).decode()
resp = requests.post(
f"{API}/emails",
headers={**HEADERS, "Idempotency-Key": f"receipt-{order_id}"},
json={
"from": "Acme Billing <[email protected]>",
"to": to,
"subject": f"Your receipt for order {order_id}",
"text": f"Thanks for your order {order_id}. Your receipt is attached.",
"html": f"<p>Thanks for your order <b>{order_id}</b>.</p><p>Your receipt is attached.</p>",
"attachments": [
{"filename": f"receipt-{order_id}.pdf", "content": pdf, "content_type": "application/pdf"}
],
"tags": ["receipt"],
},
timeout=10,
)
resp.raise_for_status()
return resp.json()["id"]
Keep the whole message under 10 MB. For bigger files, send a download link from your own storage instead.
Idempotency: never send twice
The Idempotency-Key header in send_receipt is built from the order id, so it is the same on every attempt to send that receipt. If a request times out after the server accepted it and your code tries again, api.email recognises the key and returns the first result instead of sending a duplicate. Choose keys that describe the email, such as receipt-1042 or password-reset-<request id>, and never generate a fresh random key per attempt.
Errors and retries
Errors share one JSON shape, {"error": {"code", "message", "hint"}}. Retry only what can succeed later: rate limits and server errors. Validation errors and an unverified domain will fail identically on every attempt, so raise them straight away.
import time
RETRYABLE = {429, 500, 502, 503, 504}
def post_with_retry(payload: dict, key: str, attempts: int = 4) -> dict:
for i in range(attempts):
resp = requests.post(
f"{API}/emails",
headers={**HEADERS, "Idempotency-Key": key},
json=payload,
timeout=10,
)
if resp.ok:
return resp.json()
if resp.status_code not in RETRYABLE or i == attempts - 1:
err = resp.json().get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')} ({err.get('hint')})")
time.sleep(0.5 * 2 ** i) # 0.5s, 1s, 2s
Because the retry reuses the same idempotency key, it can never cause a second delivery.
Django and Flask
In Django, put the call in a small module such as notifications/email.py and call it from your views or signals. For anything triggered by a user action, send from a task queue (Celery, RQ or Django’s own background tasks) so a slow network never slows the page down. If you would rather keep django.core.mail, set the SMTP backend to smtp.api.email, port 587, EMAIL_USE_TLS = True, user api and your API key as the password; the SMTP documentation has the full settings.
In Flask, the same helper works inside a route. Create one requests.Session at startup and reuse it, which keeps a warm HTTPS connection to the API and makes each send faster.
Verify delivery webhooks
The API response confirms acceptance. Delivery, bounces and complaints arrive later as webhooks signed with your endpoint secret in the api-email-signature header. Verify the signature over the raw body before you trust the event.
import hashlib, hmac, os, re, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["API_EMAIL_WEBHOOK_SECRET"]
def verify(raw: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
# Same helper as in the webhooks docs: rejects replays and forged or malformed
# signatures, and accepts both signatures during a secret rotation.
t, sigs = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
sigs.append(value)
if t is None or not sigs or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post("/hooks/email")
def email_events():
raw = request.get_data()
if not verify(raw, request.headers.get("api-email-signature"), SECRET):
abort(400)
event = request.get_json()
if already_processed(event["id"]): # retries are safe
return "", 200
if event["type"] == "email.bounced":
mark_undeliverable(event["to"])
return "", 200
hmac.compare_digest compares in constant time, which matters for signatures, and the timestamp check stops anyone from replaying a captured request. Answer with a 2xx status quickly and do slow work elsewhere; failed deliveries are retried with backoff for up to 24 hours.
Send asynchronously with httpx
In async frameworks such as FastAPI or Starlette, a blocking requests call stalls the event loop for every other request. Use httpx.AsyncClient instead: the request body, headers and responses are identical, only the client changes. Create one client when the app starts and close it on shutdown, so connections are reused.
import httpx
client = httpx.AsyncClient(base_url="https://api.email/v1", headers=HEADERS, timeout=10)
async def send_welcome(to: str) -> str:
resp = await client.post("/emails", json={
"from": "Acme <[email protected]>", "to": to,
"subject": "Welcome to Acme", "text": "Your account is ready.",
})
resp.raise_for_status()
return resp.json()["id"]
Test without sending real email
Use a test API key in your test suite and in CI. Test keys pass through the same validation, except that the sending domain doesn’t have to be verified yet, and return the same responses and webhook events, but nothing is delivered, so you can assert on the full flow, including error handling, without emailing real people. For pure unit tests, mock the HTTP layer with responses or respx and assert on the JSON your code sends: the to, the subject and the idempotency key are usually what matters.
Pick a sender address
Send from an address on your verified domain that people recognise, such as [email protected] for product email or [email protected] for receipts, and keep it stable: inbox providers learn the reputation of a sender over time. If replies matter, make sure the address can receive mail, or set reply_to to one that can. Avoid noreply@ for anything a customer might answer; a reply that bounces is a worse experience than one that reaches a person.
Production checklist
- Read keys from the environment and keep separate keys per environment, so test traffic can never reach real users.
- Set a
timeouton every request and reuse arequests.Session. - Log the returned
idwith your own identifiers for support requests. - Add a DMARC record such as
v=DMARC1; p=noneif your domain has none, then tighten it once reports look clean. - Send bulk or scheduled jobs from a worker, not from the request that a user is waiting on.