Shorts season webhooks: a Python receiver that counts episodes
Receive Sume job webhooks for a season of Shorts renders: verify sume-v1, reject an empty secret, de-dupe by job_id, and know when every episode is terminal.

What the receiver has to do
A Shorts season is a batch of independent render jobs, and the question that matters is "are all N episodes terminal yet?" Polling answers it, but a webhook lets you stop polling. Sume sends signed terminal events only: job.completed, job.failed and job.canceled, with no progress or partial deliveries. The receiver below verifies the signature, ignores anything unsigned or stale, records each job_id once, and prints how many episodes have reached a terminal state.
The timing is topical. The October platform roundup reports YouTube's Shorts series, with seasons, episodes and sequential playback, rolling out from 23 September on web, mobile and TV. Publishing a season in order is a batch problem, and a receiver that knows which episode numbers are done is the first piece.
The signature, exactly as Sume documents it
Each delivery carries x-sume-webhook-timestamp and x-sume-webhook-signature. The signed string is <timestamp>.<raw_body>, hashed with HMAC-SHA256 under your signing secret, and the header value looks like sume-v1=<hex>. During a secret rotation the header carries one entry per live secret, newest first, separated by commas, so accept the delivery if any sume-v1= entry matches. Reject the callback when the timestamp is outside your replay window; 300 seconds is the default in Sume's own verifier.
Two details cause most bugs. Verify against the raw bytes, not a re-serialized JSON object, because whitespace changes the HMAC. And refuse an empty secret before comparing anything: an unset environment variable would otherwise turn into a verifier that signs with an empty key. The code below returns false when the secret is empty.
The receiver (Python standard library only)
Set SUME_COM_WEBHOOK_SIGNING_SECRET to the secret from the Webhooks tab of the dashboard. Fill EPISODES with the request_id values returned when you submitted each render, mapped to the episode number you track. The handler answers 200 only after it has recorded the event.
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
EPISODES = {"job_a": 1, "job_b": 2} # job_id -> episode, saved when you submitted
DONE = {}
def verify(ts, header, raw, secret, tolerance=300):
try:
if not secret or abs(time.time() - int(ts)) > tolerance:
return False
except (TypeError, ValueError):
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
return any(hmac.compare_digest(p.strip(), want) for p in header.split(","))
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
h = self.headers
if not verify(h.get("x-sume-webhook-timestamp"),
h.get("x-sume-webhook-signature", ""), raw, SECRET):
return self.send_response(401), self.end_headers()
event = json.loads(raw)
if event.get("job_id") in EPISODES:
DONE[event["job_id"]] = event["event"] # same job_id twice is harmless
print(sorted(EPISODES[j] for j in DONE), "of", len(EPISODES), "episodes terminal")
self.send_response(200), self.end_headers()
HTTPServer(("", 8080), Hook).serve_forever()Delivery behavior you can rely on
These are the documented numbers; design the receiver around them.
| Topic | Documented behavior | What to do in the receiver |
|---|---|---|
| Events | job.completed, job.failed, job.canceled only | Treat all three as terminal for the episode |
| Retries | Up to 10 attempts, fixed spacing (30 s default) | Record by job_id so a repeat changes nothing |
| Timeout | 10 s per attempt | Store the event, then answer; do slow work later |
| After 10 refusals | Delivery fails, the job still finished | Keep polling status_url as the recovery path |
| Redeliver | Fresh timestamp and signature, not counted in the 10 | Verify the new signature like any other |
Failure paths worth testing
Use the dashboard's Send test, or POST /v1/webhooks/test-deliveries with an account:write key, to post a signed webhook.test payload to your URL. That event has no job_id, so the receiver above ignores it and still answers 200, which is what you want from a connectivity check. To replay a real event, call POST /v1/jobs/{job_id}/webhook/redeliver, which re-sends the job's actual terminal event with a fresh signature.
Failed and canceled events use status: "ERROR" and include an error object. For a season that means one episode can end job.failed while the others complete. Do not wait for N completions; wait for N terminal events, then retry only the failed episodes with new idempotency keys. The job's result and the status_url stay available if a delivery never arrives, and the documented loop for that is in jobs and results.
Where this fits in a season pipeline
Submit each Timeline 1.0 render with a webhook_url, keep the returned request_id against the episode number, and let this process fill in DONE. Replace the dictionary with a database table keyed on job_id, since a process restart would otherwise forget what finished. Once every episode is terminal, publish in episode order, not completion order, because renders finish out of order.
Two production habits are worth adding early. Return a 2xx only after the event is stored, because Sume treats anything else as a failed attempt and spends one of its ten. And keep the status poll as a nightly reconciliation: list the episodes you submitted, read each job's status, and compare against what the webhook recorded. A missed delivery then shows up as a gap you can fill, not as an episode that never published.
Sources
Related posts
More in Developers
- Sora API gone: 9:16 portrait video on Sume, model by model
Which Sume video models return 9:16 after the Sora API ended on 2026-09-24, how to ask for it, and a script that reads the catalog instead of trusting a table.
- Parse a Sume video poll response with a dataclass, no network
A stdlib Python dataclass for the /v1/videos poll response (status, unsigned_urls, usage.cost, error), tested on sample JSON so a ported client fails early.
- Export old video prompts to JSONL, then re-render on Sume resumably
A prompt manifest for a Sora back catalog: one JSON line per clip with a stable idempotency key and status, and a runnable script that skips what is done.
- Photo to video on Sume: frame_images or input_references?
Which Sume video field starts from your photo and which only guides style. If you send both, frame_images wins. Two request bodies and a check you can run.
Written by Sume