Django webhook view for an AI video job: csrf_exempt and HMAC check

A Django view that verifies Sume's x-sume-webhook-signature over timestamp.raw_body, rejects an empty secret, and handles job.completed for a /v1/videos clip.

6 min readSume
All posts

A Django view that receives an AI video webhook needs three things: @csrf_exempt (the caller is a server, not a browser form), the raw bytes in request.body for the signature check, and a fast 2xx so the sender stops retrying. Sume signs the raw JSON body with HMAC SHA-256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex> (Sume webhooks guide, read 2026-10-06).

To get webhooks for a video, send callback_url (an HTTPS URL) on POST /v1/videos. Sume posts its standard job envelope when the job reaches job.completed, job.failed or job.canceled; there are no progress events.

What is the full view?

Django documents HttpRequest.body as the raw request body bytes and HttpRequest.headers as a case-insensitive mapping, so the view reads both directly (Django request reference, read 2026-10-06). Never parse the JSON first and re-serialize it; the HMAC is over the exact bytes that were sent.

import hashlib, hmac, json, os, time
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")

def verified(raw: bytes, ts: str, header: str) -> bool:
    if not SECRET or not ts.isdigit():
        return False
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(SECRET.encode(), ts.encode() + b"." + raw,
                   hashlib.sha256).hexdigest()
    want = f"sume-v1={mac}"
    return any(hmac.compare_digest(want, p.strip())
               for p in header.split(","))

@csrf_exempt
@require_POST
def sume_webhook(request):
    h = request.headers
    if not verified(request.body, h.get("x-sume-webhook-timestamp", ""),
                    h.get("x-sume-webhook-signature", "")):
        return HttpResponse(status=401)
    event = json.loads(request.body)
    if event["event"] == "job.completed":
        pass  # enqueue a download task keyed by event["job_id"]
    return HttpResponse(status=200)

Which details are easy to get wrong?

  • Empty secret: if the environment variable is unset, the code above returns 401 for everything instead of comparing against an empty key.
  • Rotation: during a signing-secret rotation the signature header carries one sume-v1= entry per live secret, comma separated, newest first. The any(...) over the split header accepts either.
  • Replay window: Sume's guide suggests five minutes, which is the 300 in the code.
  • Order of decorators: csrf_exempt outermost, so Django's CSRF middleware sees the exemption flag on the view it resolves (Django CSRF how-to, read 2026-10-06).
  • Do the work later: enqueue a task and return. The webhook is a delivery optimization; Sume's guide says to keep polling status_url as a backup for missed deliveries.

Where does the signing secret come from?

Reveal it on the Webhooks tab of the Sume dashboard, or read GET /v1/webhooks/signing-secret with an API key that has account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET; job webhooks and run webhooks share that one secret. Each delivery also carries x-sume-webhook-secret-fingerprint, so when a signature fails you can compare fingerprints with the dashboard instead of copying the secret around.

Duplicates are possible with any webhook sender, so key your handler on job_id. A SQLite insert-or-ignore on the job id is enough; in Django, a unique constraint on the job id column and get_or_create does the same.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume