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.

5 min readSume
All posts

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.

Cases the test file covers, from the Sume webhook docs (read 2026-10-08)
CaseExpected
header has new and old entries, verify with new secretTrue
same header, verify with old secretTrue
different secretFalse
body with one extra byteFalse
timestamp 301 seconds old, correctly signedFalse
empty secretraises 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

All Developers posts

Written by Sume