Debug a Sume webhook signature mismatch offline: a diagnosis script
Saved a failing Sume webhook? This Python script tells you if it was a stale timestamp, an empty secret, a mutated body or the wrong secret. Self-test included.

When a Sume webhook fails verification, save the raw body, the x-sume-webhook-timestamp and the x-sume-webhook-signature, then run the same HMAC offline and test four causes in order: an empty secret, a stale timestamp, a body that changed in transit, and the wrong secret. The script below does that and prints which one it is, with a self-test that signs a fake payload so you can trust the tool.
What Sume signs
Sume signs the raw JSON body with HMAC-SHA256 over the string made of the timestamp, a dot, then the raw body. The header value is sume-v1= followed by the hex digest. During a secret rotation, the header carries one entry for each live secret, newest first, comma separated, and you accept the delivery when any entry matches.
Every delivery also carries x-sume-webhook-secret-fingerprint, and the dashboard shows the fingerprint next to the secret. When verification fails, comparing fingerprints is the safest first step, since neither side ever has to paste the secret itself.
Failure causes, in the order to test
Most failures are not cryptographic. They are plumbing. This table orders them by how cheap they are to rule out.
| Order | Cause | How to spot it | Fix |
|---|---|---|---|
| 1 | Empty or unset secret | Secret variable is blank in the process | Set SUME_COM_WEBHOOK_SIGNING_SECRET |
| 2 | Stale timestamp | More than 300 s from your clock | Fix clock skew, or use redeliver |
| 3 | Body changed | Framework parsed and re-serialized JSON | Verify the raw bytes before parsing |
| 4 | Wrong secret | Fingerprints differ | Copy the current secret, or accept the rotation overlap |
The script
Feed it the three captured values and your secret through environment variables. The final lines are a self-test: it builds a valid signature, then breaks it three ways, so you can see each message.
import hashlib, hmac, os, time
def diagnose(secret: str, ts: str, header: str, raw: bytes, now: float | None = None) -> str:
if not secret:
return "secret is empty"
now = time.time() if now is None else now
if not ts.isdigit() or abs(now - int(ts)) > 300:
return "timestamp is missing or outside the 300 s window"
want = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
entries = [e.strip() for e in header.split(",") if e.strip().startswith("sume-v1=")]
if any(hmac.compare_digest(e, want) for e in entries):
return "ok"
stripped = raw.strip()
alt = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + stripped, hashlib.sha256).hexdigest()
if stripped != raw and alt in entries:
return "body has extra whitespace compared to what was signed"
return "signature mismatch: wrong secret, or the body was changed"
secret, ts, raw = "test-secret", str(int(time.time())), b'{"event":"job.completed","job_id":"job_1"}'
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
print(diagnose(secret, ts, sig, raw))
print(diagnose("", ts, sig, raw))
print(diagnose(secret, str(int(time.time()) - 900), sig, raw))
print(diagnose(secret, ts, sig, raw.replace(b"job_1", b"job_2")))
if os.environ.get("CAPTURED_BODY_FILE"):
print(diagnose(os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", ""),
os.environ["CAPTURED_TS"], os.environ["CAPTURED_SIG"],
open(os.environ["CAPTURED_BODY_FILE"], "rb").read()))
Reading the output
The self-test should print ok, then secret is empty, then the timestamp message, then a signature mismatch. If your own capture prints ok here, but your server rejects it, the bug is in the server, nearly always in how it reads the body. If it prints a mismatch, compare the fingerprint header with the dashboard before you suspect anything else.
An important detail is that body mutation is silent. A proxy that rewrites line endings, or a framework that decodes and re-encodes JSON, changes bytes without any error. The raw bytes are the contract, which is why Sume's SDK docs say that a framework that parses JSON for you has already destroyed the bytes.
Recovering the missed event
A failed verification does not mean the job failed. The job reached its terminal state anyway, and you can read it with a normal poll. If you want the event again, POST /v1/jobs/{job_id}/webhook/redeliver with a key that has jobs:write, and Sume re-sends the real terminal event with a fresh timestamp and signature. That does not use one of the 10 automatic attempts, and it works after they are all spent.
- Capture raw bytes in a debug log, behind a flag, and hex-encode them so nothing changes them.
- Test with the dashboard Send test action before you blame a real delivery.
- Rotation overlap lasts 24 hours, so a one-signature verifier fails if it compares the whole header.
- Never log the secret. Log the fingerprint.
Sources
Related posts
More in Developers
- Dedupe Sume webhooks with SQLite: INSERT OR IGNORE on job_id and event
Sume retries up to 10 times and Redeliver repeats events. A stdlib SQLite table keyed on job_id and event makes your video webhook handler safe to run twice.
- Two model fields: deepseek-flash for your planner, sume-agent for Sume
Your planner config says deepseek-flash. The Sume Agent Completion model field takes only sume-agent. Keep them in separate settings.
- DeepSeek V4.1 Flash peak pricing vs Sume spend caps for agent loops
DeepSeek bills Flash at double rates 01:00-04:00 and 06:00-10:00 UTC on weekdays. Sume spend caps cover generation only, so budget planner tokens separately.
- DeepSeek V4.1 Flash JSON Output vs Sume output_schema: what differs
DeepSeek's JSON Output makes valid JSON but does not enforce a schema. Sume's output_schema is enforced on a run, with output null and output_error on a miss.
Written by Sume