Sending Email in Python (2026): urllib, httpx, requests, Django, and Webhook Verification
Send transactional email in Python using urllib, httpx async with asyncio.Semaphore for concurrent batch sends, and requests; includes a Django custom email backend, Flask webhook view, and HMAC-SHA256 signature verification with hmac.compare_digest.

Python ships everything you need for transactional email in its standard library: urllib or http.client for HTTP, hmac and hashlib for HMAC-SHA256, and hmac.compare_digest for timing-safe comparison. In practice, most Python projects also reach for httpx or requests. This guide covers all three, plus Django integration, idempotent retries, file attachments, concurrent batch sends with asyncio, and webhook signature verification.
Prerequisites
- Python 3.10 or newer
- A Rovela API key from the dashboard
httpxfor the async sections (install withpip install httpx)- Django 4.2 or newer for the Django email backend section
Standard Library: Sending a Single Email
urllib.request is enough for a simple send. Keep the API key in an environment variable.
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass, field
ROVELA_URL = "https://api.rovela.dev/emails"
@dataclass
class RovelaError(Exception):
status_code: int
name: str
message: str
def __str__(self) -> str:
return f"[{self.status_code}] {self.name}: {self.message}"
def send_email(
api_key: str,
payload: dict,
idempotency_key: str = "",
) -> str:
body = json.dumps(payload).encode()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(ROVELA_URL, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
return data["id"]
except urllib.error.HTTPError as exc:
data = json.loads(exc.read())
raise RovelaError(exc.code, data.get("name", "Unknown"), data.get("message", ""))
Usage:
api_key = os.environ["ROVELA_API_KEY"]
email_id = send_email(api_key, {
"from": "hello@yourcompany.com",
"to": ["customer@example.com"],
"subject": "Welcome aboard",
"html": "<h1>You are in.</h1><p>Click below to get started.</p>",
})
print(f"sent: {email_id}")
Retries with Idempotency-Key
The Rovela API accepts an Idempotency-Key header on POST /emails. Within a 24-hour window, repeating the same key returns the cached response instead of sending a duplicate. Generate the key once before the loop so every retry reuses it. Retry only on 429 Too Many Requests.
import time
import uuid
def send_with_retry(api_key: str, payload: dict, max_attempts: int = 3) -> str:
# Generated once; reused on every retry so the send is idempotent.
idempotency_key = str(uuid.uuid4())
for attempt in range(max_attempts):
try:
return send_email(api_key, payload, idempotency_key)
except RovelaError as exc:
if exc.status_code != 429:
raise
time.sleep(2 ** attempt)
raise RuntimeError(f"exceeded {max_attempts} attempts")
File Attachments
Add an attachments list to the payload. Each item needs a filename, base64-encoded content, and a content_type.
import base64
from pathlib import Path
def build_invoice_email(recipient: str, invoice_path: Path) -> dict:
content = base64.b64encode(invoice_path.read_bytes()).decode()
return {
"from": "billing@yourcompany.com",
"to": [recipient],
"subject": "Your invoice",
"html": "<p>Please find your invoice attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": content,
"content_type": "application/pdf",
}
],
}
httpx: Async Concurrent Batch Sends
httpx.AsyncClient pairs with asyncio.gather for concurrent sends. Use asyncio.Semaphore to cap concurrency so you stay under the Rovela rate limit. Generate each idempotency key before gathering so retries inside send_with_retry_async stay stable.
import asyncio
import httpx
async def send_one(
client: httpx.AsyncClient,
api_key: str,
payload: dict,
idempotency_key: str,
max_attempts: int = 3,
) -> str:
for attempt in range(max_attempts):
resp = await client.post(
ROVELA_URL,
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": idempotency_key,
},
timeout=10,
)
if resp.status_code == 429:
await asyncio.sleep(2 ** attempt)
continue
resp.raise_for_status()
return resp.json()["id"]
raise RuntimeError(f"exceeded {max_attempts} attempts")
async def send_batch(api_key: str, payloads: list[dict], concurrency: int = 5) -> list[str]:
sem = asyncio.Semaphore(concurrency)
# Generate idempotency keys before launching tasks so each retry reuses its key.
keys = [str(uuid.uuid4()) for _ in payloads]
async def guarded(payload: dict, key: str) -> str:
async with sem:
async with httpx.AsyncClient() as client:
return await send_one(client, api_key, payload, key)
return await asyncio.gather(*(guarded(p, k) for p, k in zip(payloads, keys)))
Call it from a synchronous context with asyncio.run:
payloads = [
{"from": "news@yourcompany.com", "to": [addr], "subject": "Weekly digest", "html": html}
for addr, html in recipients
]
ids = asyncio.run(send_batch(api_key, payloads))
The batch endpoint (POST /emails/batch) accepts up to 100 recipients in a single call and is more efficient when every recipient shares the same subject and body. Reserve the concurrent approach above for personalised sends where each payload differs.
requests: Synchronous Client
For projects already using requests:
import requests
def send_email_requests(api_key: str, payload: dict, idempotency_key: str = "") -> str:
headers = {"Authorization": f"Bearer {api_key}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
resp = requests.post(ROVELA_URL, json=payload, headers=headers, timeout=10)
if resp.status_code >= 400:
data = resp.json()
raise RovelaError(resp.status_code, data.get("name", ""), data.get("message", ""))
return resp.json()["id"]
Django: Custom Email Backend
Django's email layer lets you swap the backend without changing call sites. A custom backend translates Django's EmailMessage objects to Rovela API calls, so existing send_mail and EmailMultiAlternatives usage keeps working.
# myapp/backends.py
import base64
import json
import urllib.error
import urllib.request
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail import EmailMessage
ROVELA_URL = "https://api.rovela.dev/emails"
class RovelaEmailBackend(BaseEmailBackend):
def __init__(self, api_key: str | None = None, **kwargs):
super().__init__(**kwargs)
from django.conf import settings
self.api_key = api_key or settings.ROVELA_API_KEY
def send_messages(self, email_messages: list[EmailMessage]) -> int:
sent = 0
for msg in email_messages:
try:
self._send_one(msg)
sent += 1
except Exception:
if not self.fail_silently:
raise
return sent
def _send_one(self, msg: EmailMessage) -> None:
payload: dict = {
"from": msg.from_email,
"to": list(msg.to),
"subject": msg.subject,
}
if msg.cc:
payload["cc"] = list(msg.cc)
if msg.bcc:
payload["bcc"] = list(msg.bcc)
if msg.reply_to:
payload["reply_to"] = msg.reply_to[0]
# Prefer HTML alternative when available.
html_body = None
for content, mimetype in getattr(msg, "alternatives", []):
if mimetype == "text/html":
html_body = content
break
if html_body:
payload["html"] = html_body
if msg.body:
payload["text"] = msg.body
attachments = []
for filename, content, mimetype in msg.attachments:
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
body = json.dumps(payload).encode()
req = urllib.request.Request(
ROVELA_URL,
data=body,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
pass
except urllib.error.HTTPError as exc:
data = json.loads(exc.read())
raise RovelaError(exc.code, data.get("name", ""), data.get("message", ""))
Configure it in settings.py:
EMAIL_BACKEND = "myapp.backends.RovelaEmailBackend"
ROVELA_API_KEY = env("ROVELA_API_KEY")
Existing call sites need no changes:
from django.core.mail import send_mail, EmailMultiAlternatives
# Plain text
send_mail("Subject", "Body text.", "from@example.com", ["to@example.com"])
# HTML with text fallback
msg = EmailMultiAlternatives("Subject", "Text fallback.", "from@example.com", ["to@example.com"])
msg.attach_alternative("<p>HTML body</p>", "text/html")
msg.send()
Webhook Signature Verification
Rovela signs webhook deliveries with HMAC-SHA256. The signature is in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. The signed string is the timestamp, a literal dot, then the raw request body: <timestamp>.<raw-body>.
Python's hmac.new computes the HMAC and hmac.compare_digest does the timing-safe comparison.
import hashlib
import hmac
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> dict:
if not signature_header or "," not in signature_header:
raise ValueError("missing or malformed x-resend-signature")
parts: dict[str, str] = {}
for segment in signature_header.split(","):
k, _, v = segment.partition("=")
parts[k] = v
timestamp = parts.get("t", "")
v1 = parts.get("v1", "")
if not timestamp or not v1:
raise ValueError("malformed x-resend-signature")
# signed string = "<timestamp>.<raw-body>"
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
raise ValueError("signature mismatch")
return json.loads(raw_body)
Django Webhook View
Read the raw body before Django's middleware processes it. request.body gives the exact bytes from the request stream.
# myapp/views.py
import json
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def rovela_webhook(request: HttpRequest) -> HttpResponse:
if request.method != "POST":
return HttpResponse(status=405)
secret = settings.ROVELA_WEBHOOK_SECRET
header = request.headers.get("x-resend-signature", "")
try:
event = verify_webhook(request.body, header, secret)
except ValueError:
return HttpResponse("Unauthorized", status=401)
# Dispatch to your task queue.
# handle_rovela_event.delay(event)
return HttpResponse(status=204)
Register the route in urls.py:
from django.urls import path
from myapp.views import rovela_webhook
urlpatterns = [
path("webhooks/rovela/", rovela_webhook),
]
Flask Webhook View
from flask import Flask, request, abort
import os
app = Flask(__name__)
@app.post("/webhooks/rovela")
def rovela_webhook():
secret = os.environ["ROVELA_WEBHOOK_SECRET"]
header = request.headers.get("x-resend-signature", "")
try:
event = verify_webhook(request.get_data(), header, secret)
except ValueError:
abort(401)
# handle event
return "", 204
request.get_data() returns the raw bytes before Flask's JSON parsing, which is what you need.
Production Checklist
- API key in environment: read from
ROVELA_API_KEY; never hard-code or commit it. - Idempotency key before the loop: call
uuid.uuid4()once before the retry loop, not inside it. - Retry only on 429: the API returns
{"statusCode":429,"name":"Too Many Requests"}when you exceed the send limit; retrying is safe because the idempotency key prevents duplicates. - Raw body for webhooks: use
request.body(Django) orrequest.get_data()(Flask) before any framework transformation; the signature covers the exact bytes Rovela sent. - Timing-safe comparison: always use
hmac.compare_digest, never==on the hex string. - Semaphore under the rate limit: cap
asyncio.Semaphoreconcurrency below your Rovela send limit divided by expected per-send latency. - Webhook secret in environment: store in
ROVELA_WEBHOOK_SECRETand rotate it in the dashboard and your deployment atomically.