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.

5 min readSume
All posts

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.

Verifier checks and what each prevents (read 2026-10-04)
CheckWhy it is there
Refuse an empty secretAn empty key signs anything, so a missing setting must fail loudly
Timestamp tolerance of 300 secondsStops a captured request from being replayed later
Constant-time compareAvoids leaking the signature through timing
Any sume-v1= entry may matchDuring secret rotation the header carries the new and the previous signature
Raw body bytesParsing 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-fingerprint header 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

All Developers posts

Written by Sume