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.

6 min readSume
All posts

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.

OpenRouter facts from its video guide (read 2026-10-10); Sume facts from the Webhooks and Videos API docs.
ItemOpenRouterSume
Eventsvideo.generation.completed, failed, cancelled, expiredjob.completed, job.failed, job.canceled
Signature headerX-OpenRouter-Signature (t=, v1=)x-sume-webhook-signature: sume-v1=<hex>
Signed string{timestamp},{raw_body}<timestamp>.<raw_body>
Timestamp headerInside the signature headerx-sume-webhook-timestamp
Replay windowReject if over 5 minutes oldFive minutes is the suggested tolerance
Dedupe keyThe idempotency header, <job_id>-<status>job_id in the body
RetriesNot read from the pageUp 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

All Developers posts

Written by Sume