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.

5 min readSume
All posts

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.

Webhook handler checklist (Sume docs read 2026-10-07)
StepRule
VerifyHMAC-SHA256 over <timestamp>.<raw_body>
FreshnessReject timestamps outside five minutes
DedupeKey on run_id; it repeats on retries
OrderingUse created_at, not request_id
Branchoutcome 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

All Formats posts

Written by Sume