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
- Claude per-message effort: drop to low while a Sume job runs
Claude's per-message effort beta keeps the prompt cache when you change effort mid-conversation. How to use it around Sume jobs, and its Haiku 5.5 limit.
- Claude tool search limits: 200-char regex, 500-char BM25, 5 results
Claude's tool search tool has fixed limits on pattern length, results and deferred tools. What they mean for a Sume hosted MCP tool list.
- Cloudflare Queue consumer 15 min wall time: poll a Sume job
A Queue consumer on Cloudflare can run 15 minutes of wall time, but a Sume job can wait longer. Re-enqueue with a delay and honor next_poll_after_seconds.
- Convert MAI-Transcribe-2 word offsets to Sume video caption words
Turn word timings in milliseconds into the seconds-based words array Sume video-captions accepts. A short Python converter plus the 60 s and 1,200-word limits.
Written by Sume