A Python Sume webhook verifier for the two-signature rotation header
In a 24-hour secret rotation Sume sends two sume-v1 signatures. This 25-line Python verifier accepts either, refuses an empty secret, and prints its own test.

A Sume webhook verifier must accept a header with two signatures during a secret rotation. For 24 hours after you rotate, Sume signs with both the new and the previous secret and sends sume-v1=<new>,sume-v1=<old>, so the receiver should accept the delivery if any entry matches. The 25-line Python below does that, refuses an empty secret, and checks the timestamp against a 300-second window.
Equality on the whole header fails on every delivery during the window, which is how a rotation turns into a night of retries.
What the signature covers
These details come from the webhooks and SDK pages, read 2026-10-08.
- Sume signs the raw JSON body with HMAC SHA-256 over the string
<timestamp>.<raw_body>. - Headers are
x-sume-webhook-timestampandx-sume-webhook-signature: sume-v1=<hex>. - Reject timestamps outside your replay window. Five minutes is the documented default.
- The same scheme covers job webhooks (
job.completed,job.failed,job.canceled) and run webhooks (format.run.terminal), so one verifier serves both.
The verifier
Pass the raw bytes of the request body. A parsed and re-serialized body does not verify because key order and whitespace are part of what was signed. The loop compares every entry with a constant-time comparison and does not stop at the first match, so timing does not reveal which secret matched.
import hashlib, hmac, time
def verify(raw: bytes, ts: str, header: str, secret: str, tolerance=300, now=None):
if not secret:
raise ValueError("refusing to verify with an empty secret")
now = time.time() if now is None else now
try:
if abs(now - int(ts)) > tolerance:
return False
except ValueError:
return False
digest = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "sume-v1=" + digest
ok = False
for entry in header.split(","):
if hmac.compare_digest(entry.strip(), expected):
ok = True
return ok
body, ts = b'{"event":"job.completed"}', "1791460800"
def sign(s):
return "sume-v1=" + hmac.new(s.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
hdr = sign("new-secret") + "," + sign("old-secret")
for s in ("new-secret", "old-secret", "other"):
print(s, verify(body, ts, hdr, s, now=1791460810))Reading the test output
The test signs one body with two secrets and builds the rotation header. Verifying with the new secret prints True, verifying with the old secret prints True, and a third unrelated secret prints False. The now argument is fixed 10 seconds after the timestamp so the replay check passes without a real clock.
In production, read the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name that Sume's delivery worker uses. If that variable is unset, the empty string reaches verify and it raises instead of returning a quiet False that would let a misconfigured server accept nothing without telling anyone why.
Operational notes
Return a 2xx after you store the event durably. Sume retries network errors and non-2xx responses, up to 10 attempts in total, with a fixed delay (30 seconds by default) and a 10-second timeout per attempt. Use job_id as the idempotency key on your side.
Upgrade the receiver before you rotate. Rotating twice inside one window retires the secret from two rotations ago immediately, which is how a leak actually stops. If a signature does not verify, compare x-sume-webhook-secret-fingerprint with the fingerprint in the dashboard; neither side has to send the secret.
Testing against a real delivery
Before you trust the verifier, send it something Sume signed. The Webhooks tab of the dashboard has a Send test control, and POST /v1/webhooks/test-deliveries does the same with an account:write key. It posts a dummy webhook.test payload, signed, to a URL that you type. The body has no job_id, so your handler must accept an event it does not know and answer with a 2xx, not a 500.
Send test never replays a real job. To re-send a real terminal event, use Redeliver on a delivery row, or POST /v1/jobs/{job_id}/webhook/redeliver with a jobs:write key. Redeliver signs with a fresh timestamp, so it passes your replay window even when the original delivery is hours old.
In a web framework, make sure that the raw body reaches verify before any JSON middleware touches it. In Flask that is request.get_data(), and in FastAPI it is await request.body(). Both give bytes, which is what the function above expects.
Sources
Related posts
More in Developers
- quality on Flux or Seedream returns 400 on Sume: which rows take it
Only GPT Image 2.5, GPT Image 2 and Ideogram 4.5 list quality on POST /v1/images. Send it to Flux, Seedream or Qwen and you get 400 unsupported_parameter.
- Railway closes idle HTTP at 5 minutes: why Sume sync waits 30 s
Railway keeps a request open up to 15 minutes only while data moves. Sume sync mode caps the wait at 30 seconds, so long video jobs need async or a webhook.
- Ratio check before a batch: 4:5, 9:16 and 8:1 on three Sume rows
Imagen 4 lists 5 ratios, GPT Image 2.5 lists 9 and Nano Banana 2.1 lists 15. Which of 4:5, 9:16 and 8:1 each Sume row accepts, and the fallback.
- Read back a Sume TTS job to keep the next line in the same voice
A completed Sume text_to_speech job records model_id, voice, language, output_format, generation_config and speed. Reuse them so line two matches line one.
Written by Sume