Python webhook HMAC verification in FastAPI and Django
Verify a Sume webhook in Python: HMAC-SHA256 over timestamp.raw_body, split the signature header on commas, compare in constant time, answer fast.

To verify a Sume webhook in Python, read the raw request body as bytes, compute HMAC-SHA256 over <timestamp>.<raw_body> with your signing secret, and accept the delivery when any sume-v1= entry in x-sume-webhook-signature matches in a constant-time comparison, after rejecting a timestamp more than five minutes off. FastAPI gives you the bytes with await request.body(); Django gives you request.body.
Sume facts come from Run webhooks, Webhooks, Verifying webhooks, and the Cookbook; framework facts come from the Python, Starlette, FastAPI, and Django docs under Sources. All were read on 2026-09-27. Sume has no FastAPI or Django integration, and its SDK, @sume-com/sdk, is a TypeScript client, so each receiver below is a plain route that checks the signature with the standard library's hmac and hashlib. The TypeScript receiver is in Signed webhooks for video runs.
What exactly does Sume sign?
Every delivery, run or generation job, uses the same scheme and the same workspace secret, so one verifier covers both.
| Piece | Value |
|---|---|
| Algorithm | HMAC-SHA256, hex-encoded |
| Signed string | <timestamp>.<raw_body>: the timestamp header, a dot, then the raw body |
x-sume-webhook-timestamp | Unix time in seconds, such as 1785000000 |
x-sume-webhook-signature | sume-v1=<hex>; for 24 hours after a secret rotation, sume-v1=<new>,sume-v1=<old> |
| Replay window | Reject timestamps outside it; five minutes is a reasonable default |
| Secret | Dashboard Webhooks tab, or GET /v1/webhooks/signing-secret with account:read; store it as SUME_COM_WEBHOOK_SIGNING_SECRET |
How do I write the verifier in Python?
One function serves both frameworks. It rejects a timestamp outside the replay window before computing the HMAC. Then it splits the header on commas and keeps the sume-v1= entries, because during a rotation window Sume sends one entry per live secret, newest first, and a check that compares the whole header fails on every delivery in that window. It compares the hex value after sume-v1= with hmac.compare_digest, which Python's docs describe as designed to prevent timing analysis, and it compares every entry.
import hashlib, hmac, os, time
SECRET = os.environ["SUME_COM_WEBHOOK_SIGNING_SECRET"].encode()
TOLERANCE_SECONDS = 300 # five minutes
def verify(raw: bytes, timestamp: str | None, header: str | None) -> bool:
if not SECRET or not timestamp or not header:
return False # an empty secret would verify a forged signature
try:
ts = int(timestamp)
except ValueError:
return False
if abs(time.time() - ts) > TOLERANCE_SECONDS:
return False # outside the replay window: no HMAC computed
digest = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
expected = digest.encode()
matched = False
for entry in header.split(","): # one entry per live secret during a rotation
version, _, value = entry.strip().partition("=")
if version == "sume-v1" and hmac.compare_digest(value.encode(), expected):
matched = True # keep comparing the remaining entries
return matchedHow do I receive it in FastAPI?
FastAPI's Request comes directly from Starlette, where await request.body() returns the body as bytes and headers are case-insensitive. BackgroundTasks runs a task after returning a response, which fits a short follow-up; for heavy work, FastAPI points to bigger tools such as Celery with a message queue.
import json
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
@app.post("/hooks/sume")
async def sume_webhook(request: Request, background_tasks: BackgroundTasks):
raw = await request.body() # bytes, before any JSON parsing
if not verify(raw, request.headers.get("x-sume-webhook-timestamp"),
request.headers.get("x-sume-webhook-signature")):
return Response(status_code=401)
event = json.loads(raw)
if event.get("event") != "format.run.terminal":
return Response(status_code=204) # unknown event: 204, not 500
if record_once(event["request_id"], raw): # your insert-or-ignore
background_tasks.add_task(handle, event) # runs after the response
return Response(status_code=204)How do I receive it in Django?
Django's CSRF middleware answers a POST that lacks a valid CSRF token with a 403, and Sume cannot send one, so mark the view @csrf_exempt; the HMAC check takes the token's place. request.body is the raw bytestring. Read it before anything else reads the stream, because accessing body after request.read() raises RawPostDataException. Size is not a problem: Django's default DATA_UPLOAD_MAX_MEMORY_SIZE is 2.5 MB, above the 1 MiB up to which Sume inlines a receipt.
import json
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # Sume sends no CSRF token; the HMAC authenticates the request
def sume_webhook(request):
if request.method != "POST":
return HttpResponse(status=405)
raw = request.body # raw bytestring, read before anything reads the stream
if not verify(raw, request.headers.get("x-sume-webhook-timestamp"),
request.headers.get("x-sume-webhook-signature")):
return HttpResponse(status=401)
event = json.loads(raw)
if event.get("event") == "format.run.terminal" and record_once(event["request_id"], raw):
enqueue(event) # your task queue; answer within Sume's 10 s
return HttpResponse(status=204)What should the handler do after verifying?
The delivery rules are the same in any language, and Signed webhooks for video runs lists them. In short: record the event durably and answer a 2xx within the 10-second attempt window, because a receiver that renders video before responding gets retried while it works, up to 10 attempts in total. Dedupe on request_id for runs, which repeats on every retry, or on job_id for generation jobs. When a receipt over 1 MiB arrives with payload: null, fetch it from error.result_url with your API key.
Sources
- Run webhooks
- Webhooks
- Verifying webhooks
- Cookbook
- TypeScript SDK
- Python: hmac (read 2026-09-27)
- Starlette: Requests (read 2026-09-27)
- FastAPI: Using the Request Directly (read 2026-09-27)
- FastAPI: Background Tasks (read 2026-09-27)
- Django 5.2: CSRF protection (read 2026-09-27)
- Django 5.2: Request and response objects (read 2026-09-27)
- Django 5.2: Settings (read 2026-09-27)
Related posts
More in Integrations
- Shopify product video AI API with products/create webhooks
Answer Shopify's products/create webhook within five seconds, run a Sume Format from a queue, then upload the MP4 to Shopify with a staged upload.
- Slack bot to generate video: a slash command with Sume's API
Ack Slack's slash command within 3000 ms, submit POST /v1/videos with a callback_url, then post the URL to response_url when Sume's webhook lands.
- Cline MCP remote server: add Sume's hosted MCP
Add Sume's hosted MCP server to Cline as a remote server with type streamableHttp and an API-key header, and keep paid tools out of autoApprove.
- VS Code remote MCP server: add Sume's hosted MCP in mcp.json
Add Sume's hosted MCP server to VS Code with an http entry in mcp.json, see what Sume's OAuth consent grants, and choose which tools chat can call.
Written by Sume