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

Installbash
# requests is the only runtime dependency for the send path.
pip install requests
# Add it to requirements.txt / pyproject.toml as appropriate.
Environmentenv
# .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.sh

Recipes

The patterns you actually need.

  1. 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')
  2. 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_err
  3. Send 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,
            )
  4. 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}',
    )
  5. 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.