Python verifier for Sume webhooks: rotation header and empty secrets
A Python function that checks the sume-v1 HMAC over timestamp.raw_body, accepts either signature during rotation, and refuses an empty secret. Under 30 lines.

Sume signs each webhook with HMAC-SHA256 over <timestamp>.<raw_body> and sends it as x-sume-webhook-signature: sume-v1=<hex> with x-sume-webhook-timestamp. During a secret rotation the header has one entry per live secret, newest first, separated by commas, so a verifier must accept the delivery if any sume-v1= entry matches.
Two Python details cause most failures: hashing a re-serialized JSON body instead of the raw bytes, and passing an empty secret, which makes an HMAC that anyone can compute. The function below refuses an empty secret and compares every entry in constant time.
import hashlib, hmac, time
def verify_sume_webhook(raw_body: bytes, timestamp: str, header: str,
secret: str, tolerance: int = 300) -> bool:
if not secret:
raise ValueError("webhook signing secret is empty")
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(int(time.time()) - ts) > tolerance:
return False
digest = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
expected = f"sume-v1={digest}"
matched = False
for entry in header.split(","):
entry = entry.strip()
if entry.startswith("sume-v1=") and hmac.compare_digest(entry, expected):
matched = True
return matched
Where the inputs come from
Read the signing secret on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with a key that has account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker uses. The secret is derived for your workspace, so a valid signature proves Sume signed the delivery for you.
Job webhooks and run webhooks share one secret and one scheme, so this function covers both. Pass the body exactly as received. In Flask use request.get_data(), in FastAPI use await request.body(), before any JSON parsing.
| Header | Use |
|---|---|
| x-sume-webhook-timestamp | Seconds since epoch; reject outside your window (300 s is a good default) |
| x-sume-webhook-signature | sume-v1=<hex>, comma-separated during rotation, newest first |
| x-sume-webhook-secret-fingerprint | Names the new secret; compare with the dashboard when a check fails |
Rotation and replay
For 24 hours after a rotation Sume signs with both the new and previous secret. Upgrade the receiver before you rotate: a verifier that compares the header for equality fails on every delivery in the window. The fingerprint header names the new secret from the moment you rotate, which tells you what to move to, not which secrets are still accepted.
After verifying, return a fast 2xx and dedupe. Job webhooks use job_id as the key and run webhooks use request_id. Sume retries non-2xx and network errors, up to 10 attempts.
- Never log the secret. The fingerprint is the only safe value to paste into a ticket.
- Do not follow up with a redirect: Sume does not follow them on run webhooks, and a
3xxis a failed attempt. - A slow handler burns the 10-second attempt budget. Record the event, return, then process.
Testing the verifier
Write three tests. A body signed with the secret verifies. The same body with one changed byte fails. A header with two entries, sume-v1=<new>,sume-v1=<old>, verifies when either matches your secret. Add a fourth test that an empty secret raises, so a missing environment variable fails the deploy and does not silently accept every request.
Use the Send test action on /dashboard/webhooks to send a signed webhook.test payload to your endpoint. It is not a replay of a real job. For a real replay use Redeliver on the delivery row, or POST /v1/jobs/{job_id}/webhook/redeliver with jobs:write.
Sources
Related posts
More in Developers
- Quantized H3 vs lossless H3 vs a hosted clip: compare fairly
MiniMax's guide says not to mix ComfyUI quantized and SGLang lossless outputs. How to set up a fair comparison with a hosted clip, including the seed catch.
- Re-base caption words after a trim: subtract actual_start_seconds
Caption words must use the trimmed file's clock. A word at 12.3-12.7 s becomes 1.9-2.3 s when the trim really started at 10.4, not 0.3-0.7 s.
- A Sume job failed: read GET /v1/jobs/:id/events, then act
The events endpoint is the first stop for a failed or canceled Sume job: the eight event names, what stays hidden, error categories, and a TypeScript reader.
- Read your Sume plan from ratelimit-limit on a GET: 4,800 to 48,000
A GET /v1/balance returns ratelimit-limit. 4800, 12000, 24000 or 48000 reads a minute maps to Free, Pro, Startup or Scale, and a full Wan 720p queue reserve.
Written by Sume