Python Sume webhook handler: stdlib verify and SQLite dedupe
A stdlib Python handler for Sume webhooks: verify that accepts rotation, then INSERT OR IGNORE on job_id so a retry runs once. Tested on 3.14 and 3.15.

A Python handler for Sume webhooks needs two things from the standard library: hmac to check the sume-v1 signature over timestamp.rawbody, and sqlite3 to make the delivery idempotent. Insert the job_id with INSERT OR IGNORE into a table where it is the primary key, and read cursor.rowcount: 1 means this is the first copy, 0 means a retry you can acknowledge and skip. The 29-line module below returns an HTTP status you can plug into any framework.
Dedupe is not optional. Sume retries a failed delivery up to 10 times, and the redeliver endpoint sends the real terminal event again with a fresh timestamp and signature, so the same job can reach you more than once. The docs tell receivers to treat job_id as the idempotency key.
The handler
Pass the raw request bytes, never a parsed dict, plus the headers and your signing secret. It accepts a comma-separated header, which is how Sume signs during a secret rotation (one entry per live secret, newest first), and returns 401 for an empty secret rather than comparing against nothing.
import hashlib, hmac, json, sqlite3, time
db = sqlite3.connect("webhooks.db")
db.execute("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, event TEXT, body TEXT)")
def verify(raw: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
if not secret:
return False # refuse an empty secret
h = {k.lower(): v for k, v in headers.items()}
try:
ts = int(h["x-sume-webhook-timestamp"])
sigs = h["x-sume-webhook-signature"].split(",")
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s.strip(), f"sume-v1={mac}") for s in sigs)
def handle(raw: bytes, headers: dict, secret: str) -> int:
if not verify(raw, headers, secret):
return 401
event = json.loads(raw)
key = event.get("job_id") or event.get("run_id")
if key:
with db: # one transaction: the insert is the dedupe check
cur = db.execute("INSERT OR IGNORE INTO seen VALUES (?, ?, ?)",
(key, event.get("event"), raw.decode()))
if cur.rowcount == 1:
print("new event", event.get("event"), key)
return 204What I tested
The inputs were signed locally with the documented scheme, so this verifies my reading of the docs rather than a live Sume delivery.
| Input | Status | Handled |
|---|---|---|
| Valid signature, first delivery | 204 | Yes |
| Same delivery again | 204 | No, ignored |
| Two signatures, valid one second | 204 | No, same job |
| Wrong secret | 401 | No |
| Empty secret | 401 | No |
| Timestamp outside 300 seconds | 401 | No |
Production notes
Failure modes to plan for:
- The insert commits before your real work runs. If the work then fails, the event is marked seen and will not run again, so add a status column and set it when the work finishes.
- Move the work to a queue and return 204 in under 10 seconds, the per-attempt timeout.
- SQLite is fine for one process. With several workers, use a Postgres unique index with
ON CONFLICT DO NOTHING. - Route
webhook.testevents away: they have nojob_idand never replay a real job.
Limits
This stores the whole body next to the id; if bodies contain result URLs you do not want in a table, store only the id and event. It does not read your signing secret for you: get it from the dashboard Webhooks tab or GET /v1/webhooks/signing-secret with an account:read key, and keep status polling as the backup for events that never arrive.
Sources
Related posts
More in Developers
- Verify a Sume webhook in Rails: raw_post, skip_forgery_protection
A Rails controller that verifies Sume's sume-v1 HMAC over the raw body, accepts the rotation header, refuses an empty secret and skips CSRF for that route only.
- Why did my Sume webhook not arrive? Read the job events
One GET on a job lists a webhook.delivery event with status, attempts, last HTTP code and host. A 19-line Python function turns it into a one-line verdict.
- Repeat a TTS take: read generation_config and speed from the job
A completed Sume TTS job records its engine, voice, language, output_format, generation_config and speed. Read them back to make the next line sound the same.
- Reconcile a no-code run with GET /v1/jobs and idempotency_key
When a Zap, scenario or flow loses its job ids, list jobs with GET /v1/jobs and join on idempotency_key. Pages are newest first, 100 at most, cursor-paged.
Written by Sume