Sign and verify a Sume webhook offline: a Python test vector
Build a sume-v1 test signature with Python's hmac and check your verifier against it, including an empty secret, an old timestamp and a rotation header.

You can test a Sume webhook verifier without any network: sign a body yourself with HMAC-SHA256 over <timestamp>.<raw_body>, prefix the hex digest with sume-v1=, and feed the result to the verifier. The verifier below refuses an empty secret, rejects timestamps outside the 300-second window, and accepts a header with several comma-separated entries.
import hashlib
import hmac
import json
import time
def verify(secret, timestamp, signature_header, raw_body, tolerance=300, now=None):
if not secret:
raise ValueError("signing secret is empty; refusing to verify")
now = time.time() if now is None else now
try:
if abs(now - int(timestamp)) > tolerance:
return False
except (TypeError, ValueError):
return False
signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
expected = "sume-v1=" + digest
entries = [e.strip() for e in (signature_header or "").split(",")]
return any(hmac.compare_digest(e, expected) for e in entries)
def route(raw_body):
event = json.loads(raw_body).get("event", "")
if event.startswith("job."):
return 200, "job event"
if event.endswith(".run.terminal"):
return 200, "run event"
return 204, "ignored"A test vector
To use it, build a signed case from the same secret. These calls are the test vector:
import hashlib, hmac, time
secret, body, ts = "whsec_test", b'{"event":"job.completed"}', str(int(time.time()))
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
print(verify(secret, ts, sig, body))
print(verify(secret, ts, "sume-v1=00," + sig, body))
print(verify(secret, str(int(ts) - 400), sig, body))
print(route(body))
try:
verify("", ts, sig, body)
except ValueError as e:
print(e)What it prints
Append that snippet to the file and run it. It prints True, True, False, then (200, 'job event'), then the empty-secret message. The second line is a rotation header: during a secret rotation the header carries one entry per live secret, and the check passes when any entry matches.
Rules the code follows
The table lists the rules from the webhooks page.
| Rule | Value |
|---|---|
| Signed string | <timestamp>.<raw_body> |
| Header format | x-sume-webhook-signature: sume-v1=<hex> |
| Replay window | 300 seconds by default |
| Rotation | Several sume-v1= entries, comma-separated; accept any match |
| Delivery | Up to 10 attempts, 30 s apart, 10 s timeout each |
Receiver habits
Verify the raw bytes, not a parsed and re-serialized body, and compare with hmac.compare_digest. Return a 2xx fast, then do the slow work after you reply, since each attempt has a 10-second timeout. Use job_id as the idempotency key for your handler, because retries and redeliveries repeat the same job.
Unknown events
The route function answers 204 for event names it does not know, so a new event type on Sume's side does not turn into a retry storm.
Sources
Related posts
More in Developers
- size vs image_size vs aspect_ratio: which field wins on Sume images
On POST /v1/images, image_size beats aspect_ratio, size is a tier word that rejects WxH, and resolution is a tier. Which one to send per model.
- Sora's GET /videos and DELETE /videos/{id}: what Sume offers instead
Sora had list and delete routes. Sume lists your key's own jobs with GET /v1/jobs and documents no public delete, so plan retention in your own storage.
- Sora to Sume in Python: a 10% rollout flag with a spend guard
Move video traffic off a dead Sora call one slice at a time. A stable per-user percentage flag, one Sume call, and a cost guard that stops at your daily cap.
- Speaking rate in words per minute from Sume STT word times (Python)
Compute words per minute for a recording from the words[] start and end times Sume STT returns, plus a per-minute pacing table. Offline Python, no API call.
Written by Sume