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.

5 min readSume
All posts

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.

Webhook delivery facts (Sume docs, read 2026-10-05)
ItemValue
Eventsjob.completed, job.failed, job.canceled
AttemptsUp to 10 in total
Spacing30 seconds by default, fixed
Timeout10 seconds per attempt
Replay window5 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

All Developers posts

Written by Sume