How to send email from Django with an API, a Celery task and a webhook view
Published Updated 10 min readBy the Rasket team

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 | One package | 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 rasket and read the key from settings
pip install rasket svix
# or, in a uv projectuv add rasket svixTwo packages: the client, 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.
- Add and verify a sending domain — Add the domain the mail will come from, publish the DNS records Rasket generates for it, and wait for verification to pass. A send from an unverified domain is refused.
- Install the client and the webhook verifier — Run pip install rasket svix, or uv add rasket svix in a uv project. The first is the API client; the second verifies webhook signatures later in the guide.
- Read the key into settings — Create an API key in the dashboard, set it as RASKET_API_KEY, and read it in settings.py with os.environ[...] so a missing variable stops the process at start-up rather than at send time.
- Send from a view — Build the client with a user agent, call emails.send with an idempotency key derived from the order, and store result.body["id"] against your own row.
- Move the send into a Celery task — Declare autoretry_for=(RasketConnectionError,) with backoff and jitter, pass the same idempotency key on every attempt, and enqueue the task from transaction.on_commit.
- Receive delivery events in a webhook view — Add a @csrf_exempt view, verify request.body with the svix package before anything parses it, answer 200 at once, and record the event from a task keyed on svix-id.
Send from a view
This is the sample the stack page shows, unchanged, so the two pages describe one client.
# orders/views.py — pip install rasketimport os
from django.http import JsonResponsefrom rasket import Rasket
def send_shipping_email(request, order_id): rasket = Rasket( api_key=os.environ["RASKET_API_KEY"], user_agent="acme-billing/1.0", )
result = rasket.emails.send( { "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": result.body["id"]})Three details carry the weight. The client is built with a user_agent, because a request without one is refused, and what you pass is appended to the client’s own so support can tell your billing worker from your web process. The response is an envelope: result.body is the parsed JSON, so the id you store is result.body["id"], and result.rate_limit says where the team is in its window. 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.pyfrom celery import shared_taskfrom django.conf import settingsfrom rasket import Rasket, RasketApiError, RasketConnectionError
rasket = Rasket( api_key=settings.RASKET_API_KEY, user_agent="acme-billing/1.0",)
@shared_task( bind=True, autoretry_for=(RasketConnectionError,), retry_backoff=True, retry_backoff_max=600, retry_jitter=True, max_retries=5,)def send_shipping_email(self, order_id): try: result = rasket.emails.send( { "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 RasketApiError as error: if error.name == "rate_limit_exceeded" and error.rate_limit: wait = error.rate_limit.retry_after_seconds or 1 raise self.retry(countdown=wait) raise # any other 4xx will fail the same way next time
return result.body["id"]The decorator does most of the work. autoretry_for names the one exception that is safe to retry blindly: RasketConnectionError 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 RasketApiError 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 rate_limit_exceeded is worth waiting on, and the error 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})
rasket.emails.send( { "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.rasket.emails.send( { "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.
Frequently asked questions
Can I keep Django's EMAIL_BACKEND and send through the API?
Not through a backend setting: the API is HTTPS rather than SMTP, so there is nothing for EMAIL_BACKEND to point at. Call the client from a Celery task instead, and keep send_mail with the console or locmem backend for local development and tests if you like. 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.
How do I test a view or a task that sends?
Patch the send method 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.
What happens if Celery retries after the API already accepted the message?
Nothing is sent twice, provided every attempt carries the same idempotency key. The API remembers a key for 24 hours and answers a repeat with the original response, so the retry gets the first email's id back. That is why the key is derived from the order rather than generated inside the task.
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.
Sources
- Sending email — Django Software Foundation, read 2026-09-16
- Tasks: Retrying — Celery Project, read 2026-09-16
Related
- Send email from Django with an API — Send email from Django without an SMTP backend: one typed call in a view or a task, an id back, and delivery reported on a webhook.
- Python SDK — The rasket package on PyPI: the Node client's methods, in snake_case, over httpx.
- Idempotency — Retry a send without sending it twice.
- Send email from Python: smtplib or an API — Send email from Python without an SMTP conversation: the client library, a plain requests call, where the send belongs in Django and FastAPI, and retries.
- Webhooks — Every delivery, bounce, complaint, open and click posted to your endpoint, signed with a timestamp, retried on failure and replayable from the dashboard.