# Send Email with Django Using an Email API

> Send email with Django through an email API: a custom EMAIL_BACKEND so send_mail and password resets use Cresca, plus templates, idempotency and Celery.

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

Django already has everything you need to **send email with Django**: `send_mail()`, `EmailMessage`, password reset emails and admin error emails. What it needs is a delivery service. Instead of SMTP settings, this tutorial adds a small email backend that sends through the Cresca email API, so all of Django's built-in email, and your own, goes out from your verified domain with no change to the code that calls it. Then it shows when to call the API directly for idempotent, template-based sends.

**TL;DR**

- Write a 60-line `CrescaEmailBackend` and set `EMAIL_BACKEND` to it.
- `send_mail()`, `EmailMultiAlternatives` and Django's password reset emails then send through Cresca.
- For important emails, call the API directly with an `Idempotency-Key` or a Cresca template.
- Send from a Celery task so requests never wait on email.

## 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`.

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

## Step 1: a small API client

This is the client from the [Python tutorial](https://www.cresca.xyz/blog/send-email-python), with retries for network errors, `429` and `5xx` responses:

```python
# myproject/cresca.py
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 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:
            if attempt == ATTEMPTS:
                raise
            time.sleep(2 ** (attempt - 1) + random.random() / 4)
            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 2 ** (attempt - 1) + random.random() / 4)
```

## Step 2: send email with Django through a custom backend

An email backend is a class with a `send_messages()` method. This one turns each `EmailMessage` into an API request: subject, recipients, the text body, the HTML alternative if there is one, and file attachments as base64.

```python
# myproject/cresca_backend.py
import base64

from django.core.mail.backends.base import BaseEmailBackend

from .cresca import cresca

class CrescaEmailBackend(BaseEmailBackend):
    """Sends Django email through the Cresca API."""

    def send_messages(self, email_messages):
        sent = 0
        for message in email_messages:
            try:
                cresca("/emails", self._payload(message))
                sent += 1
            except Exception:
                if not self.fail_silently:
                    raise
        return sent

    def _payload(self, message):
        payload = {
            "from": message.from_email,
            "to": message.to,
            "subject": message.subject,
        }
        if message.cc:
            payload["cc"] = message.cc
        if message.bcc:
            payload["bcc"] = message.bcc
        if message.reply_to:
            payload["reply_to"] = message.reply_to

        if message.content_subtype == "html":
            payload["html"] = message.body
        else:
            payload["text"] = message.body
        for content, mimetype in getattr(message, "alternatives", []):
            if mimetype == "text/html":
                payload["html"] = content

        attachments = []
        for attachment in message.attachments:
            if not isinstance(attachment, tuple):
                continue  # MIMEBase attachments are not supported here
            filename, content, mimetype = attachment
            if isinstance(content, str):
                content = content.encode()
            attachments.append({
                "filename": filename,
                "content": base64.b64encode(content).decode(),
                "content_type": mimetype or "application/octet-stream",
            })
        if attachments:
            payload["attachments"] = attachments
        return payload
```

Point Django at it in settings.py:

```python
# settings.py
EMAIL_BACKEND = "myproject.cresca_backend.CrescaEmailBackend"
DEFAULT_FROM_EMAIL = "Acme <hello@acme.com>"  # an active sender on your verified domain
SERVER_EMAIL = "Acme Errors <errors@acme.com>"  # sender for admin error emails
```

Keep the console backend (`django.core.mail.backends.console.EmailBackend`) in development settings if you do not want to send real email locally.

## Step 3: send email as usual

Nothing changes in the code that sends email:

```python
from django.core.mail import EmailMultiAlternatives, send_mail
from django.template.loader import render_to_string

send_mail(
    "Welcome to Acme",
    "Hi Ana, your account is ready.",
    None,  # uses DEFAULT_FROM_EMAIL
    ["ana@example.com"],
)

context = {"user": user, "order": order}
message = EmailMultiAlternatives(
    subject=f"Your order #{order.number} is confirmed",
    body=render_to_string("emails/order_confirmed.txt", context),
    to=[user.email],
    reply_to=["support@acme.com"],
)
message.attach_alternative(render_to_string("emails/order_confirmed.html", context), "text/html")
message.attach(f"receipt-{order.number}.pdf", order.receipt_pdf_bytes(), "application/pdf")
message.send()
```

Django's own emails come along for free: `PasswordResetView`, `mail_admins()` and the error emails Django sends to `ADMINS` all go through the backend. Django templates auto-escape variables, so user input in an HTML template is safe.

## When to call the API directly

Django's mail API has no place for two things Cresca supports, so call the client directly when you need them:

- **Idempotency.** `send_mail()` cannot pass an `Idempotency-Key`. For emails that must never be duplicated, such as receipts, send with a key built from the event. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Cresca templates.** Design the email in Cresca, publish it, and send only the alias and values.

```python
from myproject.cresca import cresca

cresca(
    "/emails",
    {
        "to": user.email,
        "template": {
            "id": "order-confirmed",  # the template's alias or id
            "variables": {"NAME": user.first_name, "ORDER_NUMBER": order.number},
        },
    },
    idempotency_key=f"order-confirmed/{order.number}",
)
```

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).

## Send from a Celery task

Both the backend and the client are synchronous, which suits a worker. Move the send out of the request so users never wait on the network, and let Celery retry if Cresca stays unavailable:

```python
# myproject/tasks.py
from celery import shared_task

from .cresca import cresca

@shared_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=5)
def send_welcome_email(user_id, email, first_name):
    cresca(
        "/emails",
        {"to": email, "template": {"id": "welcome", "variables": {"NAME": first_name}}},
        idempotency_key=f"welcome/{user_id}",
    )

# In a view, after the user is saved:
# transaction.on_commit(lambda: send_welcome_email.delay(user.id, user.email, user.first_name))
```

`transaction.on_commit` waits until the user row is committed, so the task never runs for a signup that rolled back. Because of the idempotency key, a Celery retry cannot send a second welcome email.

## Attachments and batches

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.

`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.

For a newsletter-style send to many users, build the list of payloads and call `cresca("/emails/batch", chunk, idempotency_key=...)` for every 100, as in the [Python tutorial](https://www.cresca.xyz/blog/send-email-python). Django's `send_mass_mail()` works with the backend too, but it sends one request per message.

## Errors to handle when you send email with Django

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).

With the backend, an API error raises `CrescaError` from `send_mail()`, unless you pass `fail_silently=True`.

## 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 email with Django without SMTP?

Set EMAIL\_BACKEND to a custom backend that calls an email API. The backend in this tutorial converts each EmailMessage into a request to https://api.cresca.xyz/api/v1/emails, so send\_mail(), EmailMultiAlternatives and Django's password reset emails all send through Cresca without SMTP settings.

### Do Django's password reset emails work with the backend?

Yes. PasswordResetView sends through whatever EMAIL\_BACKEND is configured, including the HTML version if you set html\_email\_template\_name. Make sure DEFAULT\_FROM\_EMAIL is an active sender on your verified domain.

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

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

### Can I use django-anymail instead?

django-anymail supports many email providers but does not include Cresca, so you would still need a backend like the one in this tutorial. It is short enough to keep in your project and change as you need.

### 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) · [Python](https://www.cresca.xyz/blog/send-email-python) · [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) · [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 Django 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).
