# Send Email with Python: A Complete API Tutorial

> Send email with Python using requests and an email API: HTML, templates, attachments, batch sending and safe retries, with working code. No smtplib needed.

Canonical: https://www.cresca.xyz/blog/send-email-python
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

The quickest way to **send email with Python** in production is an email API: one HTTPS request with `requests`, no SMTP server, no `smtplib` connection handling. Cresca accepts the email, returns an id and delivers it from your own verified domain within seconds. This tutorial starts with a short script and builds up to production code with templates, attachments, batch sending, safe retries and a FastAPI example.

**TL;DR**

- `requests.post("https://api.cresca.xyz/api/v1/emails", json=...)` with your key as a bearer token.
- The body needs `from`, `to`, `subject` and `html` or `text`.
- Add an `Idempotency-Key` header so retries never send twice.
- Batch up to 100 emails per request with `/emails/batch`.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

You need Python 3.8 or later and the `requests` package (`pip install requests`). The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email with Python: the basic request

```python
import os
import requests

res = requests.post(
    "https://api.cresca.xyz/api/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"},
    json={
        "from": "Acme <hello@acme.com>",
        "to": "ana@example.com",
        "subject": "Welcome to Acme",
        "html": "<p>Hi Ana, your account is ready.</p>",
    },
    timeout=10,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{body['name']}: {body['message']}")
print(body["id"])
```

The response is `200 OK` with the email's id:

```json
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
```

Passing `json=` makes requests set the `Content-Type` header and encode the body. `from` is a reserved word in Python, which is one reason to build the payload as a dict rather than keyword arguments. If you send only `html`, Cresca generates the plain-text part; send `text` too if you want to control it. Optional fields include `cc`, `bcc`, `reply_to`, `tags` and `scheduled_at`.

## A production-ready helper with retries

The basic request is fine for a script. In production you want three more things, and the `cresca()` function below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```python
# cresca.py: a small client for the Cresca email API.
import os
import random
import time

import requests

API_URL = "https://api.cresca.xyz/api/v1"
ATTEMPTS = 4

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['CRESCA_API_KEY']}"

class CrescaError(Exception):
    def __init__(self, status, name, message):
        super().__init__(f"{status} {name}: {message}")
        self.status = status
        self.name = name

def backoff(attempt):
    # 1s, 2s, 4s... plus up to 250 ms of jitter.
    return 2 ** (attempt - 1) + random.random() / 4

def cresca(path, payload, idempotency_key=None):
    headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
    for attempt in range(1, ATTEMPTS + 1):
        try:
            res = session.post(API_URL + path, json=payload, headers=headers, timeout=10)
        except requests.RequestException:
            # Network error or timeout: the idempotency key makes a retry safe.
            if attempt == ATTEMPTS:
                raise
            time.sleep(backoff(attempt))
            continue

        try:
            body = res.json()
        except ValueError:
            body = {}
        if res.ok:
            return body

        name = body.get("name", "unknown_error")
        retryable = (
            res.status_code >= 500
            or (res.status_code == 429 and name != "monthly_quota_exceeded")
            or name == "concurrent_idempotent_requests"
        )
        if not retryable or attempt == ATTEMPTS:
            raise CrescaError(res.status_code, name, body.get("message", res.reason))
        retry_after = res.headers.get("retry-after")
        time.sleep(float(retry_after) if retry_after else backoff(attempt))
```

Using it:

```python
from html import escape
from cresca import cresca

sent = cresca(
    "/emails",
    {
        "from": "Acme <hello@acme.com>",
        "to": user.email,
        "subject": "Welcome to Acme",
        "html": f"<p>Hi {escape(user.first_name)}, your account is ready.</p>",
    },
    idempotency_key=f"welcome/{user.id}",
)
print(sent["id"])
```

`html.escape` keeps a name like `<b>Ana</b>` from changing your markup. Templates escape values for you.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

```python
cresca(
    "/emails",
    {
        "to": user.email,
        "template": {
            "id": "password-reset",  # the template's alias or id
            "variables": {"NAME": user.first_name, "RESET_LINK": reset_url},
        },
    },
    idempotency_key=f"password-reset/{reset_request.id}",
)
```

## Add attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

```python
import base64
from pathlib import Path

pdf = Path("invoices/inv-2026-0042.pdf").read_bytes()

cresca(
    "/emails",
    {
        "from": "Acme Billing <billing@acme.com>",
        "to": customer.email,
        "subject": "Your invoice INV-2026-0042",
        "html": "<p>Thanks for your payment. Your invoice is attached.</p>",
        "attachments": [
            {
                "filename": "inv-2026-0042.pdf",
                "content": base64.b64encode(pdf).decode(),
                "content_type": "application/pdf",
            }
        ],
    },
    idempotency_key="invoice/inv-2026-0042",
)
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```python
emails = [
    {
        "from": "Acme <hello@acme.com>",
        "to": user.email,
        "subject": "Your weekly summary",
        "html": render_summary(user),
    }
    for user in users
]

for i in range(0, len(emails), 100):
    result = cresca(
        "/emails/batch",
        emails[i : i + 100],
        idempotency_key=f"weekly-summary/2026-10-27/{i // 100}",
    )
    print(f"Batch {i // 100}: {len(result['data'])} emails accepted")
```

## Send email from FastAPI or Flask without blocking

The helper is synchronous, which is what you want in a script, a Celery task or an RQ job. In a web request, move the send out of the response path so a slow network never slows your page. In FastAPI that is `BackgroundTasks`:

```python
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel

from cresca import cresca

app = FastAPI()

class Signup(BaseModel):
    email: str
    first_name: str

@app.post("/signup", status_code=201)
def signup(data: Signup, background_tasks: BackgroundTasks):
    user = create_user(data)  # your own code
    background_tasks.add_task(
        cresca,
        "/emails",
        {"to": user.email, "template": {"id": "welcome", "variables": {"NAME": user.first_name}}},
        idempotency_key=f"welcome/{user.id}",
    )
    return {"id": user.id}
```

In Flask, call `cresca()` from a Celery or RQ task rather than the view. Using Django? Read [send email with Django](https://www.cresca.xyz/blog/send-email-django), which plugs Cresca into `send_mail()`.

## smtplib or an email API?

Python's built-in `smtplib` sends email over SMTP and is fine for a script that talks to a mail server you run. For an application, an API removes the moving parts: no SMTP login, no TLS negotiation, no connection that drops halfway, JSON errors with a stable `name`, and idempotency keys for safe retries. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown here.

## Errors to handle when you send email with Python

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send an email with Python without SMTP?

Use an email API. With Cresca you send a POST request with requests to https://api.cresca.xyz/api/v1/emails, with your API key in the Authorization header and a JSON body containing from, to, subject and html. You get back an email id, and Cresca delivers it from your verified domain.

### Is there an official Cresca SDK for Python?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in Python works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### Can I send email asynchronously with httpx or aiohttp?

Yes. The API does not care which client you use. Replace the requests session with httpx.AsyncClient, make cresca() an async function and use await asyncio.sleep() for the backoff. The retry rules and the idempotency key stay exactly the same.

### How do I send HTML email with Python?

Put the HTML in the html field of the JSON body. Cresca generates the plain-text version automatically, or you can send your own in the text field. Escape any user input with html.escape, or use a Cresca template, which escapes variables for you.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Python in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
