Verify x-sume-webhook-signature in Node: sume-v1 HMAC, raw body
A node:crypto verifier for Sume's sume-v1 signature that refuses an empty secret, checks the 5-minute window, and accepts either entry during a rotation.

Sume signs <timestamp>.<raw_body> with HMAC SHA-256 and sends the result as x-sume-webhook-signature: sume-v1=<hex> beside x-sume-webhook-timestamp. To verify in Node, recompute the digest over the raw bytes, compare it in constant time against every sume-v1= entry in the header, and reject timestamps more than five minutes old.
Two details cause most failures: parsing the JSON before hashing, and forgetting that during a rotation the header carries two comma-separated signatures. The verifier below handles both and throws if the secret is empty, because an empty key would turn the check into a formality.
The verifier
Pass the body exactly as received, as a string.
import crypto from "node:crypto";
export function verifySume({ rawBody, headers, secret, toleranceS = 300 }) {
if (!secret) throw new Error("webhook secret is empty; refusing to verify");
const ts = Number(headers["x-sume-webhook-timestamp"]);
const header = String(headers["x-sume-webhook-signature"] ?? "");
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > toleranceS) return false;
const digest = crypto
.createHmac("sha256", secret)
.update(ts + "." + rawBody)
.digest("hex");
const want = Buffer.from("sume-v1=" + digest);
let ok = false;
for (const part of header.split(",")) {
const got = Buffer.from(part.trim());
if (got.length === want.length && crypto.timingSafeEqual(got, want)) ok = true;
}
return ok;
}Wiring it to a server
Read the stream into a buffer first; do not let a JSON middleware touch the bytes.
import http from "node:http";
import { verifySume } from "./verify.js";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) throw new Error("set SUME_COM_WEBHOOK_SIGNING_SECRET");
http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!verifySume({ rawBody, headers: req.headers, secret })) {
res.writeHead(401).end();
return;
}
const ev = JSON.parse(rawBody);
console.log(ev.event, ev.job_id); // store durably, dedupe on job_id
res.writeHead(204).end();
}).listen(8080);Checklist
- Get the secret from the dashboard Webhooks tab or
GET /v1/webhooks/signing-secret(needsaccount:read). - Return a fast 2xx after storing the event; Sume retries anything else.
- On a mismatch, compare
x-sume-webhook-secret-fingerprintwith the fingerprint in the dashboard rather than pasting the secret anywhere.
Why constant-time comparison
A normal string comparison stops at the first differing byte. Over many guesses that timing difference can leak how much of a signature is right. crypto.timingSafeEqual compares in time that does not depend on where the bytes differ, but it throws on buffers of different length, which is why the code checks the length first. The loop compares every entry in the header rather than returning on the first match, so timing does not reveal which entry matched.
Common failures
- Using
JSON.stringify(req.body): re-serialization changes key order or spacing, and the digest no longer matches. - Reading
Date.now()in milliseconds as seconds: the timestamp header is in seconds. - Comparing the whole header to one signature: this breaks during a 24-hour rotation window.
- Logging the secret while debugging; log the fingerprint header instead.
Sources
Related posts
More in Developers
- verifyWebhook toleranceSeconds: 300 by default, and 0 turns replay off
In the Sume SDK, verifyWebhook rejects deliveries older than 300 seconds by default. toleranceSeconds 0 skips that check. When each setting is right.
- verifyWebhook toleranceSeconds 0 turns off the Sume replay check
In @sume-com/sdk, toleranceSeconds defaults to 300 and 0 skips the timestamp check. A runnable test shows an hour-old signed delivery passing only at 0.
- Sume video models by aspect ratio: who takes 9:16, 21:9 and 1:1
Seedance takes 21:9 through 9:16, Kling only 16:9, 9:16 and 1:1, Gemini Omni only 16:9 and 9:16. Full matrix of Sume video models as of 2026-10-08.
- AI video API with audio reference input: which Sume models accept it
Seedance (all four), Wan 3.0, MiniMax H3 and H3 Max accept audio references on Sume. Kling, Gemini Omni, Grok Imagine and the motion models do not.
Written by Sume