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.

4 min readSume
All posts

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.

Terminal webhook events (OpenRouter guide and Sume docs, read 2026-10-09)
OutcomeOpenRouter `type`OpenRouter id fieldSume `event`Sume id field
Completedvideo.generation.completeddata.idjob.completedjob_id
Failedvideo.generation.faileddata.idjob.failedjob_id
Canceledvideo.generation.cancelleddata.idjob.canceledjob_id
Expiredvideo.generation.expireddata.idno such eventnone

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

All Developers posts

Written by Sume