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.

5 min readSume
All posts

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.

Webhook differences, vendor docs read 2026-10-08
ItemOpenAI guideSume docs
Success eventvideo.completedjob.completed
Failure eventvideo.failedjob.failed, plus job.canceled
Where configuredplatform settings pagedashboard webhooks page, or callback_url per request
Signature headersnot covered in the guide text I readx-sume-webhook-timestamp, x-sume-webhook-signature (sume-v1=hex)
Payloadvideo id and statusevent, 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

All Developers posts

Written by Sume