Fastify: verify a Sume video webhook where Sora's video.completed was
Swap the Sora video.completed handler for Sume's job.completed in Fastify. Keep the raw string body, verify sume-v1 with the SDK, and refuse an empty secret.

In Fastify, the Sume receiver needs the raw request body as a string, because the signature is an HMAC-SHA256 over the timestamp, a dot and those exact bytes. Register a content type parser with parseAs string, call verifyWebhook from @sume-com/sdk, and only then JSON.parse. Your old handler for Sora's video.completed becomes a branch on event job.completed.
OpenAI's video guide, read 2026-10-08, names video.completed and video.failed as its webhook events, set up on a platform settings page; Sume sends job.completed, job.failed and job.canceled.
Event and header map
Sume passes the delivery's signature and time in two headers. The verifier checks both and rejects anything older than five minutes unless you change the tolerance.
| Item | OpenAI guide | Sume docs |
|---|---|---|
| Success event | video.completed | job.completed |
| Failure event | video.failed | job.failed, plus job.canceled |
| Where configured | platform settings page | dashboard webhooks page, or callback_url per request |
| Signature headers | not covered in the guide text I read | x-sume-webhook-timestamp, x-sume-webhook-signature (sume-v1=hex) |
| Payload | video id and status | event, request_id, job_id, status, payload.artifacts[] |
The handler
Set SUME_COM_WEBHOOK_SIGNING_SECRET to the value from the dashboard or from GET /v1/webhooks/signing-secret. The program refuses to start with an empty secret instead of silently accepting unsigned calls. Run it as an ES module.
import Fastify from "fastify";
import { verifyWebhook } from "@sume-com/sdk";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const app = Fastify();
app.addContentTypeParser("application/json", { parseAs: "string" },
(req, body, done) => done(null, body));
app.post("/hooks/sume", async (req, reply) => {
const ok = await verifyWebhook({ body: req.body, headers: req.headers, secret });
if (!ok) return reply.code(401).send({ error: "bad signature" });
const event = JSON.parse(req.body);
if (event.event === "job.completed") {
console.log(event.job_id, event.payload.artifacts[0]?.url);
}
return reply.code(200).send({ ok: true });
});
await app.listen({ port: 3000 });Retries and duplicates
Sume retries a failed delivery up to 10 times, 30 seconds apart, with a 10 second timeout on each attempt, so return 200 fast and do slow work afterward. Use job_id as your idempotency key: a retry or a manual redelivery carries the same job.
Do not put a body-parsing plugin in front of this route that reserializes JSON; a parsed-and-rewritten body will not verify. The event mapping post shows the payload fields in full.
Sources
Related posts
More in Developers
- Find the timestamp of a quote in a recording with Sume STT words
Sume's STT result returns every word with start and end seconds. A short Python function finds a quoted phrase and returns where to cut. About a cent a minute.
- First-frame image for /v1/videos: public HTTPS only, no signed URLs
Image and video inputs to Sume generation must be fetchable public HTTPS URLs. Localhost, private IPs, signed URLs and wrong content types are rejected.
- Free, Pro, Startup, Scale: processing seats, queue slots, full hold
Sume's concurrency by plan, queue capacity max(3, 5 x concurrency), accepted job capacity, and the balance reserved if every slot holds a 10 s clip.
- Typed Sume video client from the OpenAPI JSON, after Sora
Sume publishes OpenAPI 3.0.3 at api.sume.com/reference/json. List the four video operations, then use the SDK's generated calls instead of hand-typing the wire.
Written by Sume