Self-test a Python Sume verifier: rotation, stale time, empty secret
Six asserts for a stdlib hmac verifier: new and old secret both pass, tampered body fails, 301 seconds old fails, empty secret raises. One file, no framework.

A webhook verifier that you wrote by hand needs a test for the cases that fail quietly: a rotated header with two signatures, a stale timestamp, a changed body, and an empty secret. The file below holds a stdlib verifier and six asserts. Run it with python verify_test.py; it prints ok when all pass.
The cases
Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends sume-v1=<hex>. During a signing-secret rotation it sends two entries, newest first, separated by a comma, and keeps doing so for 24 hours. A receiver holding either secret must pass, which is what the first assert checks.
| Case | Expected |
|---|---|
| header has new and old entries, verify with new secret | True |
| same header, verify with old secret | True |
| different secret | False |
| body with one extra byte | False |
| timestamp 301 seconds old, correctly signed | False |
| empty secret | raises RuntimeError |
The file
The verifier uses hmac.compare_digest on every entry and returns any. The helper sign builds a header entry the way Sume does, so you can make test deliveries without a network call.
import hashlib, hmac, time
def verify(raw, ts, header, secret, tol=300):
if not secret:
raise RuntimeError("empty webhook secret")
try:
if abs(time.time() - int(ts)) > tol:
return False
except (TypeError, ValueError):
return False
want = sign(secret, ts, raw)
return any(hmac.compare_digest(p.strip(), want) for p in (header or "").split(","))
def sign(secret, ts, raw):
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
return "sume-v1=" + mac.hexdigest()
raw, ts = b'{"event":"job.completed","job_id":"job_1"}', str(int(time.time()))
hdr = sign("new", ts, raw) + "," + sign("old", ts, raw)
assert verify(raw, ts, hdr, "new") and verify(raw, ts, hdr, "old")
assert not verify(raw, ts, hdr, "other") and not verify(raw + b" ", ts, hdr, "new")
stale = str(int(ts) - 301)
assert not verify(raw, stale, sign("new", stale, raw), "new")
try:
verify(raw, ts, hdr, "")
raise SystemExit("empty secret was accepted")
except RuntimeError:
print("ok")Why each case exists
Rotation is the case people skip, and it is the one that causes an outage: a verifier that compares the whole header with == works for months and then fails every delivery for 24 hours after someone rotates the secret. The stale case catches a verifier that forgot the window. The tampered-body case catches one that hashes a parsed object. The empty-secret case catches a deploy with a missing environment variable.
Add the test to CI so a refactor cannot remove one of them.
What the test does not prove
It proves your logic against your own sign, so if sign has the scheme wrong, both sides are wrong together. Check it once against a real delivery: save the raw body and the two headers from a test delivery (POST /v1/webhooks/test-deliveries, needs account:write) and run verify on them with the real secret.
The empty-secret case is deliberate. A receiver whose environment variable failed to load should refuse to start or answer 500, not run with "" as a key, because anyone can sign a payload with an empty key. For the SDK's rotation behaviour, see verifyWebhook during a rotation.
- Test on bytes, not on a parsed dict.
- Keep one real delivery as a fixture.
- Rotate the secret in staging once and replay a captured header against both versions.
Sources
Related posts
More in Developers
- Test an Omni edit prompt at 360p first: 23 cents vs 113 at 1080p
Edit mode on Gemini Omni Flash 1.1 takes resolution as an option. Try the edit at 360p, then render the winner at 1080p. Arithmetic for a 6 s clip.
- Test one video prompt on ten models for under $7 (Python sweep)
A 5-second, lowest-resolution sweep of one prompt over ten Sume video ids costs about $5.60 in total. The price of each row and a script that submits them.
- Test Sume retry code with a unittest fake server: 429 then 202
A stdlib fake server answers 429 with Retry-After, then 202. The test asserts your retry reuses the same Idempotency-Key and waits 7 seconds, with no sleep.
- The 202 from POST /v1/videos has four fields: where the rest arrives
The Sume submit response returns only id, polling_url, status and model. Usage, unsigned_urls and error come on the poll. A parser that expects no more.
Written by Sume