Send email with Django through Volanea by making a server-side JSON request to the transactional send endpoint. This guide uses Django’s normal synchronous request flow plus Python’s requests package—no provider-specific Django backend or fictional SDK required.
What you will build
You will add a small email service to a Django project that:
- installs the only extra dependency it needs;
- loads a Volanea secret key from the environment rather than source control;
- posts one transactional email to Volanea’s REST API;
- sends both plain-text and HTML content;
- uses an idempotency key so a retry does not create duplicate messages;
- returns a clear Django response for successful and failed requests.
The resulting integration is deliberately small. Django remains responsible for your application logic, templates, authentication, and request handling. Volanea receives a properly authenticated API request, processes the message through its transactional pipeline, and returns an API response that your application can log or act on.
This approach is useful when you want explicit control over the outbound request. You do not need to replace Django’s email abstractions globally, modify every installed app, or depend on a package that may lag behind Django or the provider API. The code is also easy to move into a background worker later because it is an ordinary Python function with a defined input and output.
Prerequisites
Before adding code, make sure you have the following:
- A Django project running in a Python virtual environment.
- A Volanea secret key. Keep this key on the server only; never expose it in browser JavaScript, a mobile app, or a public repository.
- A sending domain configured and verified in Volanea. The address in the
fromfield must use that sending domain. - A recipient address you control for initial testing.
- Permission to send the transactional message. Do not use a transactional endpoint to send unsolicited promotional mail.
Volanea’s send API accepts a single message request at POST /v1/send. The endpoint supports sending to one address or a small recipient list, while the separate batch endpoint is intended for larger personalized sends. For the first integration, send to one recipient so that a failed recipient, invalid sender, or malformed payload is straightforward to identify.
A test secret key beginning with sk_test_ is useful while building. Test-mode sends are designed for safely validating an integration without delivering an email. When you are ready to deliver to real recipients, place the appropriate live secret in the deployment environment and run the same application code.
Install the Python dependency
This guide calls the REST API directly. Install requests inside the same virtual environment that runs Django:
pip install requests
If you track application dependencies in requirements.txt, record the dependency after installation:
pip freeze | grep '^requests==' >> requirements.txt
For a new Django project, the basic setup might look like this:
python -m venv .venv
source .venv/bin/activate
pip install django requests
On Windows PowerShell, activate the environment with:
.venv\Scripts\Activate.ps1
Do not install an unverified package named after the provider just to make this example shorter. The HTTP request is small, inspectable, and based on the REST API. requests is the only package added by this integration, and it handles the JSON encoding, response parsing, request timeout, and HTTP exceptions used below.
Configure the Volanea API key
Store the key in an environment variable named VOLANEA_API_KEY. Do not put the key directly in settings.py, a committed .env file, a Django template, or a frontend build variable.
For a local macOS or Linux shell session:
export VOLANEA_API_KEY="sk_test_replace_with_your_key"
For PowerShell:
$env:VOLANEA_API_KEY="sk_test_replace_with_your_key"
For production, configure the equivalent environment variable using your hosting platform’s secrets manager or deployment configuration. Restart the Django process after adding or rotating an environment variable; a long-running application will not automatically reload its process environment.
Add the configuration to settings.py:
# settings.py
import os
VOLANEA_API_KEY = os.environ.get("VOLANEA_API_KEY")
VOLANEA_API_BASE_URL = "https://api.volanea.com"
VOLANEA_DEFAULT_FROM_EMAIL = "Example App <notifications@your-verified-domain.com>"
Using os.environ.get() lets Django start in contexts where sending email is not required, such as documentation builds or certain management commands. The mail service below checks for the missing value before it makes a network call. If every environment running this project must have a key, you may instead use os.environ["VOLANEA_API_KEY"] to fail at startup.
The VOLANEA_DEFAULT_FROM_EMAIL value is application configuration, not a user-controlled input. Never construct a sender address from a form field or an account profile. Keeping the sender fixed to a verified domain makes sender identity predictable and prevents accidental attempts to send from an unauthorized domain.
Create a reusable Volanea email service
Create an application if you do not already have one for communication-related logic:
python manage.py startapp notifications
Then create notifications/email.py. This module holds the outbound API call so your views, signals, commands, and background jobs can all reuse the same implementation.
# notifications/email.py
from __future__ import annotations
import uuid
from typing import Any
import requests
from django.conf import settings
class VolaneaEmailError(Exception):
"""Raised when Volanea cannot accept a transactional email request."""
def send_transactional_email(
*,
to_email: str,
subject: str,
text: str,
html: str,
) -> dict[str, Any]:
"""Send one transactional email through Volanea's REST API."""
if not settings.VOLANEA_API_KEY:
raise VolaneaEmailError(
"VOLANEA_API_KEY is not configured in the Django environment."
)
payload = {
"from": settings.VOLANEA_DEFAULT_FROM_EMAIL,
"to": [to_email],
"subject": subject,
"text": text,
"html": html,
}
headers = {
"Authorization": f"Bearer {settings.VOLANEA_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
# Generate one key for this logical send. Reuse the same key only when
# retrying this exact message after an uncertain network outcome.
"Idempotency-Key": str(uuid.uuid4()),
}
try:
response = requests.post(
f"{settings.VOLANEA_API_BASE_URL}/v1/send",
json=payload,
headers=headers,
timeout=10,
)
except requests.Timeout as exc:
raise VolaneaEmailError(
"Timed out while contacting the Volanea email API."
) from exc
except requests.RequestException as exc:
raise VolaneaEmailError(
"Could not connect to the Volanea email API."
) from exc
if not response.ok:
try:
error_body = response.json()
except ValueError:
error_body = {"raw_response": response.text}
raise VolaneaEmailError(
f"Volanea API returned HTTP {response.status_code}: {error_body}"
)
try:
return response.json()
except ValueError as exc:
raise VolaneaEmailError(
"Volanea returned a successful response that was not valid JSON."
) from exc
A few choices in this module are intentional:
json=payloadmakesrequestsserialize the Python dictionary as JSON. Do not pass the dictionary throughdata=unless you deliberately serialize it yourself.Content-Type: application/jsontells the API how to interpret the request body.Accept: application/jsonmakes the response format explicit.timeout=10prevents a broken network path from holding a Django worker indefinitely. A timeout is not a guarantee that the provider did not receive the request, which is why idempotency matters.response.oktreats any non-2xx status as a failure. Do not assume that a response body with an error message means the message was accepted.
The send payload includes both text and html. The plain-text part supports recipients whose clients do not render HTML and gives the message a sensible fallback. The HTML part can include a simple transactional layout, but keep it self-contained: email clients have limited CSS support, commonly strip scripts, and may alter markup.
Send one transactional email from a Django view
The following view gives you a complete copy-pasteable example. It sends an account welcome message to the currently authenticated user. In a real application, call this after the account creation transaction has committed rather than every time a user opens a page.
Create or update notifications/views.py:
# notifications/views.py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .email import VolaneaEmailError, send_transactional_email
@require_POST
@login_required
def send_welcome_email(request):
user = request.user
try:
result = send_transactional_email(
to_email=user.email,
subject="Welcome to Example App",
text=(
f"Hi {user.get_username()},\n\n"
"Thanks for creating an Example App account. "
"You can now sign in and get started.\n"
),
html=(
"<!doctype html>"
"<html>"
"<body>"
f"<h1>Welcome, {user.get_username()}!</h1>"
"<p>Thanks for creating an Example App account.</p>"
"<p>You can now sign in and get started.</p>"
"</body>"
"</html>"
),
)
except VolaneaEmailError as exc:
return JsonResponse(
{"status": "error", "message": str(exc)},
status=502,
)
return JsonResponse({"status": "queued", "email": result}, status=202)
Add a route for the view:
# notifications/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("send-welcome-email/", views.send_welcome_email, name="send_welcome_email"),
]
Include those URLs from the project URL configuration:
# project/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("notifications/", include("notifications.urls")),
]
With a logged-in user that has a valid email address, submit a POST request to /notifications/send-welcome-email/. The function builds the email, calls Volanea, and returns the API result only after Volanea accepts the request.
For a first test, use a controlled recipient rather than collecting a destination address in the request body. That prevents your endpoint from becoming an open relay. If you later add an endpoint that accepts a recipient, require authentication, enforce authorization rules, validate the address, rate-limit the route, and ensure the caller may contact that person.
Understand request acceptance and idempotency
A successful API response means Volanea accepted your send request for processing. It does not mean the recipient has opened the email, nor does it necessarily prove final mailbox placement. Delivery involves additional systems after the initial API request: the sending infrastructure, receiving mail server, spam and policy checks, and the recipient’s mailbox provider.
The Idempotency-Key header protects a particularly important failure case. Imagine this sequence:
- Django posts the message to the API.
- The API accepts and queues it.
- The network connection closes before Django receives the response.
- Your application sees a timeout and retries.
Without an idempotency key, the retry can create a second identical message. With an idempotency key, you reuse the key for the retry of that exact logical operation so the API can recognize it as the same send.
The sample creates a new UUID inside the service function because it demonstrates one initial send. In a production workflow that retries automatically, create and persist the idempotency key alongside the business event. For example, an OrderReceipt record can hold a unique key derived from the order ID and receipt version. Every retry then uses that stored key, while a later legitimate resend gets a new key.
Avoid retrying all failures indiscriminately. A network timeout or temporary 5xx response may be retryable. A 400-level validation error generally requires fixing the payload. An authentication failure requires replacing or correcting the secret. Retrying an invalid sender or malformed recipient dozens of times does not make it valid and can hide the real configuration problem.
Render safer HTML with Django templates
Building HTML through string concatenation works for a minimal demo, but Django templates are a better choice once your email contains customer data, links, conditions, or repeated layout. Django escapes template variables by default, which helps prevent a name or other user-supplied value from becoming untrusted HTML.
Create an email template at notifications/templates/notifications/emails/welcome.html:
<!doctype html>
<html lang="en">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
<h1>Welcome, {{ username }}!</h1>
<p>Thanks for creating an Example App account.</p>
<p>
<a href="{{ dashboard_url }}">Open your dashboard</a>
</p>
</body>
</html>
Then render the template before calling the service:
from django.template.loader import render_to_string
html = render_to_string(
"notifications/emails/welcome.html",
{
"username": user.get_username(),
"dashboard_url": "https://example.com/dashboard/",
},
)
result = send_transactional_email(
to_email=user.email,
subject="Welcome to Example App",
text=(
f"Welcome, {user.get_username()}! "
"Visit https://example.com/dashboard/ to get started."
),
html=html,
)
Keep the text alternative meaningful instead of duplicating raw HTML. Include the central action and any critical information in the text version. This is especially important for password resets, verification messages, receipts, security alerts, and account notices where the recipient needs the information even if HTML is disabled.
Treat data placed into URLs with care. A dashboard URL can be a trusted, server-generated constant. A URL that includes a token or user-generated destination needs validation before it reaches the template. Do not mark arbitrary user input as safe in an email template, and do not place a secret API key in a template context.
Common errors
Authentication failures: 401 or 403
An authentication error usually means the key is missing, malformed, revoked, from the wrong environment, or not being sent in the authorization header expected by the API. First, confirm that the process running Django has VOLANEA_API_KEY set. Running export VOLANEA_API_KEY=... in one shell does not configure a separately started Docker container, systemd service, Gunicorn worker, or cloud deployment.
Do not print a full API key in logs while debugging. Log only whether a key is present and, if needed, a small non-sensitive identifier maintained outside the secret itself. If the key was accidentally committed, revoke or rotate it immediately rather than merely deleting it from the latest commit.
Wrong content type or malformed JSON: 400 or 415
The request body must be JSON. In requests, use json=payload, not data=payload. The sample also sends Content-Type: application/json; removing that header or posting form-encoded data can make the server reject the body.
Avoid manually calling json.dumps(payload) while also using json=payload, because that sends a JSON string rather than the intended JSON object. If you choose to serialize manually, use data=json.dumps(payload) and set the content type yourself—but the simpler json=payload pattern avoids that extra failure mode.
Invalid sender or unverified domain
The sender in VOLANEA_DEFAULT_FROM_EMAIL must use a verified sending domain. A recipient cannot fix a sender-domain problem, and switching the display name does not change the underlying address. Verify the domain’s DNS configuration in Volanea, then use a sender address from that verified domain.
Keep sender selection in configuration. If different product areas need different sender addresses, define a small allowlist in Django settings rather than accepting arbitrary from values from requests, admin forms, or database rows without validation.
Missing recipient, invalid address, or suppressed address
Validate that user.email is present before attempting the send. Django’s user model does not necessarily guarantee an email value is populated or verified. For a signup flow, collect and validate the address before sending an email that the recipient must receive.
A valid-looking address can still be unavailable for sending because it previously bounced, complained, unsubscribed, or was otherwise suppressed. That is not a transient error to bypass with repeated retries. Respect suppression results and offer an appropriate product flow, such as asking the user to update their address.
Async and await mistakes
requests is synchronous. Do not write await requests.post(...); it will not turn the call into an asynchronous operation. The view in this guide is a normal synchronous Django view, so calling the synchronous send_transactional_email() function directly is correct.
If you use an async def Django view, do not block the event loop with a long synchronous network request. Either call the synchronous function through Django’s async-to-sync tooling or use an HTTP client designed for async code. For email triggered by important user actions, a background job is usually the more reliable design: commit the business transaction, queue an email task, and let a worker make the API call with retry policy and observability.
Timeout followed by an apparent duplicate
A request timeout means Django did not receive a response in time; it does not reliably tell you whether the provider received the message. Retry the same logical send with the same idempotency key, not a newly generated key. Persisting the key with the business event is what makes safe retries possible across worker restarts.
HTML renders differently in inboxes
Email HTML is not browser HTML. Many inboxes limit CSS, remove scripts, block external assets, and interpret layout differently. Use simple table-compatible layouts where needed, inline essential styles, include a text alternative, and test important transactional messages across the inboxes your customers use.
Production considerations
The example returns a 502 Bad Gateway when the upstream email API cannot be reached or rejects the request. That is useful for a demonstration, but choose your production behavior based on the business event.
For a password reset or login verification email, returning a generic success response to the user can avoid leaking account information. Internally, however, you should log the failed event and alert or retry as appropriate. For an order receipt, it may be better to commit the order first and deliver the receipt through a background worker so a temporary mail-provider outage does not block checkout.
A practical production flow has these properties:
- The database transaction that represents the business event completes before email delivery is attempted.
- An outbox row or background task records the message intent, recipient, template version, and idempotency key.
- A worker performs the API call with bounded retries for transient failures.
- Logs record a request or message identifier without recording secrets or unnecessary email content.
- Webhook processing updates internal status when delivery-related events occur.
- Alerting distinguishes provider connectivity issues from invalid application configuration.
Do not put a requests.post() call in a database model’s save() method. Model saves can occur from the admin, management commands, shell sessions, data migrations, tests, and code paths that did not intend to send an email. Trigger delivery explicitly from an application service, domain event handler, or queued task.
Next steps
After the first message is sending, move message status out of guesswork and into an observable workflow with webhooks. A webhook endpoint receives delivery-related events from Volanea so your application can record events such as accepted, delivered, bounced, complained, or opened when those signals are available. Verify webhook signatures before trusting an incoming request, return a fast 2xx response after persistence, and process slower follow-up work asynchronously.
For repeated message designs, use reusable templates rather than embedding every HTML document in Python. Templates let your application send a named layout with per-message variables, which keeps presentation changes separate from business logic and reduces the risk of inconsistent transactional copy. Review the email API reference and setup guides before adding template sends, webhook endpoints, batch delivery, or other API features.
Finally, add tests around your own service boundary. Unit tests can mock the HTTP request and assert that the payload uses the correct sender, recipient, subject, text fallback, HTML content, JSON headers, timeout, and idempotency behavior. Keep one controlled integration test in a non-production environment to confirm that credentials, domain configuration, and API connectivity are still working.
FAQ
Do I need a Django-specific Volanea package to send email?
No. This guide uses the REST API directly with requests, so it does not depend on a provider-specific Django backend. That keeps the integration explicit and avoids inventing SDK methods that do not exist.
Should I use a test key or a live key first?
Start with a test key such as sk_test_... while developing. Test mode lets you validate the request flow safely. Use a live secret only after the sending domain and application behavior have been verified.
Why send both text and HTML email content?
The text part is a readable fallback for recipients and clients that do not render HTML. It also makes critical transactional information available without relying on a complex email layout.
Can I call the send function from a Celery task or Django worker?
Yes. The send_transactional_email() function is independent of the HTTP view, so it can be called from a Celery task, management command, scheduled job, or another server-side worker. Persist and reuse the idempotency key when implementing retries.
Does a successful API response mean the email reached the inbox?
No. It means the API accepted the message for processing. Use delivery events and webhook handling to observe what happens after acceptance, and remember that inbox placement is determined by recipient-side systems as well as sender configuration.