Integration
Send email from Django with SNDR.
Send transactional email from Django using the SNDR HTTP API directly. No native Python SDK; the patterns below use the requests library for sends and Django's hmac primitives for webhook verification.
There's no official Python SDK yet — and you don't need one. SNDR's API is a single POST /v1/send with a typed JSON body and a Bearer token, which requests handles cleanly. The patterns below show how to wrap the API in a small service module, queue sends through Celery (so transactional emails survive a request failure), and verify incoming webhooks with constant-time HMAC.
Setup
Install + configure
# requests is the only runtime dependency for the send path.
pip install requests
# Add it to requirements.txt / pyproject.toml as appropriate.# .env (loaded via django-environ, python-dotenv, or your favorite)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...
SNDR_FROM_ADDRESS=hello@yourdomain.com
SNDR_API_BASE=https://api.sndr.shRecipes
The patterns you actually need.
Configure settings
Pull env vars in settings.py once. Reading os.environ scattered through view code is hard to mock in tests.
# settings.py import os SNDR_API_KEY = os.environ['SNDR_API_KEY'] SNDR_WEBHOOK_SECRET = os.environ.get('SNDR_WEBHOOK_SECRET', '') SNDR_FROM_ADDRESS = os.environ.get('SNDR_FROM_ADDRESS', 'hello@yourdomain.com') SNDR_API_BASE = os.environ.get('SNDR_API_BASE', 'https://api.sndr.sh')Service module
A small module isolates the HTTP details. Reuse a Session for connection pooling — meaningful on Python's relatively heavy TLS setup.
# core/sndr.py import time import uuid from typing import Any import requests from django.conf import settings _session: requests.Session | None = None def _session_singleton() -> requests.Session: global _session if _session is None: _session = requests.Session() return _session class SndrError(Exception): def __init__(self, code: str, message: str, status: int, request_id: str | None = None): super().__init__(message) self.code = code self.status = status self.request_id = request_id def sndr_send( payload: dict[str, Any], *, idempotency_key: str | None = None, max_attempts: int = 3, ) -> dict[str, Any]: s = _session_singleton() key = idempotency_key or str(uuid.uuid4()) url = f"{settings.SNDR_API_BASE}/v1/send" headers = { 'Authorization': f'Bearer {settings.SNDR_API_KEY}', 'Content-Type': 'application/json', 'Idempotency-Key': key, } last_err: Exception | None = None for attempt in range(max_attempts): try: r = s.post(url, json=payload, headers=headers, timeout=30) if r.status_code < 400: return r.json() if r.status_code in (429, 500, 502, 503, 504) and attempt < max_attempts - 1: # Honour Retry-After if present; otherwise exponential backoff. ra = r.headers.get('Retry-After') delay = float(ra) if ra and ra.isdigit() else min(5, 0.25 * (2 ** attempt)) time.sleep(delay) continue err = r.json().get('error', {}) if 'application/json' in r.headers.get('content-type', '') else {} raise SndrError( code=err.get('code', 'unknown'), message=err.get('message', f'HTTP {r.status_code}'), status=r.status_code, request_id=err.get('request_id'), ) except requests.exceptions.RequestException as e: last_err = e if attempt < max_attempts - 1: time.sleep(min(5, 0.25 * (2 ** attempt))) else: raise SndrError('network_error', str(e), 0) from e raise SndrError('unknown', 'send failed', 0) from last_errSend from a view
Class-based view for symmetry with the rest of Django. For non-blocking sends, route through Celery (next recipe).
# views.py from django.conf import settings from django.http import JsonResponse from django.views import View from django.views.decorators.http import require_POST import json from core.sndr import sndr_send, SndrError @require_POST def send_welcome(request): body = json.loads(request.body) try: result = sndr_send({ 'from': settings.SNDR_FROM_ADDRESS, 'to': body['email'], 'subject': f"Welcome, {body['name']}", 'html': f"<p>Thanks for joining us, {body['name']}.</p>", }) return JsonResponse({'id': result['id']}) except SndrError as e: return JsonResponse( {'error': {'code': e.code, 'message': str(e)}}, status=e.status if e.status > 0 else 502, )Queue via Celery (recommended)
Transactional sends should survive a request failure. A Celery task with autoretry_for gives you that. Pair with a stable per-resource idempotency key (e.g. order ID).
# tasks.py from celery import shared_task from core.sndr import sndr_send, SndrError @shared_task( bind=True, max_retries=5, autoretry_for=(SndrError,), retry_backoff=True, retry_backoff_max=900, retry_jitter=True, ) def send_transactional(self, payload: dict, idempotency_key: str): return sndr_send(payload, idempotency_key=idempotency_key) # Dispatch from anywhere: send_transactional.delay( { 'from': 'hello@yourdomain.com', 'to': user.email, 'subject': 'Order confirmation', 'html': render_to_string('emails/order_confirmation.html', {'order': order}), }, idempotency_key=f'order-{order.id}', )Verify an incoming webhook
Webhook receivers must be CSRF-exempt (they're not browser-initiated). Compare HMACs with hmac.compare_digest for constant time. Reject stale timestamps.
# views.py import hmac import hashlib import json import time from django.conf import settings from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @csrf_exempt @require_POST def sndr_webhook(request): raw = request.body header = request.headers.get('X-Sndr-Signature', '') t = v1 = None for part in header.split(','): eq = part.find('=') if eq < 0: continue k, v = part[:eq].strip(), part[eq + 1:].strip() if k == 't': t = v elif k == 'v1': v1 = v if not t or not v1: return HttpResponse('bad signature', status=400) # Replay defense: reject timestamps older than 5 minutes. try: if abs(time.time() - int(t)) > 300: return HttpResponse('stale', status=400) except ValueError: return HttpResponse('bad signature', status=400) expected = hmac.new( settings.SNDR_WEBHOOK_SECRET.encode('utf-8'), f'{t}.'.encode('utf-8') + raw, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, v1): return HttpResponse('bad signature', status=400) event = json.loads(raw) match event['type']: case 'email.delivered': pass # event['data']: {'email_id', 'to': [...]} case 'email.bounced': pass # event['data']: {'email_id', 'error'} case 'email.unsubscribed': pass # event['data']: {'addresses': [...]} return HttpResponse('ok') # urls.py from django.urls import path from . import views urlpatterns = [ path('webhooks/sndr/', views.sndr_webhook), ]
Django FAQ
Is there an official Python SDK?
Not yet. The HTTP API is small enough that a 60-line wrapper module covers the full send + retry + error surface. If a community SDK appears we will link to it from the docs.
Why not use Django's built-in send_mail / EmailMessage?
You can — SNDR speaks SMTP too. The HTTP API is faster, exposes idempotency keys, returns structured error codes, and works on serverless platforms (Vercel Python, Lambda) where SMTP is awkward. Use send_mail when you want Django's template integration; use sndr_send when you want the API's full feature surface.
How do I add Idempotency-Key from a model?
Stable per-resource keys are best — e.g. f'order-confirmation-{order.id}' or f'password-reset-{user.id}-{hour}'. The same key reused across retries makes the second send a no-op server-side.
How do I avoid blocking the request on the send call?
Always queue sends. Use Celery (shown above), Django-Q, RQ, or Dramatiq. Calling SNDR synchronously inside a view ties response latency to the email provider's response, which violates one of the rules of healthy server design.
Ship your first Django email in 5 minutes.
Free tier includes 3,000 emails / month. No card required.
Start building
Your next message starts here.
Connect your domain, create an API key, and send with SNDR.