Check Sume webhook signatures in Python with hmac.compare_digest
A Python verifier for the sume-v1 header: raw bytes, constant-time compare, 300 s replay window, empty-secret refusal, and a Flask route that dedupes on job_id.

In Python, verify a Sume webhook by hashing timestamp + b'.' + raw_body with HMAC SHA-256, prefixing the hex digest with sume-v1=, and comparing it with hmac.compare_digest against each comma-separated entry of x-sume-webhook-signature. Use the raw request bytes, never json.dumps(request.json), because key order and whitespace are part of what was signed.
The function below also refuses an empty secret. With an unset environment variable, hmac.new(b'', ...) still runs, and an attacker who knows the scheme could forge a matching signature, so failing loudly is the safer default.
verify.py
Encode both sides to bytes before comparing; compare_digest rejects non-ASCII str values.
import hashlib, hmac, time
def verify_sume(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
if not secret:
raise ValueError("webhook secret is empty; refusing to verify")
try:
ts = int(headers["x-sume-webhook-timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
signed = str(ts).encode() + b"." + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
want = ("sume-v1=" + digest).encode()
ok = False
for part in headers.get("x-sume-webhook-signature", "").split(","):
if hmac.compare_digest(part.strip().encode(), want):
ok = True
return okA Flask receiver with dedupe
Sume retries on any non-2xx, so the same event can arrive more than once. A primary key on job_id makes the second delivery a no-op.
import os, sqlite3
from flask import Flask, request
from verify import verify_sume
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
raise SystemExit("set SUME_COM_WEBHOOK_SIGNING_SECRET")
db = sqlite3.connect("hooks.db", check_same_thread=False)
db.execute("create table if not exists seen (job_id text primary key, body text)")
app = Flask(__name__)
@app.post("/sume")
def hook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_sume(raw, request.headers, SECRET):
return "bad signature", 401
event = request.get_json(force=True)
db.execute("insert or ignore into seen values (?, ?)",
(event.get("job_id") or event["request_id"], raw.decode()))
db.commit()
return "", 204Test it before you trust it
Sign a body with a throwaway secret, send it with a current timestamp, and expect True. Then change one byte of the body, then the timestamp by ten minutes, and expect False both times. Finally call it with secret="" and expect the ValueError.
Raw bytes in other frameworks
Flask's request.get_data() returns the body untouched. In FastAPI, read await request.body() before calling request.json(), and in Django use request.body. In each case the rule is the same: take the bytes first, verify, and only then parse. A framework that decodes and re-encodes the body has already destroyed the exact bytes that Sume signed.
Failure cases to test
| Case | Expected |
|---|---|
| Correct signature, fresh timestamp | True |
| One byte of the body changed | False |
| Timestamp 10 minutes old | False |
| Header holds old and new entries | True when either matches |
| Empty secret | ValueError |
Delivery limits to design around
Sume sends up to 10 attempts in total, with a fixed delay between them (30 seconds by default), and each attempt has a 10-second timeout. Verify, store the event, return a 2xx, and do slow work afterwards. If a signature fails to verify, compare the x-sume-webhook-secret-fingerprint header with the fingerprint shown next to your secret in the dashboard rather than pasting the secret anywhere.
During a secret rotation the signature header carries one sume-v1= entry per live secret, newest first, separated by commas. The verifier above already accepts a delivery when any entry matches, so rotating does not break it.
Sources
Related posts
More in Developers
- Check a voiceover with STT: 312 seconds costs 5.2 cents
Transcribe the finished voiceover and diff the words against the script: Sume STT bills $0.052 for 312 s. A runnable Python word-diff included. Read 2026-10-08.
- Claude Code hook: exit 2 or a JSON deny for a paid Sume call?
Exit code 2 blocks and cannot be overridden by JSON; exit 0 with permissionDecision deny carries a reason. A runnable Python guard for Sume's paid tools.
- Hook matcher rules: when mcp__sume__ names are exact, not regex
Claude Code reads a matcher of letters, digits, underscores and pipes as exact names, and other characters as regex. How to write it for Sume tools.
- Claude Code http hook: send paid Sume call records to an audit log
An http hook POSTs hook input to a URL, with headers from allowed environment variables only. Log Sume tool names and idempotency keys with a tiny receiver.
Written by Sume