Express route for a Sume webhook: raw body, verifyWebhook, 204
In Express, express.json() breaks the Sume signature check. Mount express.raw on the webhook route only, call verifyWebhook, and return a fast 2xx. TypeScript.

In Express, mount express.raw({ type: "application/json" }) on the Sume webhook route only, pass req.body and req.headers to verifyWebhook from @sume-com/sdk, and return a 2xx once the event is stored. If express.json() runs first on that route, the bytes are already parsed, and the signature will not verify.
The Verifying webhooks page lists this as the first of four rules: pass the raw body, because a parsed-and-reserialized object does not verify. Key order and whitespace are part of the signed data. The other rules are that verifyWebhook is async, returns false instead of throwing, and compares in constant time.
The route
The SDK has no runtime dependencies and needs fetch and WebCrypto, so Node 18+ works. The snippet reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name Sume's own delivery worker uses, and refuses to start handling requests if it is missing.
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 not set");
const app = express();
// Raw bytes on this route only. Do not mount express.json() above it.
app.post("/hooks/sume", express.raw({ type: "application/json" }), async (req, res) => {
const ok = await verifyWebhook({ body: req.body, headers: req.headers, secret });
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
await saveEvent(event.job_id ?? event.run_id, event); // store before replying
return res.status(204).end();
});
app.use(express.json()); // every other route may parse JSON
app.listen(3000);What verifyWebhook accepts
The input table is from the same page, read 2026-10-09.
| Field | Accepts | Notes |
|---|---|---|
body | string, ArrayBuffer, or a typed array | Raw bytes; a Node Buffer is a typed array |
headers | Headers, Map, or a plain object | Case-insensitive; Node's req.headers works |
secret | Your signing secret | Not the API key |
toleranceSeconds | Number, default 300 | 0 skips the timestamp check |
Order of middleware is the usual bug
Express runs middleware in the order you mount it. If app.use(express.json()) sits above the webhook route, the body is consumed before express.raw sees it, and req.body is a parsed object. The symptom is a valid secret and a 401 on every delivery. Mount the parser below the webhook route, as the snippet does, or give the route its own router.
Do the slow work after the reply is decided. Sume gives each attempt 10 seconds and retries a slow endpoint, so store the event, answer 204, and process in a queue. Use job_id as the dedupe key on your side, since retries and manual redelivers send the same event again.
The route also handles run webhooks. Job webhooks (job.completed, job.failed, job.canceled) carry job_id, and run webhooks (format.run.terminal and its siblings) carry run_id, but both use the same sume-v1 scheme, so one handler verifies both. Route on event after verification and do not assume a body has a particular id field. The snippet falls back from one to the other for that reason.
If checks still fail after the middleware is fixed, compare the x-sume-webhook-secret-fingerprint header with the fingerprint next to the secret in the dashboard. A mismatch means the service holds a different secret than the one that signed the delivery.
Sources
Related posts
More in Developers
- Face swap API sync mode waits at most 30 s: then poll, do not resubmit
Sume's job modes cap sync and subscribe waits at 30 seconds. A face swap that is not terminal by then returns the envelope: poll the job, never resubmit.
- Face swap webhook receiver in Python that refuses an empty secret
Verify a Sume face swap job.completed webhook in Python: HMAC SHA-256 over timestamp.body, sume-v1 entries, a 5-minute window, and an empty secret refused.
- A failed Sume video poll has error as a string, not an error object
On /v1/videos, HTTP errors use {error:{code,message}} but a failed poll carries error as a string. Read both without a TypeError in Python and TypeScript.
- ffprobe check for Gemini Omni reference videos: 3 files, 3 s each
Before sending reference videos to Sume's gemini-omni-flash-1.1, check with ffprobe that you have one to three files and each is 3.0 s or shorter. Bash script.
Written by Sume