Express breaks your Sume webhook signature: mount express.raw first
If verifyWebhook returns false in Express, a JSON parser changed the body. Mount express.raw on the webhook route only. Working Node code with the SDK.

Why does my Sume webhook signature fail in Express?
Because express.json() ran first. Sume signs the exact bytes it sent, as HMAC-SHA256 over <timestamp>.<raw_body>. A parsed and re-serialized object has different whitespace or key order, so the digest does not match even though the secret is right. The fix is to give the webhook route the raw body and parse it yourself after the check.
The SDK's verifyWebhook takes the body as a string or bytes, reads the two headers from req.headers in any case, and returns false instead of throwing when something is wrong. That makes a wrong parser order easy to miss: you get a clean 401 and no stack trace.
What each failure looks like
Check the cheap causes first. All of them return false from the verifier, so log which one you have before changing code.
| Symptom | Cause | Fix |
|---|---|---|
| Always false, secret is right | express.json() ran before the route | Mount express.raw on this route first |
| False for old deliveries | Timestamp outside 300 s | Fix server clock, or set toleranceSeconds |
| False only after a rotation | Old secret still deployed | Deploy the new secret; a header can carry both |
| False, header missing | A proxy dropped x-sume-webhook-* headers | Forward them unchanged |
Route order that works
Register the webhook route with its own raw parser before any global JSON middleware, then add express.json() for the rest of the app. The guard on the secret matters too: an unset environment variable becomes undefined, and a verifier that accepts an empty secret would sign nothing and approve everything.
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();
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).end();
const event = JSON.parse(body);
console.log(event.event, event.job_id);
res.status(204).end();
});
app.use(express.json());
app.listen(3000);After the check
Return a 2xx fast, because each attempt has a 10 second timeout, and do the slow work after the response. Deduplicate on job_id, since Sume may deliver the same terminal event more than once. The same route can serve job webhooks and run webhooks, because both use the sume-v1 scheme and the same signing secret.
If you cannot change the middleware order, capture the raw bytes in the JSON parser's verify callback and keep them on the request. Either way the rule is the same: the signature covers bytes, so verify bytes.
Test it with the dashboard's test delivery before you rely on a real job. A signed webhook.test event goes through the same route and the same verifier, so a green test means the parser order, the secret and the headers are all correct in the environment you will actually run.
Sources
Related posts
More in Developers
- Express receiver for Sume TTS job webhooks: raw body and HMAC
A Node Express route that verifies Sume job webhooks over the raw body, accepts rotated secrets, rejects an empty secret and answers 204 before any work.
- Fall back to a second image model after three 502s: a Python breaker
Sume returns 502 when an image job fails inside the wait budget. Count them, switch to a second model id after three, and use a new idempotency key per model.
- Gemini CLI and the hosted Sume server: do not rely on env in headers
Add the hosted Sume server to Gemini CLI with httpUrl and a bearer header. Gemini expands env vars only in the env block; set a timeout above jobs_wait.
- Gemini Omni 4K on Sume: send 4K or 4k, 10 s max, 16:9 or 9:16
Sume's gemini-omni-flash-1.1 takes 4K, and lowercase 4k works as an alias. Requests run 3 to 10 seconds, 16:9 or 9:16. Python body builder.
Written by Sume