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.

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 event | Sume event | When it is sent (Sume docs) |
|---|---|---|
video.completed | job.completed | The job completed and a public result is available |
video.failed | job.failed | The job failed with a public error |
| None listed | job.canceled | The 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
- Sora Videos API replacement: seconds and size on Sume
OpenAI removed the Videos API on 2026-09-24. Map its seconds and size fields to Sume's duration, resolution and aspect_ratio; size returns 400 on Sume.
- Speech to text custom vocabulary: Sume STT takes a language hint only
Gemini 3.5 Transcribe biases up to 1,000 custom terms. Sume STT has no vocabulary field, only a language_code hint, so fix names after transcription.
- Standard Webhooks headers vs Sume sume-v1: a header map
Sume does not send webhook-id, webhook-timestamp or webhook-signature. It sends x-sume-webhook-* headers with a sume-v1 hex signature. Use verifyWebhook.
- Stripe idempotency key length (255) and Sume's key rules
Stripe allows keys up to 255 characters. Sume generation keys are 1-255 printable ASCII and optional; crawl keys are required and capped at 200.
Written by Sume