# Send email with Python

> Send email from Python with Email Fast: a complete stdlib urllib example against the REST API, with a sandbox key, idempotency, and real error handling.

Canonical: https://emailfast.dev/send-email-with/python

Python's standard library is enough — the Email Fast API is one JSON POST, so
`urllib.request` does the job without adding `requests` to your dependencies. Wrap it
in a small function and call it anywhere. If you'd rather have a typed client, the
[Python SDK quickstart](/docs/quickstart-python) covers that instead.

```python
# send.py — Python 3.10+, stdlib only
import json
import os
import urllib.error
import urllib.request

API_KEY = os.environ["EMAILFAST_API_KEY"]  # ef_sandbox_... while testing

def send_email(to: str, subject: str, html: str, data: dict, idempotency_key: str) -> dict:
    req = urllib.request.Request(
        "https://api.emailfast.dev/v1/emails",
        data=json.dumps({
            "to": to,
            "subject": subject,
            "html": html,
            "data": data,
            "idempotency_key": idempotency_key,
        }).encode(),
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"send rejected ({e.code}): {e.read().decode()}") from e

result = send_email(
    to="ada@example.com",
    subject="Welcome",
    html="<h1>Hi {{name}}</h1>",
    data={"name": "Ada"},
    idempotency_key="welcome-ada-1",  # a retry replays, never re-sends
)
print(result["id"], result["status"])  # msg_... queued
```

a 202 from the API means the send is committed to a durable, partitioned outbox before we answer — a crash can't lose it, and a retry with the same idempotency key can't double-send.

## Gotchas

- `urllib` raises `HTTPError` on any 4xx/5xx — catch it and read `e.read()`; the body names the exact rejection (invalid address, suppressed recipient, quota).
- Always pass `timeout=` — the default is to block forever on a stuck connection.
- Read the key with `os.environ[...]`, not a literal: a `KeyError` at startup beats a silent auth failure at 2 a.m.
- Sending many? Don't loop this function — POST up to 500 messages to `/v1/emails/batch` in one call.

## Next

- [Python SDK quickstart](/docs/quickstart-python) — the typed client
- [The email API](/features/email-api) — batch, scheduling, message timelines
- [Sending from Django](/send-email-with/django)
- [All docs](/docs)
