One webhook route for OpenRouter video and Sume job events
Normalize OpenRouter video.generation.* and Sume job.* webhook bodies to one outcome type, and answer 204 to events you do not know. Runnable Bun/Node code.

If you move an OpenRouter video client to Sume in stages, one receiver can handle both envelopes. Branch on the body: Sume sends event and job_id (job.completed, job.failed, job.canceled), while OpenRouter sends type and data.id (video.generation.completed, failed, cancelled, expired). Map both to one outcome and answer 204 to anything else.
The two envelopes side by side
Sume's video docs say that a callback_url on /v1/videos receives Sume's standard job webhook envelope, not the OpenRouter video.generation.* envelope. OpenRouter's guide documents the other one.
| Outcome | OpenRouter `type` | OpenRouter id field | Sume `event` | Sume id field |
|---|---|---|---|---|
| Completed | video.generation.completed | data.id | job.completed | job_id |
| Failed | video.generation.failed | data.id | job.failed | job_id |
| Canceled | video.generation.cancelled | data.id | job.canceled | job_id |
| Expired | video.generation.expired | data.id | no such event | none |
The normalizer
The code below returns null for unknown events. I tested it with Bun. The expired mapping to failed is a choice for this example, and Sume has no job.expired event to receive.
type Outcome = { jobId: string; state: "done" | "failed" | "canceled" };
export function normalize(raw: string): Outcome | null {
const e = JSON.parse(raw);
// Sume job webhook: { event: "job.completed", job_id }
if (typeof e.event === "string" && e.event.startsWith("job.")) {
const state = { "job.completed": "done", "job.failed": "failed", "job.canceled": "canceled" }[
e.event as string
] as Outcome["state"] | undefined;
return state ? { jobId: e.job_id, state } : null;
}
// OpenRouter: { type: "video.generation.completed", data: { id } }
if (typeof e.type === "string" && e.type.startsWith("video.generation.")) {
const state = {
completed: "done", failed: "failed", cancelled: "canceled", expired: "failed",
}[e.type.split(".")[2] as string] as Outcome["state"] | undefined;
return state ? { jobId: e.data.id, state } : null;
}
return null; // unknown event: answer 204, never 500
}
console.log(normalize('{"event":"job.completed","job_id":"job_1"}'));
console.log(normalize('{"type":"video.generation.expired","data":{"id":"abc"}}'));
console.log(normalize('{"event":"webhook.test"}'));Rules that stay the same
Verify the signature before you parse, and verify the raw bytes. The two schemes differ, so a single verifier needs a branch on the header name, as the signature comparison shows. Unknown events should return 204, not 500. The Verifying webhooks page makes the same point: a new event type must not cause a retry storm.
On the Sume side, a failed and a canceled job both arrive with status: "ERROR" and an error object, so branch on event, not on status.
Dedupe differs
OpenRouter adds an X-OpenRouter-Idempotency-Key header of the form <job_id>-<status>. Sume's docs tell you to use job_id as the idempotency key on your side. In a shared receiver, key the table on the normalized job id and the state, and store before you answer.
Keeping the two paths separate
Verify the signature before you parse the JSON. The two senders sign different strings, so route on the header names, not on guesses from the body. Reject anything that matches neither set of headers, and refuse to run with an empty secret.
Normalize into one internal event with a job id, a state, and a result URL, then hand that to the rest of your code. Keep the raw body for the signature check, because re-serialized JSON will not match the signed bytes.
Sources
Related posts
More in Developers
- OpenAI's three tiers vs Sume plan concurrency of 1, 4, 8 and 20
OpenAI cut API usage tiers from five to three on Oct 6. Sume sets concurrency by plan, not spend. The plan numbers and the queue math, side by side.
- OpenRouter video client on Sume: cancelled is terminal, callback_url
Moving an OpenRouter video client to Sume: add cancelled to your terminal statuses, send callback_url on each request, and keep unknown statuses non-terminal.
- Org workspace concurrency floor of 10: the queue and wave that follow
Sume gives org workspaces a processing floor of 10. With the default queue formula that is 50 queued, 60 accepted, and a wave hint of 45. Read your own fields.
- Pick a Sume video model in code: audio refs, 1080p, 20 seconds
Filter GET /v1/video-router/models on reference_audios, resolutions and duration_seconds, then submit the survivor with reference_audio_urls (up to five).
Written by Sume