Verify a Sume webhook signature in Python for a finished TTS job
A short Python verifier for Sume job webhooks: HMAC SHA-256 over timestamp.body, the sume-v1 header, a five-minute replay window, and no empty secret.

The check in one paragraph
Sume signs the raw JSON body with HMAC SHA-256 over the string <timestamp>.<raw_body>, and sends the result as x-sume-webhook-signature: sume-v1=<hex> next to x-sume-webhook-timestamp. To verify, rebuild that string from the bytes you received, compute the hex digest with your signing secret, compare in constant time, and reject a timestamp more than five minutes old. Refuse to run at all if the secret is empty.
This works for a finished text-to-speech job exactly as for any other job, because the webhooks guide uses one scheme for every generation route.
Submit the TTS job with a webhook
Send mode: "webhook" and a public HTTPS webhook_url on POST /v1/tts-1.0/generate. The call returns at once with a job id. Your endpoint later receives one terminal event: job.completed, job.failed or job.canceled. There are no progress events, so a missing call is not a sign the job is still running. Keep a poll fallback on GET /v1/jobs/{id}/status, as the jobs guide recommends.
The verifier
Verify against the raw bytes, before any JSON parsing. A parsed and re-serialised body can differ in spacing and key order, and then the signature fails for the wrong reason.
import hashlib, hmac, time
def verify(secret, timestamp, signature_header, raw_body, tolerance=300):
if not secret:
raise ValueError("signing secret is empty")
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
expected = f"sume-v1={digest}"
parts = [p.strip() for p in signature_header.split(",")]
return any(hmac.compare_digest(expected, p) for p in parts)
if __name__ == "__main__":
body = b'{"event":"job.completed"}'
ts = str(int(time.time()))
sig = hmac.new(b"demo-secret", ts.encode() + b"." + body, hashlib.sha256).hexdigest()
print(verify("demo-secret", ts, f"sume-v1={sig}", body))What each part protects against
Every line has a reason, and removing one opens a hole.
| Check | Why it is there |
|---|---|
| Refuse an empty secret | An empty key signs anything, so a missing setting must fail loudly |
| Timestamp tolerance of 300 seconds | Stops a captured request from being replayed later |
| Constant-time compare | Avoids leaking the signature through timing |
Any sume-v1= entry may match | During secret rotation the header carries the new and the previous signature |
| Raw body bytes | Parsing and re-encoding changes the bytes that were signed |
After it verifies
Return a 2xx quickly and do the work afterwards. Read job_id from the body, check the event name, and for job.completed fetch the audio from payload.artifacts[] (type audio, content_type audio/mpeg). Make the handler idempotent on job_id, since a delivery can arrive more than once. Get your signing secret from the dashboard's Webhooks tab or from GET /v1/webhooks/signing-secret with an account:read key, and keep it in an environment variable, not in code.
- Never log the secret or the full signature.
- If verification fails, compare the
x-sume-webhook-secret-fingerprintheader with the dashboard. - Treat a failed verify as a 400 and do not run the payload.
Testing the verifier
Write three tests before you deploy. First, a valid delivery with a fresh timestamp must pass. Second, the same body with one changed byte must fail, which proves you are signing the raw bytes. Third, a delivery whose timestamp is ten minutes old must fail even with a correct signature, which proves the replay window works. Add a fourth for the empty secret: the function should raise, not return false, so a deployment with a missing environment variable crashes at the first call instead of silently rejecting every event.
During a rotation, test a header with two entries separated by a comma and check that either one can match. That is the case that breaks hand-written parsers, and it only appears for a short period.
Sources
Related posts
More in Developers
- Verify a Sume job webhook in Python for finished Omni clips
Check x-sume-webhook-signature on job.completed callbacks: HMAC SHA-256 over timestamp.raw_body, 5-minute tolerance, rotation-safe, empty secret rejected.
- Verify a Sume video download against checksum_sha256 in Python
Stream a finished Sume artifact to disk, hash it with hashlib, and compare to checksum_sha256 from the job result. Skips cleanly when the field is null.
- Sume webhook signature header: why the sume-v1= prefix is checked
verifyWebhook only compares entries that start with sume-v1= and drops others, so a future scheme in the same header cannot break a receiver. A test proves it.
- Video batch budget guard in Python: stop when usage.cost hits the cap
Run a batch of Sume video jobs one at a time, add up each finished job's usage.cost, and stop before the next submit would cross a dollar cap.
Written by Sume