Verify a Sume job webhook in Python, and refuse an empty secret
A Python verifier for Sume job webhooks: HMAC SHA 256 over timestamp.body, a 5-minute window, rotation-safe, and a hard refusal when the secret is empty.

Sume signs each job webhook with HMAC SHA 256 over '<timestamp>.<raw_body>' and sends the result as 'sume-v1=<hex>' in x-sume-webhook-signature. The Python verifier below checks the timestamp is within five minutes, accepts any matching entry during a secret rotation, and raises on an empty secret so a missing env var never turns into 'everything verifies'.
What Sume sends
Send mode: 'webhook' with a public HTTPS webhook_url when you submit a lip-sync job. When the job ends, Sume POSTs job.completed, job.failed or job.canceled. Two headers matter: x-sume-webhook-timestamp and x-sume-webhook-signature.
Read your signing secret on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with an API key that has account:read. The docs name the env var SUME_COM_WEBHOOK_SIGNING_SECRET.
The verifier
Use the raw request body bytes, not a re-serialized object. Any change in whitespace changes the hash.
import hashlib, hmac, os, time
def verify(raw_body: bytes, timestamp: str, header: str,
secret: str, tolerance: int = 300) -> bool:
if not secret:
raise ValueError("webhook secret is empty")
try:
ts = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - ts) > tolerance:
return False
signed = f"{ts}.".encode() + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
expected = f"sume-v1={digest}"
ok = False
for entry in header.split(","):
if hmac.compare_digest(entry.strip(), expected):
ok = True
return ok
secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
body = b'{"event":"job.completed"}'
ts = str(int(time.time()))
sig = "sume-v1=" + hmac.new(secret.encode() or b"x", f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
print(verify(body, ts, sig, secret or "x"))Why each check is there
- Empty secret: an HMAC with an empty key still produces a valid-looking hash, so refuse it up front.
- Timestamp window: stops an old captured request from being replayed.
- Comma-separated entries: during a rotation the header carries one signature per live secret, newest first.
- compare_digest: a constant-time comparison.
Delivery behavior to design for
Return any 2xx after you store the event durably. Failed deliveries are retried up to 10 attempts in total, 30 seconds apart by default, with a 10-second timeout per attempt. Use job_id as your idempotency key because the same event can arrive twice.
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled |
| Attempts | Up to 10 in total |
| Spacing | 30 seconds by default, fixed |
| Timeout | 10 seconds per attempt |
| Replay window | 5 minutes is a reasonable default |
What to do
Keep a status poll as a fallback for events that never arrive. A delivery can fail after all attempts while the job itself still reached its real terminal state, so the poll is the safe route to the result.
Sources
Related posts
More in Developers
- LiveKit avatar join latency and playback latency: what to measure
LiveKit avatar plugins emit join latency and playback latency. Learn what each means before you pick a live avatar, and what a rendered clip has instead.
- LiveKit avatar providers with Node.js support vs a Sume Node job
LiveKit lists eight avatar providers with Node.js plugins and eight Python-only ones. For a clip rather than a live room, Sume works from Node with fetch.
- Load-test Sume job polling with k6: reads have their own budget
A k6 script that polls one finished Sume job from 5 virtual users, and what 4,800 reads a minute on Free means for any 429 you see.
- Log usage.cost for every Sume video job in Python and total a batch
A finished Sume video job returns usage.cost in USD. Log it with the job id and model, and sum it for a batch. Here is a short Python script that does it.
Written by Sume