OpenRouter's video expired event has no Sume twin: one normalizer
OpenRouter sends completed, failed, cancelled and expired video events; Sume sends three job events. A Python normalizer and verifier for both.

OpenRouter's video webhooks have four events, video.generation.completed, failed, cancelled and expired, and Sume's job webhooks have three: job.completed, job.failed and job.canceled. There is no Sume event for expiry, so a handler that listens for both has to map four names onto three outcomes and decide what an expired OpenRouter job means for you.
OpenRouter's side is from its video generation guide, read 2026-10-10. Sume's side is from the Webhooks and Videos API docs.
The two envelopes
OpenRouter's page says a per-request callback_url takes priority over the workspace default, that an idempotency header of the form <job_id>-<status> is sent, and that the signature header X-OpenRouter-Signature carries t= and v1= values, an HMAC-SHA256 over {timestamp},{raw_body}, to be rejected when over 5 minutes old.
Sume's webhook carries x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>, an HMAC-SHA256 over <timestamp>.<raw_body>. The body has event, request_id, job_id, status (OK or ERROR) and payload. On /v1/videos, a callback_url produces Sume's standard envelope, not the OpenRouter one.
| Item | OpenRouter | Sume |
|---|---|---|
| Events | video.generation.completed, failed, cancelled, expired | job.completed, job.failed, job.canceled |
| Signature header | X-OpenRouter-Signature (t=, v1=) | x-sume-webhook-signature: sume-v1=<hex> |
| Signed string | {timestamp},{raw_body} | <timestamp>.<raw_body> |
| Timestamp header | Inside the signature header | x-sume-webhook-timestamp |
| Replay window | Reject if over 5 minutes old | Five minutes is the suggested tolerance |
| Dedupe key | The idempotency header, <job_id>-<status> | job_id in the body |
| Retries | Not read from the page | Up to 10 attempts, 30 s apart, 10 s timeout |
A normalizer and a Sume verifier
The first function maps both vocabularies onto three outcomes. The second verifies Sume's signature and refuses an empty secret, since an empty key would let anyone forge a valid-looking digest.
import hashlib, hmac, time
OUTCOME = {
"video.generation.completed": "completed", "job.completed": "completed",
"video.generation.failed": "failed", "job.failed": "failed",
"video.generation.cancelled": "canceled", "job.canceled": "canceled",
"video.generation.expired": "failed",
}
def normalize(event):
return OUTCOME[event]
def verify_sume(secret, ts, sig_header, raw, now=None, tol=300):
if not secret:
raise ValueError("empty signing secret")
if abs((now or time.time()) - int(ts)) > tol:
return False
want = hmac.new(secret.encode(), f"{ts}.".encode() + raw,
hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p.strip(), f"sume-v1={want}")
for p in sig_header.split(","))
raw = b'{"event":"job.completed"}'
sig = "sume-v1=" + hmac.new(b"s3cret", b"1780000000." + raw, hashlib.sha256).hexdigest()
print(verify_sume("s3cret", "1780000000", sig, raw, now=1780000100))
print(normalize("video.generation.expired"))What to do with expired
I mapped expired to failed in the code, but that is a choice, not a fact from either vendor. The OpenRouter page names the event; the portion I read did not define what expires, so check it before deciding. For Sume, do not wait for an event that never comes: keep the poll as a backup, as the Sume docs advise, and read GET /v1/jobs/:id/status if a delivery is missing.
Verify against the raw bytes, not a re-serialised body, and return a 2xx only after you have stored the event. Sume retries up to ten times and you can ask for a fresh delivery with POST /v1/jobs/{id}/webhook/redeliver.
Idempotent handling
Store the dedupe key before you act. For OpenRouter that is the idempotency header the page describes, and for Sume it is the job_id, which the docs recommend as your own idempotency key. Because a retry can arrive after your first attempt succeeded but before your 2xx got back, the second delivery must change nothing.
Finally, log the normalised outcome and the raw event name side by side. The raw name keeps the difference between a cancel and an expiry visible in your history, even though your business logic treats some of them the same.
Sources
Related posts
More in Developers
- Pick the cheapest Sume image row for a ratio and 3 refs (Python)
A 24-line Python script reads GET /v1/images/models and the endpoints route, filters by aspect ratio and reference count, and sorts by billed price.
- Pick the highest resolution a Sume video model lists (Python)
Sume's catalog row is now the one resolution list for both video endpoints. A short Python helper reads supported_resolutions and steps down instead of failing.
- Port a fal queue submit and poll loop to Sume /v1/videos in Python
A fal queue client posts to queue.fal.run, polls a status URL and fetches a result. The Sume version is 21 lines of Python on /v1/videos. Field map, traps.
- Port an ElevenLabs text-to-speech call to Sume TTS 1.0, field by field
ElevenLabs puts the voice in the URL; Sume TTS 1.0 takes it in the body and rejects model_id. A field map and a tested mapper function.
Written by Sume