Send email from Django with an email API
Send email from Django without an SMTP backend: one HTTPS call in a view or a task, an id back, and delivery reported on a webhook.
# orders/views.py — pip install httpximport os
import httpxfrom django.http import JsonResponse
def send_shipping_email(request, order_id): response = httpx.post( "https://api.rasket.com/emails", headers={ "Authorization": "Bearer " + os.environ["RASKET_API_KEY"], "User-Agent": "acme-billing/1.0", # Same key on every retry of this order. "Idempotency-Key": f"order-{order_id}", }, json={ "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your order has shipped", "html": "<p>Order 1042 shipped today.</p>", }, ) response.raise_for_status()
return JsonResponse({"id": response.json()["id"]})What sending from Django actually involves
To send email from Django you do not have to configure EMAIL_BACKEND and an SMTP host at all. Call the API from the view, the task or the signal handler that knows the mail should go out, and you get an id back you can store on the order. The sample below is a view that posts with httpx.
Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.
What you get with the key
Five things that arrive with the first request rather than with a later plan.
Idempotent sends
Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.
Signed webhooks you can replay
Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.
Inbound mail on your own domain
Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.
Templates with typed variables
Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.
Campaigns and automations
Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.
How to send email from Django
Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.
- 1
Create an API key
Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.
- 2
Add a sending domain
POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.
- 3
Publish the DNS records
Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.
- 4
Verify the domain
POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.
- 5
Send your first email from Django
Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.
- 6
Subscribe a webhook and check its signature
POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.
Verify webhooks in Django
Use request.body, which is the raw bytes, and read it before anything touches request.POST or json.loads. The view needs csrf_exempt, because the request comes from us rather than from a form on your site, and the signature is the authentication. Pass the bytes and the headers to the svix package's Webhook(secret).verify().
The five steps a verifier performs, in seven languages, are on the webhooks reference.
The Django mail backend vs an API
To send email from Django you either use the mail framework the project already ships — send_mail, EmailMultiAlternatives and an EMAIL_BACKEND that speaks SMTP to a server you configure — or you make one HTTPS request to an email API from a view or a task. Both get a message to a mail server. The difference is everything around that handoff, and it is the difference this guide is about.
| An HTTP API | django.core.mail | |
|---|---|---|
| Installation | An HTTP library such as requests | Built in |
| Configuration | A key in the environment | Host, port, user, password, TLS |
| Round trips | One | Several, by design |
| What you get back | A message id to store | A count of messages sent |
| MIME assembly | Not yours | Yours, via the message classes |
| Domain authentication | Generated and checked for you | Yours to publish and watch |
| Suppressions | Kept and enforced at send time | None |
| Bounces | Typed events on a webhook | Messages you parse |
| Testing | Stub one HTTP call | locmem |
| Best for | Product mail you have to account for | A relay you already run, or a console backend in development |
The mail framework is good at what it does. The locmem backend in particular is a pleasure to test against, and the console backend is the right development default. What it does not do is remember that an address bounced last week, refuse a send from a domain that fails authentication, or tell you the message was delivered. With a bare relay those are yours to build. The API versus SMTP comparison goes through the trade in full; the Django stack page is the short version. The rest of this page is the Django email API integration end to end: the key, the send, the task, the templates and the webhook.
Install requests and read the key from settings
pip install requests svix
# or, in a uv projectuv add requests svixComing soon: the rasket package is not published yet. Until it is, two packages do the job: requests for the API calls, and svix for the webhook section later. Neither has a heavy dependency tree. Then put the key where Django puts configuration.
# settings.pyimport os
# os.environ[...] rather than .get(): a missing variable fails at# start-up, which is the moment you want to hear about it.RASKET_API_KEY = os.environ["RASKET_API_KEY"]RASKET_WEBHOOK_SECRET = os.environ["RASKET_WEBHOOK_SECRET"]The subscript rather than .get is deliberate. A key that is missing should stop manage.py runserver with a KeyError, not send None as a bearer token from a worker at three in the morning. Locally the variable comes from your shell or a dotenv file; in production it comes from the host’s own environment. It is never a literal in settings.py, and it is never committed.
Send from a view
The send is one POST /emails, and a small helper keeps the key, the headers and the rate-limit answer in one place. Every later sample calls it.
# orders/rasket.pyimport requestsfrom django.conf import settings
URL = "https://api.rasket.com/emails"
class RateLimited(Exception): def __init__(self, retry_after): super().__init__(f"rate limited for {retry_after}s") self.retry_after = retry_after
def send_email(message, idempotency_key): response = requests.post( URL, headers={ "authorization": f"Bearer {settings.RASKET_API_KEY}", "content-type": "application/json", # Required: a request without one is refused. "user-agent": "acme-billing/1.0", "idempotency-key": idempotency_key, }, json=message, timeout=10, ) if response.status_code == 429: raise RateLimited(int(response.headers["retry-after"])) response.raise_for_status() return response.json()["id"]# orders/views.pyfrom django.http import JsonResponse
from .rasket import send_email
def send_shipping_email(request, order_id): email_id = send_email( { "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your order has shipped", "html": "<p>Order 1042 shipped today.</p>", }, # Same key on every retry of this order. idempotency_key=f"order-{order_id}", )
return JsonResponse({"id": email_id})Three details carry the weight. The request names your application in its user-agent, because a request without one is refused, and a name of your own is what lets support tell your billing worker from your web process. The timeout turns a hung connection into an error you can act on. And the idempotency_key is derived from the order, which is the thing that caused the send, rather than generated fresh. A repeat of a keyed request inside 24 hours returns the original response and sends nothing; the idempotency guide has the exact rules.
The from address has to be on a verified domain
A send from a domain that has not passed verification is refused before anything leaves. Publishing the records takes a few minutes and the verification walkthrough covers the panel behaviour that trips most people up. Until then, test against a managed address.
Send from a Celery task
A Celery task is the right home for a send in Django: it already has retries, backoff and a worker that is not your web process. The only thing to get right is which failures to retry, and with what key.
# orders/tasks.pyimport requestsfrom celery import shared_task
from .rasket import RateLimited, send_email
@shared_task( bind=True, autoretry_for=(requests.ConnectionError, requests.Timeout), retry_backoff=True, retry_backoff_max=600, retry_jitter=True, max_retries=5,)def send_shipping_email(self, order_id): try: return send_email( { "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your order has shipped", "html": "<p>Order 1042 shipped today.</p>", }, # The SAME key on every attempt. A retry after a timeout # replays the first answer instead of sending twice. idempotency_key=f"order-{order_id}", ) except RateLimited as error: raise self.retry(countdown=error.retry_after) # Any other 4xx raises HTTPError: it will fail the same way next time.The decorator does most of the work. autoretry_for names the one kind of failure that is safe to retry blindly: a ConnectionError or a Timeout means nothing answered, so you do not know whether the message went. retry_backoff spaces the attempts out, and retry_jitter stops a hundred tasks retrying in the same second. Celery’s task guide documents each option.
The comment inside the call is the reason the whole thing is safe. Because every attempt sends order-1042 as its key, an attempt that timed out after the API had in fact accepted the message gets the original response back rather than a second email. Without that, backoff is a machine for sending duplicates politely. A 4xx is the other case: the API answered and said no. A validation error will say no identically forever, so the task lets it fail; only a 429 is worth waiting on, and its retry-after header carries the number of seconds to wait. The limit is ten requests a second per team, shared by every key, and the rate limits guide has the headers and the error body.
# orders/views.pyfrom django.db import transaction
from .tasks import send_shipping_email
def ship(request, order_id): order = mark_shipped(order_id) # Enqueue after the commit, so a worker that starts at once # finds the row it was told about. transaction.on_commit(lambda: send_shipping_email.delay(order.id)) return JsonResponse({"status": "shipped"})transaction.on_commit is the small thing that avoids a large confusion. Without it, a fast worker can pick the task up before the view’s transaction commits and find no order, or the old state of one.
Templates: Django or Rasket
You have two template engines to choose from, and they suit different situations. Django already knows how to turn a context into HTML, and an API call is happy to carry whatever it produces. Rasket also stores templates of its own, rendered on the API side from variables you pass.
from django.template.loader import render_to_string
# 1. Django renders the HTML, the API sends it.html = render_to_string("orders/shipped.html", {"order": order})
send_email( { "from": "Acme <orders@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": f"Order {order.number} has shipped", "html": html, }, idempotency_key=f"order-{order.id}",)
# 2. A published Rasket template renders on the API side.# No html or text in the request; from and subject can# come from the template as well.send_email( { "to": ["ronald.williams@example.com"], "template": { "id": "order-shipped", "variables": {"order_number": order.number, "carrier": order.carrier}, }, }, idempotency_key=f"order-{order.id}",)- Render in Django when the markup depends on your models, your translations or logic a simple substitution cannot express.
render_to_stringgives you a string; the request carries it ashtml. Add atextpart when you can. - Send a Rasket template when someone other than a developer edits the copy, or when you want every send to record which version it used. The request sets
templatewith an id or alias and avariablesmap, and leaveshtmlandtextout — the two cannot be combined. The template must be published, every declared variable is a string or a number, and a key you do not pass takes its fallback.
Either way the idempotency key is the same, because the send is the same send. What changes is where the body is rendered.
Webhooks in a Django view
A send returns 200 when the message is accepted and recorded, not when it is delivered. Delivery, bounces, complaints and opens arrive later as signed HTTPS requests to a URL you register. In Django that URL is a view with three properties the framework will fight you on.
# hooks/views.py — pip install svixfrom django.conf import settingsfrom django.http import HttpResponse, HttpResponseBadRequestfrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_POSTfrom svix.webhooks import Webhook, WebhookVerificationError
from .tasks import record_event
@csrf_exempt@require_POSTdef rasket_webhook(request): # request.body is the raw bytes. Never json.loads() first: # the signature is over what arrived, not what you re-serialise. try: event = Webhook(settings.RASKET_WEBHOOK_SECRET).verify( request.body, dict(request.headers) ) except WebhookVerificationError: return HttpResponseBadRequest("invalid signature")
# Answer first, work later. The task dedupes on svix-id, so a # redelivery of the same event is a no-op. record_event.delay(request.headers["svix-id"], event) return HttpResponse(status=200)- It is CSRF exempt. The CSRF middleware rejects any POST without a token it issued, and a webhook sender has none.
@csrf_exemptturns the check off for this one view; the signature is the authentication instead. - It reads
request.bodyfirst. The signature is an HMAC over the raw bytes. Parsing the JSON and serialising it again produces a different string, and a different string has a different signature. Read the bytes, verify, then use the parsed event the verifier returns. - It answers fast. A handler that does its work inline and times out will be sent the same event again. Hand the event to a task keyed on
svix-id, answer200, and let the task dedupe.
Wire it in urls.py with path("hooks/rasket", rasket_webhook) and register the same path in the dashboard. The verifier checks the timestamp as well as the digest, so a request replayed more than five minutes later is refused too. The webhooks article covers retries, ordering and replay, and the events reference lists every event a send can produce.
Sources
- Sending email — Django Software Foundation, read 2026-09-16
- Tasks: Retrying — Celery Project, read 2026-09-16
Questions about Django
Do I need an SMTP server to send email from Django?
No. Calling an HTTPS API skips EMAIL_BACKEND entirely: no host, no port, no TLS settings, one key in the environment. A project that must keep using send_mail() can instead point Django's SMTP backend at smtp.rasket.com on port 465 with EMAIL_USE_SSL, the username rasket and the key.
Where do I keep the API key in a Django project?
In the environment, read in settings.py as os.environ["RASKET_API_KEY"] alongside SECRET_KEY, so a deployment missing it fails at startup. Do not put it in a settings module you commit, and do not read it in a template.
Should I send inside the request or in a background task?
In a task, once you have more than a handful. The call is a network round trip, so doing it inside the view adds that latency to the response; Celery, django-q or any queue is the right place. Whichever you choose, give the send an Idempotency-Key so a retried task cannot send twice.
How do I stop a retry from sending the same email twice?
Derive the Idempotency-Key from the row the mail is about — the order id, the invoice number — rather than generating one per attempt. For 24 hours the same key with the same payload returns the first response, so a task that is retried after a timeout is safe.
How do I verify a Rasket webhook in Django?
Write a csrf_exempt view, read request.body as the first statement, and pass it with request.headers to the svix package's verify(). Store the event and answer 200; answer 400 when verification raises. A view that reaches for request.POST first has already lost the bytes the signature covers.
Can I keep Django's EMAIL_BACKEND and send through the API?
Yes, through the SMTP relay: point Django's SMTP backend at smtp.rasket.com on port 465 with EMAIL_USE_SSL, the username rasket and your key, and each message becomes an API send. This guide calls the API from a Celery task instead, which hands back the message id. You can keep send_mail with the console or locmem backend for local development and tests; the two coexist in one project without conflict.
Should I create the client once per process or once per call?
Either works. The sample on the stack page builds it inside the view for brevity, and the Celery sample builds it once at import time so every task in the worker shares it. What matters is that the key comes from settings or os.environ in both cases, and that the user agent is set in one place.
Do I need a client library to send from Django?
No. The send is one HTTPS request, and the guide makes it with requests from a helper of about twenty lines that sets the key, the User-Agent and the Idempotency-Key in one place. The rasket package for Python is coming soon; until it is published, the helper is the whole integration.
How do I test a view or a task that sends?
Patch the send function with unittest.mock and assert on what it was called with: the recipient, the from address and above all the idempotency key, which is the part that goes wrong quietly. A test that reaches the real API is an integration test and belongs behind a flag with a test-mode key.
Should I render the email with Django templates or with a Rasket template?
Render in Django when the markup depends on your models, translations or logic a substitution cannot express, and send the result as html. Use a Rasket template when someone other than a developer edits the copy or when you want every send to record which version it used. A request carries one or the other, never both.
Why does my webhook view answer 403?
Almost always the CSRF middleware, which rejects a POST without a token it issued. The @csrf_exempt decorator has to sit on the view Django actually calls: on a function view it is the outermost decorator, and on a class-based view it goes on dispatch through method_decorator. The signature check is what authenticates the request instead.
Keep reading
Make your first send
Create a key, verify a domain, and post your first message from Django. The free plan does not expire.