Sora video.completed webhook to Sume job.completed

Sora emitted video.completed and video.failed. Sume sends job.completed, job.failed and job.canceled with an x-sume-webhook-signature header. Map the handler.

4 min readSume
All posts

Map video.completed to job.completed and video.failed to job.failed, then add a branch for job.canceled, which Sora's guide did not list. Sume's payload is its standard job envelope, not OpenAI's, so the handler needs a new parser and a new signature check.

Sora facts are from OpenAI's video generation guide, which says the Videos API shut down on September 24, 2026. Sume facts are from Webhooks; read 2026-09-30.

What did Sora send?

The guide says that when a job finishes the API emits one of two event types, video.completed and video.failed, and each event includes the id of the job that triggered it.

What does Sume send?

Sora events versus Sume job events, read 2026-09-30.
Sora eventSume eventWhen it is sent (Sume docs)
video.completedjob.completedThe job completed and a public result is available
video.failedjob.failedThe job failed with a public error
None listedjob.canceledThe job reached canceled state

What does the Sume payload look like?

The body carries event, request_id, job_id, status and a payload with artifacts. Failed and canceled deliveries use status: "ERROR" with an error object. Sume sends terminal events only, so there is no progress event to ignore. For /v1/videos, pass callback_url in the request body; the docs state the payload is Sume's standard job webhook, not OpenRouter's video.generation.* envelope.

How do I verify the signature?

Sume signs <timestamp>.<raw_body> with HMAC SHA 256 and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex_signature>. During secret rotation the header holds several comma-separated entries; accept any match. Reject timestamps outside your replay window (five minutes is the docs' default).

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySume(rawBody, headers, secret) {
  if (!secret) throw new Error("signing secret is empty");
  const ts = headers["x-sume-webhook-timestamp"];
  if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
  const want = createHmac("sha256", secret)
    .update(ts + "." + rawBody)
    .digest("hex");
  return String(headers["x-sume-webhook-signature"] ?? "")
    .split(",")
    .map((e) => e.trim().replace(/^sume-v1=/, ""))
    .some((got) => {
      const a = Buffer.from(got);
      const b = Buffer.from(want);
      return a.length === b.length && timingSafeEqual(a, b);
    });
}

Where do I debug deliveries?

See debug Sume webhook delivery and OpenRouter video events versus Sume job events. Keep polling as a fallback.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume