Send email with Python

Send email with Python using requests: plain text and HTML, attachments, idempotency keys, retries and webhook verification, plus Django and Flask notes.

Updated 2026-10-25

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_KEY environment 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 timeout on every request and reuse a requests.Session.
  • Log the returned id with your own identifiers for support requests.
  • Add a DMARC record such as v=DMARC1; p=none if 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.

FAQ

Should I use smtplib or an email API to send email with Python?

smtplib works, but you manage connections, TLS, retries and bounce handling yourself. An email API is one HTTPS request with requests or httpx, returns an id you can track and supports idempotency keys. Keep smtplib for legacy code that already speaks SMTP.

How do I send email from Django?

Call the API from a helper function or a background task, or point Django's SMTP backend at smtp.api.email with EMAIL_HOST_USER set to api and EMAIL_HOST_PASSWORD set to your API key. Both use the same account and logs.

Can I send email asynchronously from Python?

Yes. Use httpx.AsyncClient in async frameworks such as FastAPI, or push the send into a task queue like Celery or RQ so the web request returns immediately. The request body is the same either way.

How do I keep my API key out of the code?

Read it from an environment variable with os.environ, and load a local .env file only in development. Use separate keys for development, staging and production.

Get API key

Create your api.email account

What do you need?

Early access: accounts open in batches. By continuing you agree to the Terms and Privacy Policy.