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.

4 min readSume
All posts

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.

sume-v1 failure causes (Sume docs, read 2026-10-05)
OrderCauseHow to spot itFix
1Empty or unset secretSecret variable is blank in the processSet SUME_COM_WEBHOOK_SIGNING_SECRET
2Stale timestampMore than 300 s from your clockFix clock skew, or use redeliver
3Body changedFramework parsed and re-serialized JSONVerify the raw bytes before parsing
4Wrong secretFingerprints differCopy 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

All Developers posts

Written by Sume