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
- Video upscale hold: omit duration_seconds and Sume reserves 5 s
Sume video upscale reserves from duration_seconds, or 5 seconds when you omit it. Hold table for 5, 15 and 30 seconds at $0.009 per second and the 402 case.
- Wan 3.0 draft and final need different Idempotency-Keys (409)
Reusing one Idempotency-Key for a 480p draft and a 1080p final of the same prompt returns 409 idempotency_conflict. A Python key builder that avoids it.
- Webhook signature mismatch: Sume fingerprint vs OpenRouter t=,v1=
A video webhook that fails verification has three usual causes: parsed body, wrong secret, wrong header format. Sume adds a secret fingerprint header.
- Test a video webhook receiver before the first job: Sume vs fal
Sume sends a signed dummy webhook.test with one API call. fal retries real results up to 31 times and treats 3xx as failure, so test your URL first.
Written by Sume