Sume webhook 300-second tolerance: verify on receipt, not later
A queued Sume webhook fails the 300-second timestamp check if verified late. Verify at the edge, then queue the body. Python with a testable clock.

A Sume webhook is signed over <timestamp>.<raw_body>, and the docs say to reject a delivery whose timestamp is outside a replay window. Five minutes, 300 seconds, is the suggested default. That check belongs at the moment the request arrives. If you push the raw request onto a queue and verify in a worker, a backlog of more than 5 minutes makes every valid event fail.
The failure pattern
It looks like a signature bug but is a clock bug. The HMAC is correct, the secret is right, and verify returns false only for old events. The same code passes in a test run where the worker picks up the message in a second. It fails in production when the worker was down for ten minutes.
The fix
- Verify the signature and the timestamp in the HTTP handler, before you answer.
- Queue the verified body, not the headers. After that the timestamp has done its job.
- Dedupe on
job_id. A redelivery carries a fresh timestamp and signature, but it is the same event. - Keep the server clock on NTP. A clock that is off by more than 300 seconds rejects good deliveries.
What redeliver changes
POST /v1/jobs/{job_id}/webhook/redeliver sends the real terminal event again with a fresh timestamp and a fresh signature. Thus a redelivery after an outage passes the tolerance check even though the first attempt was hours ago. It does not use one of the 10 automatic attempts.
Verifier with a clock you can control
The function takes now as an argument, so a test can prove both the accept and the reject paths. It refuses an empty secret and accepts any entry in a comma-separated rotation header.
import hashlib, hmac, time
def verify(raw: bytes, ts: str, header: str, secret: str, tolerance=300, now=None) -> bool:
if not secret:
raise RuntimeError("empty webhook secret")
now = time.time() if now is None else now
if abs(now - int(ts)) > tolerance:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p.strip(), "sume-v1=" + mac) for p in header.split(","))
raw, ts, secret = b'{"job_id":"job_1"}', "1780000000", "s3cret"
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
print(verify(raw, ts, sig, secret, now=1780000100)) # True, 100 s old
print(verify(raw, ts, sig, secret, now=1780000400)) # False, 400 s oldChoose the tolerance on purpose
Do not raise 300 to a day to make a slow queue pass. A wide window helps an attacker who has captured one delivery to replay it. Fix the order of operations instead.
Sources
Related posts
More in Developers
- Patching Supabase Postgres 17.11 vs Sume's 10-attempt webhook budget
Supabase's September 25 Postgres 15.19 and 17.11 releases fix 44 CVEs. A restart can outlast Sume's ten 30-second webhook attempts, so plan a redeliver.
- Supabase cached egress is $0.03/GB: cost of serving a 20 MB AI clip
Supabase lists cached Storage egress at $0.03 per GB. Worked arithmetic for serving generated clips, and when to link a Sume media URL instead of copying.
- Swap in an AI-generated presenter: portrait first, then Recast
No photo of a real person? Generate a presenter portrait with Sume's image API, pass its URL to h3-max-recast, and poll the video job. Python included.
- Find Sume jobs still queued or processing after 30 minutes
A scheduled script lists queued and processing jobs from GET /v1/jobs, picks those older than a cutoff, and prints each so you recover instead of resubmit.
Written by Sume