Format run webhook receiver in Python: HMAC and a 5-minute window
Verify a Sume format.run.terminal webhook in Python: HMAC-SHA256 over timestamp.raw_body, stale-timestamp rejection, empty-secret refusal, run_id dedupe.

How do you verify a Sume Format run webhook in Python? Compute HMAC-SHA256 of the timestamp, a dot and the raw request body with your workspace signing secret, compare it with the x-sume-webhook-signature header, and reject timestamps older than five minutes. Verify before you parse the JSON.
A serialized series makes this matter: 40 episodes finishing overnight means 40 POSTs to your endpoint, some retried, and the handler must act on each run exactly once.
What arrives
If you send communication.webhook_url on create, Sume POSTs the terminal receipt once when the run completes or fails. A canceled or skipped run never delivers. The body has event (always format.run.terminal), request_id and run_id (equal, stable across retries), status (OK or ERROR), outcome (ok, degraded or error), created_at and a payload that is the same receipt as GET /v1/format-runs/{run_id}.
Three headers carry the proof: x-sume-webhook-timestamp, x-sume-webhook-signature (sume-v1=<hex>) and x-sume-webhook-secret-fingerprint. The secret is on the Webhooks tab of the dashboard or at GET /v1/webhooks/signing-secret for a key with account:read.
| Step | Rule |
|---|---|
| Verify | HMAC-SHA256 over <timestamp>.<raw_body> |
| Freshness | Reject timestamps outside five minutes |
| Dedupe | Key on run_id; it repeats on retries |
| Ordering | Use created_at, not request_id |
| Branch | outcome degraded means real media, output null |
The verifier
This stdlib verifier refuses an empty secret and runs as written; main() signs a sample body and checks it, then tampers with it. It accepts any of the comma-separated signatures, because Sume sends two for 24 hours after you rotate the secret.
import hashlib, hmac, json, os, time
def verify(raw: bytes, headers: dict, secret: str) -> bool:
if not secret:
raise ValueError("empty signing secret")
ts = headers.get("x-sume-webhook-timestamp", "")
sig = headers.get("x-sume-webhook-signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "sume-v1=" + mac
# during a 24 h rotation the header holds two comma-separated signatures
return any(hmac.compare_digest(expected, s.strip()) for s in sig.split(","))
def main() -> None:
secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "demo-secret")
body = json.dumps({"event": "format.run.terminal", "run_id": "arun_demo"}).encode()
ts = str(int(time.time()))
mac = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
headers = {"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": "sume-v1=" + mac}
print(verify(body, headers, secret))
print(verify(body + b" ", headers, secret))
main()After verification
Parse the body, look up run_id in a table of runs you already handled, and skip it if present. Then branch on outcome. A degraded run was billed and has real media in artifacts[], but output is null because the projection did not match your schema; read output_error and use the artifacts.
The TypeScript SDK ships verifyWebhook for the same check, and the Cookbook has complete receivers in Node and Python.
Operational checks
Test the receiver with a signed fixture before you trust it: sign a sample body with a known secret, then flip one byte and confirm the request is rejected. Read the raw bytes before any JSON parsing, since re-serializing changes the signature. Return a 2xx quickly and do the heavy work after, so retries do not pile up.
- Refuse to start if the secret is empty.
- Compare signatures with a constant-time function.
- Dedupe on run_id so a repeated delivery does nothing.
Failure and retry
Sume makes up to 10 attempts per delivery, and any non-2xx or a reply slower than 10 seconds counts as a failed attempt, which is why the dedupe step matters. Record the event in durable storage, answer, then do the work. If a delivery is exhausted, fetch the run from result_url or call the redeliver endpoint after you fix the receiver.
Sources
Related posts
More in Formats
- Format showcase field: judge a Format by a real output first
GET /v1/formats returns showcase, a verified sample output. Use it with description and io to pick a Sume Format before you spend credits on a run.
- Format vanity URL or skl_ invoke_url: store the opaque path
A Format has a vanity URL (handle/slug) and an opaque skl_ invoke_url. Renamed handles keep resolving for 90 days, but only the skl_ path survives every rename.
- Can a 4:5 or 2:3 video run in Google Ads? What the specs allow
Google Ads lists 16:9, 9:16 and 1:1 as primary and says ratios in between are allowed; a spec page also names 4:3, 2:3 and 4:5. Exact sizes for Sume Timeline.
- Hook line in instruction or input? Sume Format run for ad variants
Put the hook text in the instruction field, and put product data in input. Rules, limits and errors for varying an ad hook across Sume Format runs.
Written by Sume