Sume webhook signature mismatch? Check the secret fingerprint header

Each Sume delivery carries x-sume-webhook-secret-fingerprint. Compare it with the dashboard before debugging code. Python verifier that refuses an empty secret.

4 min readSume
All posts

What do I check first when a Sume webhook signature does not match?

Compare the x-sume-webhook-secret-fingerprint header with the fingerprint shown next to your secret in the dashboard at /dashboard/webhooks. If they differ, you hold the wrong secret and no code change will fix it. If they match, the secret is right and the problem is the body, the timestamp or the header handling.

Neither side ever sends the secret itself, which is the point of the fingerprint. The same value appears as signing_secret_fingerprint on the job's webhook delivery receipt, so you can also read it without catching a live request.

A short decision order

Work from the cheapest check to the most expensive one.

Debugging a Sume webhook signature, in order (docs.sume.com, read 2026-10-06)
CheckHowIf it fails
FingerprintHeader vs dashboard valueLoad the right SUME_COM_WEBHOOK_SIGNING_SECRET
Raw bodyHash the bytes before any JSON parseRemove the parser from this route
TimestampWithin 300 s of nowFix clock drift or the replay window
RotationHeader may hold several sume-v1 entriesAccept if any entry matches

Verifier in Python

The signed string is the timestamp, a dot, and the raw body. During a secret rotation the signature header carries one sume-v1= entry per live secret, newest first, separated by commas, so the loop accepts a match on any of them. The first line refuses an empty secret, because an empty key still produces a valid-looking digest.

import hashlib, hmac, time

def verify(body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
    if not secret:
        raise ValueError("refusing to verify with an empty signing secret")
    ts = headers.get("x-sume-webhook-timestamp", "")
    sig = headers.get("x-sume-webhook-signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256)
    want = "sume-v1=" + mac.hexdigest()
    return any(hmac.compare_digest(e.strip(), want) for e in sig.split(","))

body = b'{"event":"job.completed","job_id":"job_1"}'
ts = str(int(time.time()))
sig = "sume-v1=" + hmac.new(b"whsec_test", ts.encode() + b"." + body, hashlib.sha256).hexdigest()
h = {"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": "sume-v1=00," + sig}
print(verify(body, h, "whsec_test"), verify(body, h, "other"))

Keep the fingerprint in your logs

Log the fingerprint header on every rejected delivery, not the secret and not the signature. A single line of log tells you in seconds whether a rotation reached all of your instances. Pair it with the job id so a failed delivery can be matched with a redelivery request. If you run several receivers behind one load balancer, include the instance name as well, because a half-finished rollout is an easy way for two instances to hold different secrets, and the instance name turns that guess into a fact you can read in the log.

Once the secret is fixed, you do not need to wait for a real job. POST /v1/webhooks/test-deliveries sends a signed dummy webhook.test event to your URL, and POST /v1/jobs/{job_id}/webhook/redeliver re-sends a real terminal event with a fresh signature.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume