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.

4 min readSume
All posts

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-separated sume-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 from GET /v1/webhooks/signing-secret with an account:read key.

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 False

What 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

All Developers posts

Written by Sume