Python receiver for a Sume /v1/videos callback_url, signature checked
A standard-library Python webhook receiver for Sume video jobs: checks x-sume-webhook-signature, refuses an empty secret, rejects stale timestamps.

A Sume webhook receiver does four things: read the raw body, check the timestamp is within five minutes, compare an HMAC-SHA256 of timestamp.dot.body against the sume-v1 entries in x-sume-webhook-signature, and only then parse the JSON and return a fast 2xx. The code below does that with the Python standard library and refuses to start without a secret.
What Sume sends on a video job
Add callback_url, an HTTPS public URL, to the POST /v1/videos body, and Sume posts to it when the job is terminal. Sume's job webhook has three events, job.completed, job.failed and job.canceled, and no progress events. The envelope is Sume's own, not the OpenRouter video.generation.* names, and the signature header is x-sume-webhook-signature rather than X-OpenRouter-Signature.
The signing secret is per workspace. Read it from the dashboard Webhooks tab, or from GET /v1/webhooks/signing-secret with a key that has account:read, and export it as SUME_COM_WEBHOOK_SIGNING_SECRET.
Sizing the receiver is simple. A video job produces exactly one terminal event, so a 100-clip batch produces about 100 posts over minutes, not a flood. A single-threaded server handles that. The real risk is slowness: each attempt has a 10-second timeout, so do the durable write and return, and do the heavy work, such as downloading the clip, from a queue.
The signature rules
| Item | Value |
|---|---|
| Signed string | timestamp, a dot, then the raw body |
| Algorithm | HMAC-SHA256, hex, prefixed sume-v1= |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature |
| Replay window | 300 seconds is a reasonable default |
| During secret rotation | Several comma-separated sume-v1 entries; accept any match |
| Delivery | Up to 10 attempts, 10 s timeout each; return 2xx after storing |
The receiver
Save as hook.py, export the secret, and run it. It answers 401 on a bad or stale signature and 204 otherwise. Replace the print with a durable write keyed on job_id.
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
raise SystemExit("SUME_COM_WEBHOOK_SIGNING_SECRET is empty")
def verify(raw: bytes, ts: str, header: str, tol: int = 300) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > tol:
return False
mac = hmac.new(SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
ok = False
for part in header.split(","):
ok |= hmac.compare_digest(part.strip(), want)
return ok
class H(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
h = self.headers
if not verify(raw, h.get("x-sume-webhook-timestamp", ""), h.get("x-sume-webhook-signature", "")):
return self.send_response(401) or self.end_headers()
ev = json.loads(raw)
print(ev.get("event"), ev.get("job_id")) # store durably, dedupe on job_id
self.send_response(204); self.end_headers()
HTTPServer(("", 8080), H).serve_forever()
Why it is written this way
The empty-secret check matters. With an empty key, HMAC still produces a value, and an attacker who knows that can forge a matching signature. Failing at startup removes the case. The loop compares every entry, even after a match, so timing does not tell a caller which secret matched during a rotation window.
The raw bytes are the signed data. If your framework parses JSON before your code runs, the key order and whitespace are gone and nothing verifies, which is why this server reads the body itself.
Finally, test the receiver before real money flows. The dashboard Send test action posts a dummy signed webhook.test payload to a URL you type, and it never replays a real job. Point it at a tunnel to port 8080, and a 204 tells you the secret, the clock and the raw-body handling are all correct.
Before you point Sume at it
- Put it behind HTTPS on a public host. Sume rejects localhost, private networks and plain HTTP.
- Treat job_id as the idempotency key on your side, because redelivery and retries can repeat an event.
- Keep polling as a backup. Delivery is an optimization and the job reaches its real state either way.
- If a signature fails, compare x-sume-webhook-secret-fingerprint with the fingerprint in the dashboard. Do not paste the secret into a ticket.
Sources
Related posts
More in Developers
- 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.
- 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.
Written by Sume