TypeScript webhook verifier for Wan 3.0 clips: refuse an empty secret

A TypeScript verifier for Sume webhooks: HMAC SHA 256 over timestamp.body, rotation entries, a 5 minute replay window and a hard refusal of an empty secret.

5 min readSume
All posts

To verify a Sume webhook for a finished Wan 3.0 clip, compute HMAC SHA 256 of <timestamp>.<raw_body> with your signing secret, compare it in constant time to each sume-v1= entry in x-sume-webhook-signature, reject timestamps older than five minutes, and refuse to run at all if the secret is empty. The verifier below does those four things.

Why the empty secret check matters

An empty secret is the quiet failure. If SUME_COM_WEBHOOK_SIGNING_SECRET is unset, an HMAC with an empty key still produces a valid-looking digest, and a naive check against an attacker-chosen signature can then pass. Throw at startup or on the first request so a deploy without the variable fails loudly.

The verifier and a route

The scheme comes from Sume's webhooks docs: sign the raw JSON body, send x-sume-webhook-timestamp and x-sume-webhook-signature (sume-v1=<hex>), and during rotation the header carries one entry per live secret, newest first, comma separated.

import crypto from "node:crypto";
import express from "express";
export function verify(raw: string, ts: string, header: string, secret: string) {
  if (!secret) throw new Error("webhook secret is empty");
  const t = Number(ts);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const digest = crypto.createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  const want = Buffer.from(`sume-v1=${digest}`);
  let ok = false;
  for (const entry of header.split(",")) {
    const got = Buffer.from(entry.trim());
    if (got.length === want.length && crypto.timingSafeEqual(got, want)) ok = true;
  }
  return ok;
}
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) throw new Error("set SUME_COM_WEBHOOK_SIGNING_SECRET");
const app = express();
app.post("/sume", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ts = req.header("x-sume-webhook-timestamp") ?? "";
  const sig = req.header("x-sume-webhook-signature") ?? "";
  if (!verify(raw, ts, sig, secret)) return res.status(401).end();
  const event = JSON.parse(raw);
  console.log(event.event, event.job_id);
  res.status(200).end();
});
app.listen(3000);

Raw body, not parsed JSON

Use the raw body. If a framework parses the JSON and you re-serialize it, key order or whitespace can change and the signature will not match. In Express, express.raw on the webhook route gives you the bytes Sume signed.

Where to get the secret

Where the secret comes from: read it on the Webhooks tab of the dashboard (Reveal, then copy), or from GET /v1/webhooks/signing-secret with an API key that has account:read. The docs name the environment variable SUME_COM_WEBHOOK_SIGNING_SECRET. Sume derives the secret for your workspace, so it is yours, not a shared platform value. Never log it.

Read the event

Check the payload before you act. A finished clip's event carries event, request_id, job_id, status and a payload with artifacts. Failed and canceled events use status: "ERROR" and include an error object. Treat only job.completed as a trigger to fetch the video, and key your own work on job_id, since a retry can deliver the same event again.

Test the three failure cases

Add a delivery test. The dashboard has a Send test and Redeliver option; use it on a staging endpoint and confirm you get a 200 for a valid event and a 401 for one with a changed byte. Also test with the secret variable unset: the process should refuse to start. Finally, run the unit test for a stale timestamp, because that case is easy to leave out.

What this protects

Why this matters for a 30 second Wan 3.0 clip: a 1080p render is $7.50 on Sume, and a forged job.completed event could point your pipeline at a clip that never rendered. Verification is a small piece of code that prevents spending downstream effort on an unverified event. Delivery behavior and retries are in the webhooks docs; polling is in the jobs docs.

Details worth keeping

Constant-time comparison deserves a sentence. crypto.timingSafeEqual throws if the two buffers have different lengths, which is why the code checks the length first. Looping over every header entry, rather than returning on the first match, means the time you take does not reveal which entry matched during a rotation.

Keep the replay window as a setting, not a magic number. Five minutes is the docs' suggestion, and it protects against someone re-sending a captured request later. If your server clock drifts, widen it a little, but do not remove it.

Answer fast

A note on retries. Sume delivers the same event up to ten times if your endpoint refuses or times out, so a handler that verifies and then does slow work before answering can trigger duplicates. Verify, store the event, answer 200, and do the heavy work (download, transcode, publish) from a queue after that.

Testing the verifier

Test three cases before you deploy: a valid signature that passes, a body changed by one character that fails, and an empty secret that throws at startup. A signature computed over a re-serialized JSON body fails too, which is the reason the code uses the raw bytes of the request.

Keep the secret in your environment settings and never print it in logs. If you rotate it, deploy the new value, wait until old in-flight deliveries have drained, and then remove the old one.

Header checklist

The verifier reads these inputs from the Sume docs pages, read 2026-10-05.

Webhook verifier checklist (Sume webhooks docs, read 2026-10-05)
CheckWhat the verifier does
SecretRefuses to start when the secret is empty
Signed textTimestamp, a dot, then the raw request body
AlgorithmHMAC SHA256, compared in constant time
ReplayRejects timestamps older than your window

Sources

Sources: Sume webhooks docs and Video generation docs, for callback_url and the signature headers.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume