Verify Sume job webhooks in Express: raw body, sume-v1, replay window
Verify a Sume job.completed webhook in Express with @sume-com/sdk verifyWebhook: raw body, empty secret refused, dedupe on job_id, answer 2xx fast.

Mount express.raw() on the webhook route only, call verifyWebhook from @sume-com/sdk on the unparsed body, and return a fast 2xx once you have stored the event. Sume signs <timestamp>.<raw_body> with HMAC SHA-256, and a parsed-then-reserialized body will not match.
The same verifier covers job webhooks (job.completed, job.failed, job.canceled) and run webhooks, so route on the event field.
Why the raw body matters
Key order and whitespace are part of the signed bytes. If a JSON middleware ran before your handler, the original bytes are gone. In Express that means express.raw({ type: "application/json" }) on this route, mounted before any express.json().
verifyWebhook is async because it uses WebCrypto. It returns false instead of throwing for a missing header, a bad timestamp, or a wrong signature, and it rejects timestamps outside a 300-second window by default.
| Header | Content |
|---|---|
| x-sume-webhook-timestamp | Unix seconds, part of the signed string |
| x-sume-webhook-signature | sume-v1=<hex>, may list two entries during a rotation |
| x-sume-webhook-secret-fingerprint | Identifies the secret in use, safe to log |
The receiver
Read the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, and refuse to start without it. A verifier with an empty secret accepts forged requests.
import express from "express";
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 required");
const seen = new Set(); // use a database table in production
const app = express();
app.post("/hooks/sume", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
const ok = await verifyWebhook({ body, headers: req.headers, secret });
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(body);
if (!seen.has(event.job_id)) {
seen.add(event.job_id);
console.log(event.event, event.job_id);
}
res.status(204).end();
});
app.listen(3000);After the 2xx
Do the slow work, such as copying the video, after you respond. Sume gives each attempt 10 seconds. Keep the job_id set in storage so a retry or a manual redeliver does not do the work twice.
Sources
Related posts
More in Developers
- verifyWebhook in a fetch handler: four rules, 204 for unknown events
Use @sume-com/sdk verifyWebhook on the raw body, await it, treat false as 401 and answer unknown events with 204. A runnable handler for Workers, Deno and Node.
- Video API callback_url and Idempotency-Key: a sume/auto job in curl
Submit a video job on Sume with callback_url instead of polling, add an Idempotency-Key so retries are safe, and let sume/auto pick the model. Curl and errors.
- Will polling video jobs trigger a 429? Reads and writes differ
Each Sume API key has a write budget and a read budget 40 times larger, so a status-poll loop cannot 429 your submits. Poll math, headers and a 429 backoff.
- Voice agent narrates a long task: poll a Sume video job meanwhile
Decagon Voice 3 narrates progress during long tasks. The same pattern for a voice agent that starts a Sume job: submit, speak, poll jobs_wait.
Written by Sume