Verify a Sume video callback signature in Python, empty secret refused
A short stdlib Python check for x-sume-webhook-signature on a /v1/videos callback: HMAC SHA-256 over timestamp.raw_body, rotated sume-v1 entries accepted.

Sume signs a video callback with HMAC SHA-256 over <timestamp>.<raw_body> and sends the result in x-sume-webhook-signature as sume-v1=<hex>, with the timestamp in x-sume-webhook-timestamp. A verifier must read the raw bytes, recompute the HMAC with your signing secret, compare in constant time, and refuse an empty secret instead of silently accepting every request.
What arrives on the callback
For POST /v1/videos, you send callback_url (HTTPS only) and Sume POSTs to it when the job reaches a terminal state. The payload is the standard Sume job webhook envelope, not the OpenRouter video.generation.* envelope. Facts below are from the Sume webhooks and video pages, read 2026-10-09.
| Item | Value |
|---|---|
| Signed bytes | timestamp, a dot, then the raw JSON body |
| Algorithm | HMAC SHA-256 |
| Timestamp header | x-sume-webhook-timestamp |
| Signature header | x-sume-webhook-signature: sume-v1=<hex> |
| During secret rotation | Comma-separated sume-v1 entries, newest first; accept any match |
| Replay window | Reject outside your tolerance; five minutes is the docs' default |
| Events | job.completed, job.failed, job.canceled (terminal only) |
The verifier
This is plain Python with no awaits. It raises on an empty secret, rejects stale or malformed timestamps, and compares every sume-v1= entry so a rotation does not break delivery.
import hashlib
import hmac
import time
def verify(raw: bytes, ts: str, header: str, secret: str, tol: int = 300) -> bool:
if not secret:
raise ValueError("signing secret is empty; refuse to verify")
try:
stamp = int(ts)
except (TypeError, ValueError):
return False
if abs(time.time() - stamp) > tol:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = mac.hexdigest()
for part in header.split(","):
part = part.strip()
if part.startswith("sume-v1=") and hmac.compare_digest(part[8:], want):
return True
return FalseTesting the verifier
Test three cases before you deploy. First, a good signature built with the same formula passes. Second, a body altered by one byte fails. Third, an empty secret raises, which proves the guard is in place. Add a fourth case with a timestamp 10 minutes old to see the replay window reject it.
Pass the raw request bytes to the function. Most web frameworks parse JSON before your handler sees it; if you re-serialise the parsed body, key order and spacing can change and the HMAC will not match. In a stdlib handler read Content-Length bytes from the socket; in a framework use the raw-body accessor.
Respond with a 2xx quickly and do the heavy work after. Return a non-2xx for a bad signature so the failure is visible in the delivery status. The webhook delivery states are pending, delivering, delivered, retrying, failed and exhausted, so a slow or failing endpoint is retried before it is marked exhausted.
Operational notes
Read the signing secret from the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret with an API key that has account:read; store it in an environment variable and fail the process at start-up if it is unset. Each delivery also carries x-sume-webhook-secret-fingerprint, which you can compare with the fingerprint next to the secret in the dashboard when a signature does not verify.
Keep polling as a fallback. A webhook can be retried or exhausted, so a job that you expected a callback for can be read at GET /v1/videos/{jobId}.
Sources
Related posts
More in Developers
- Verify a Sume webhook in Python with the standard library only
Check the sume-v1 HMAC in Python without a package: raw body, timestamp window, two signatures during rotation, and a refusal of an empty secret. 20 lines.
- The resolution enum has 8 values, but each Sume model takes 3 or 4
The /v1/videos resolution enum lists 360p to 4K. Wan, Seedance, H3, H3 Max and Omni each take a subset. Check supported_resolutions and what each extreme costs.
- Sume /v1/videos says cancelled, /v1/jobs says canceled: a status map
Two spellings and two vocabularies for the same Sume job: pending to cancelled on /v1/videos, queued to canceled on /v1/jobs. A table and a TypeScript mapping.
- Vidu 24-hour and FLUX 3 signed result links: copy the file first
Vidu result URLs last 24 hours and FLUX 3 signed URLs about 2 hours, or about 10 minutes by another line. Stream the Sume clip to disk and keep the job id.
Written by Sume