Python Sume webhook handler that accepts the webhook.test event
Verify the sume-v1 signature over timestamp.body, refuse an empty secret, and accept webhook.test, which has no job_id. Stdlib Python, runs offline.

Verify first, then branch on event. Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends sume-v1=<hex> in x-sume-webhook-signature. A test delivery from POST /v1/webhooks/test-deliveries has the event webhook.test and no job_id, so a handler that reads job_id before it checks the event name will crash on the first thing you send it. Return 2xx for it, and do not try to look up a job.
Run the test delivery whenever you deploy a new receiver, change a proxy or rotate the secret. It is the cheapest way to learn that your TLS, your route and your signature check agree with Sume, and it costs nothing in generation spend.
What the test delivery is for
The test route exists so you can prove the path from Sume to your server before real work depends on it. It needs an API key with account:write. The dummy payload goes to the URL you registered, and a good receiver answers fast with a 2xx status. Delivery has a 10 second timeout, does not follow redirects, and treats only 2xx as success.
If the test never arrives, check the registered URL first. It must be public HTTPS, at most 2048 characters long, and it must answer directly, because Sume does not follow redirects. A URL that points at a redirector or at a private address is rejected or never counted as delivered.
The three checks
Three checks protect the receiver, and the order matters. The secret must not be empty, because an empty secret makes every signature forgeable. The timestamp must be within 300 seconds, which stops replays. The signature must match one of the entries in the header. During a 24 hour rotation the header has comma separated entries, newest first, and any one match is enough.
Compare with hmac.compare_digest, not ==, so the comparison takes the same time for every input. Check the timestamp before you compute the HMAC, as the SDK helper does, so a flood of stale requests costs you almost nothing.
Verifier and router
The sample below signs a body itself and then verifies it, so it runs with no network. Read the body as raw bytes before any JSON parsing, because a re-serialized body does not verify.
The router answers 204 in every branch on purpose. A slow handler is the usual reason a receiver times out at 10 seconds, so in a real service record the event, return the 2xx right away, and do the heavy work in a queue after the response has gone out.
import hashlib, hmac, json, time
def verify(body: bytes, headers: dict, secret: str, tolerance=300) -> bool:
if not secret:
raise RuntimeError("refusing to run with an empty webhook secret")
ts = headers.get("x-sume-webhook-timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
entries = headers.get("x-sume-webhook-signature", "").split(",")
return any(hmac.compare_digest(e.strip(), "sume-v1=" + mac) for e in entries)
def route(event: dict) -> int:
kind = event.get("event")
if kind == "webhook.test":
return 204
if kind in ("job.completed", "job.failed", "job.canceled"):
print("job", event["job_id"], kind)
return 204
return 204
secret, body, ts = "whsec_demo", json.dumps({"event": "webhook.test"}).encode(), str(int(time.time()))
sig = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
hdrs = {"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": "sume-v1=" + sig}
print(verify(body, hdrs, secret), route(json.loads(body)))
Where the secret comes from
Load the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the same name the docs and the SDK use. Read it with GET /v1/webhooks/signing-secret, which needs account:read, or from the Webhooks tab of the dashboard. The secret is derived for your workspace, so a valid signature proves that Sume signed the delivery for you. Check the value at startup, and stop the process if it is missing.
If a signature does not verify, compare the fingerprints. Each delivery carries x-sume-webhook-secret-fingerprint, and the dashboard shows the fingerprint of the secret you hold. A mismatch means you have the wrong secret. A match with a failed signature points to a body that was changed before it reached your code.
After the verification
Answer 204 for any event you do not know. A new event type should not cause a 500 and a retry storm. Dedupe real job events on job_id or request_id, because delivery is at least once. Large receipts over 1 MiB arrive with payload set to null, an error.code of payload_too_large and a result_url, so fetch the result from that URL when you see it.
Sources
Related posts
More in Developers
- Python: three Wan 3.0 hooks from one reference image, with costs
A Python script for Sume's /v1/videos: submit three Wan 3.0 hook prompts with one reference image at 480p, poll each job, and print the usage cost.
- Python TTS cost calculator: MAI-Voice-2.1, Flash and Sume per job
A short Python function prices any script on MAI-Voice-2.1 ($22/M), Flash ($15/M) and Sume (list x 1.25, rounded up per job); 210 vs 211 characters shown.
- Test a video poll loop with unittest and a local server, no spend
A 30-line stdlib file tests a Sume video poll loop against a fake server: pending, in_progress, completed in order, and a failed job that stops with no sleep.
- Python urllib only: a Wan 3.0 test job for $0.125
No requests, no SDK: 27 lines of Python urllib submit a 2-second 480p Wan 3.0 job, poll it and save the MP4. The job costs $0.125, the cheapest Wan clip.
Written by Sume