Verify a Sume TTS webhook in Python instead of polling
Get a signed job.completed callback when a voiceover finishes. A Python verifier with a 5-minute replay window that refuses an empty secret.

To get a callback when a Sume voiceover finishes, submit the job with mode: "webhook" and a public HTTPS webhook_url, then verify the HMAC signature on the request that arrives. Sume sends only terminal events: job.completed, job.failed and job.canceled (webhook docs). There are no progress pings, so a webhook replaces a poll loop but not a status check when a delivery goes missing.
Real-time speech is where the news is this month. Microsoft says MAI-Voice-2.1-Flash returns 45 seconds of audio in 150 ms end to end (Microsoft AI, read 2026-10-04). Sume TTS is a job API instead: a sync call waits at most 30 seconds, and anything longer should be async or webhook (API reference). For a nightly batch of voiceovers, a webhook is a cheaper design than a worker that sleeps.
What Sume sends
The body is JSON. The signature is HMAC SHA 256 over <timestamp>.<raw_body>, sent in two headers.
x-sume-webhook-timestamp: Unix seconds.x-sume-webhook-signature:sume-v1=<hex>. During a secret rotation the header holds several comma-separatedsume-v1=entries, newest first. Accept the delivery when any one matches.- Reject a timestamp outside your tolerance window. The docs suggest five minutes.
- The secret is
SUME_COM_WEBHOOK_SIGNING_SECRET. Read it in the dashboard Webhooks tab or fromGET /v1/webhooks/signing-secretwith anaccount:readkey.
A verifier that fails closed
Two details matter. Sign the raw bytes, not a re-serialized dict, because any change in whitespace breaks the hash. And refuse to run with an empty secret, since an HMAC with an empty key still produces a valid-looking digest.
import hashlib, hmac, os, time
def verify(raw: bytes, ts: str, header: str) -> bool:
secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not secret:
raise RuntimeError("SUME_COM_WEBHOOK_SIGNING_SECRET is empty")
try:
age = abs(time.time() - int(ts))
except ValueError:
return False
if age > 300:
return False
want = hmac.new(secret.encode(), ts.encode() + b"." + raw,
hashlib.sha256).hexdigest()
for part in header.split(","):
part = part.strip()
if part.startswith("sume-v1=") and hmac.compare_digest(part[8:], want):
return True
return FalseWhat to do after it verifies
Return a 2xx fast, then fetch the result with GET /v1/jobs/{id}/result. The payload tells you the job finished, and the result route is where audio_url and duration_seconds live. Keep the Idempotency-Key you used at submit, so a retried submit cannot bill a second voiceover. Keep a status poll as a fallback for missed deliveries, which the jobs docs describe.
Where this fits
TTS costs $0.0475 per 1,000 characters, so a webhook-driven batch of 40 lines of 250 characters is 10,000 characters, or $0.475. For the request fields before you submit, see the text-to-speech API guide.
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.
- v1/videos/models `created` is a catalog date, not a release date
Every model on Sume's /v1/videos/models shows created 1767225600, which is 2026-01-01. It is not when Gemini Omni 1.1 Flash or MiniMax H3 launched.
Written by Sume