No queue webhook on Sume bulk runs: count item webhooks instead
Sume bulk queues have no queue-level webhook. Put a webhook_url on each item and count terminal events to know a season is finished.

Set communication.webhook_url on every item in the bulk request, then count the terminal events that arrive. Sume offers no queue-level webhook, so the season is finished when your counter reaches the number of items you queued. Poll the queue once as a backstop in case an event never lands.
This works because each queued child is an ordinary Format run, and an ordinary run can notify a URL when it ends (Sume docs: Bulk runs, read 2026-10-06).
What each event gives you
The terminal event is signed. Headers carry a timestamp, a sume-v1 HMAC-SHA256 signature over timestamp.raw_body, and a secret fingerprint. The window is five minutes, delivery retries up to 10 times with 30 seconds doubling each attempt and capped at one hour, and you should dedupe on request_id and order by created_at. The outcome is ok, degraded or error, and payload is null when the receipt exceeded 1 MiB, in which case you fetch result_url (Sume docs: Format runs, read 2026-10-06).
A counter that survives retries
Retries mean the same event can arrive twice. Count distinct request_id values, not requests. Store them in a set, and compare its size with the queued total. Verify the signature first and reject an empty secret, so an unconfigured server cannot be fed forged events.
import hashlib, hmac, os, time
SECRET = os.environ.get("SUME_WEBHOOK_SECRET", "")
TOTAL = 24
seen = set()
def verify(ts, sig, raw):
if not SECRET:
raise RuntimeError("empty webhook secret")
if abs(time.time() - int(ts)) > 300:
return False
mac = "sume-v1=" + hmac.new(SECRET.encode(), ts.encode() + b"." + raw,
hashlib.sha256).hexdigest()
return any(hmac.compare_digest(mac, s.strip()) for s in sig.split(","))
def on_event(headers, raw, request_id):
if not verify(headers["x-sume-webhook-timestamp"],
headers["x-sume-webhook-signature"], raw):
return 401
seen.add(request_id)
return 200 if len(seen) < TOTAL else 204Keep a poll as the backstop
A webhook can fail after ten attempts. If your counter is short after the queue shows completed, read the queue counts and fetch the missing children directly. You can also ask Sume to redeliver with POST on the webhook redeliver path for one run.
Whichever signal you use, read counts.failed before you call the season done. Ten events with three of outcome error is a different release decision than ten that are ok.
| Signal | Strength | Weakness |
|---|---|---|
| Per-item webhook | Push, near real time | Can be missed after 10 attempts |
| Queue poll | Authoritative counts | You must keep polling |
| Both | Counter plus backstop | Slightly more code |
Reading the queue receipt as a cross-check
The queue object carries counts with total, queued, running, completed, failed and canceled, plus one row per item with its run_id and an error of null, format_run_failed or format_run_canceled. That receipt is the authoritative record, so compare it with your counter: when status is completed, every item is terminal, and your set of distinct request_id values should equal counts.completed + counts.failed + counts.canceled for children whose webhooks arrived. A shortfall names the children to fetch directly with GET /v1/format-runs/{run_id}.
Two details keep the counter honest. The request_id in a run webhook equals the run id and stays stable across retries, which is why it is the dedupe key, while created_at is the time Sume built that delivery and is the right field for ordering. And the signature header can carry two entries, sume-v1=<new>,sume-v1=<old>, for 24 hours after you rotate the signing secret, so the verifier above accepts any matching entry rather than comparing the whole header.
Where to host the receiver, and what to keep
The receiver needs a public HTTPS URL. A small serverless function or a route in your existing backend is enough, since each event is one short POST that you verify, record and acknowledge with a 2xx. Do the slow work, such as downloading the video, in a queue of your own after you respond, so a slow download does not trigger a retry.
Log the run id the event refers to and the outcome, and nothing else sensitive. Signed media URLs and API keys do not belong in logs. When an event shows outcome degraded, read the receipt before you treat it as a success; degraded means the run finished with a problem worth looking at.
Keep the webhook secret in your secret store and rotate it by creating a new one. The fingerprint header lets you confirm which secret signed a given event during a rotation.
Testing the receiver
Test with a queue of two cheap items before a real season. Confirm both events arrive, that a replayed event does not move the counter, that a bad signature is rejected, and that an empty secret in your config makes the receiver refuse to start. Those four checks cover most of the failures you will meet in production.
Sources
Related posts
More in Formats
- Output schema 400 missing_items: an array node needs items
A Format output_schema with an array and no items fails with 400 and rule missing_items before anything runs. The bad schema, the fix, the violation.
- Output schema unsupported_ref: $defs must live at the schema root
A $ref to a nested $defs, an external URL or a missing name fails with unsupported_ref in a Sume output_schema. Move $defs to the root and fix the pointer.
- Output schema max_enum_values: 1,000 values per enum, then what
Sume refuses an enum of more than 1,000 values with rule max_enum_values. Use a string with a pattern or format, or split the list into two enums.
- Output schema max_depth: 10 levels, and $defs recursion is free
Sume's output_schema allows 10 levels of literal nesting. Past that you get max_depth. A self-referencing $defs entry does not add to the depth count.
Written by Sume