Verify a Sume job webhook in Python: HMAC, timestamp, rotation
A Python verifier for Sume job webhooks: HMAC SHA 256 over timestamp.body, a 300-second window, key rotation and a refusal of an empty secret.

How do you verify a Sume webhook from Python? Compute HMAC SHA 256 of <timestamp>.<raw_body> with your signing secret, prefix the hex with sume-v1=, and compare it with each entry in the x-sume-webhook-signature header. The Sume webhooks docs give the scheme and a TypeScript version; this is the same logic in Python.
It applies to TTS, music, audio detach and every other generation job, since all of them send the same terminal events.
What a delivery contains
Sume sends terminal events only: job.completed, job.failed and job.canceled. There are no progress or partial events. Two headers matter: x-sume-webhook-timestamp and x-sume-webhook-signature, which looks like sume-v1=<hex>. During a secret rotation the signature header carries one entry per live secret, comma separated, newest first, so accept the delivery if any entry matches.
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled |
| Signed string | <timestamp>.<raw_body>, HMAC SHA 256, hex |
| Header format | sume-v1=<hex>, comma separated during rotation |
| Replay window | Reject outside your tolerance; five minutes is the docs default |
| Attempts | Up to 10 total, fixed spacing of 30 s by default |
| Per-attempt timeout | 10 s |
| Idempotency key on your side | job_id |
The verifier
Run it against the raw bytes of the body, before any JSON parsing, because a re-serialised body will not match. It raises on an empty secret instead of silently accepting everything, and uses a constant-time comparison for every entry. Set SUME_COM_WEBHOOK_SIGNING_SECRET to run the demo at the bottom.
import hashlib
import hmac
import os
import time
def verify(raw_body, timestamp, header, secret, tolerance=300):
if not secret:
raise ValueError("signing secret is empty")
try:
ts = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - ts) > tolerance:
return False
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256)
expected = ("sume-v1=" + mac.hexdigest()).encode()
matched = False
for entry in header.split(","):
if hmac.compare_digest(entry.strip().encode(), expected):
matched = True
return matched
if __name__ == "__main__":
secret = os.environ["SUME_COM_WEBHOOK_SIGNING_SECRET"]
body = b'{"event":"job.completed"}'
now = str(int(time.time()))
mac = hmac.new(secret.encode(), f"{now}.".encode() + body, hashlib.sha256)
print(verify(body, now, "sume-v1=" + mac.hexdigest(), secret))Operational details
Return a 2xx after you have durably stored the event; any other response or a network error is retried. Because a delivery can repeat, treat job_id as the idempotency key in your own store.
Ten refused attempts leave a failed delivery but a job that still reached its terminal state. Keep status polling available for the events that never arrive, as the jobs and results page recommends. A redeliver call re-sends the real terminal event with a fresh timestamp and signature, so your tolerance check must use the new timestamp rather than the original one.
Common mistakes
Four mistakes cause most failed checks. First, parsing the JSON and signing the re-serialised text, which changes whitespace and key order. Second, comparing the hex with an ordinary equality test instead of a constant-time one. Third, checking only the first entry of the signature header, which breaks during a secret rotation. Fourth, doing slow work before replying, since each attempt times out after 10 seconds and a slow endpoint burns the retry budget. Store the event, answer 2xx, and process it afterwards from your own queue.
Testing it
Use the dashboard's Send test, or POST /v1/webhooks/test-deliveries, to post a dummy signed webhook.test payload to a URL you type. It never replays a real job and its body has no job_id, so do not use it to test idempotency. For that, redeliver a real job. Read the signing secret from the dashboard's Webhooks tab, or from GET /v1/webhooks/signing-secret with an API key that has account:read; see the API reference for authentication.
Sources
Related posts
More in Developers
- verifyWebhook returns false when a header arrives as an array
verifyWebhook refuses an array-valued signature header unless it holds exactly one value. How to normalise headers so a rotation window still verifies.
- Replay a saved Sume webhook in tests: verifyWebhook says false
A recorded delivery fails verifyWebhook once it is older than 300 seconds. Pin the clock with now or use toleranceSeconds 0 in tests, never in production.
- AI video API fallback: retry on another model when a job fails
Chain seedance-2.5, seedance-2 and kling-3 on Sume: poll status_url, read the job error category, and resubmit the brief to the next model.
- Sume video callback not verifying? Compare the secret fingerprint
Every Sume job webhook carries x-sume-webhook-secret-fingerprint. Compare it with the dashboard, accept two signatures in rotation, refuse an empty secret.
Written by Sume