Never set toleranceSeconds to 0 on a Sume webhook verifier

toleranceSeconds 0 skips the timestamp check, so a captured delivery verifies forever. See the replay and a WebCrypto verifier that rejects stale ones.

5 min readSume
All posts

Setting toleranceSeconds: 0 in the Sume SDK's verifyWebhook turns off the timestamp check, so a signed delivery that an attacker or a log leak captured once will verify again next week. The signature proves the body and the timestamp were signed with your secret; only the tolerance window proves the delivery is recent. Sume SDK webhooks documents the default as 300 seconds and states that 0 skips the check, which is useful in a test that replays a fixture and dangerous anywhere else.

The mistake usually arrives as a fix for a failing test: a fixture with a fixed timestamp stops verifying, someone adds a zero, and the zero ships. A one-line lint or a test that asserts the production config never contains it is cheap insurance.

What does a replay actually do?

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>. A replay sends the same three things again. Without a window, your handler sees a valid signature and a valid body, and runs the job.completed logic a second time: a second publish, a second email, a second charge to your customer. Deduping on job_id is the second line of defence, and you want both, because dedupe state can be lost and a clock check cannot.

How do I verify with a window and no SDK?

const enc = new TextEncoder();
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");

async function verify({ body, ts, header, secret, tolerance = 300, now = Date.now() }) {
  if (!secret) throw new Error("empty signing secret: refusing to verify");
  const t = Number(ts);
  if (!Number.isFinite(t) || Math.abs(Math.floor(now / 1000) - t) > tolerance) return false;
  const key = await crypto.subtle.importKey("raw", enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = hex(await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${body}`)));
  return header.split(",").some((p) => p.trim() === `sume-v1=${mac}`);
}
(async () => {
  const body = '{"event":"job.completed"}', secret = "whsec_test";
  const now = Date.now(), ts = Math.floor(now / 1000) - 900;
  const key = await crypto.subtle.importKey("raw", enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = hex(await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}.${body}`)));
  const header = `sume-v1=${mac}`;
  console.log("15 min old, 300 s window:", await verify({ body, ts, header, secret, now }));
  console.log("same delivery, window off:", await verify({ body, ts, header, secret, now, tolerance: Infinity }));
})();

What does the output show?

Run it and the first line prints false while the second prints true, which is exactly the behaviour of a zero tolerance: the old delivery is accepted. The code also splits the header on commas and accepts any sume-v1= entry, because during the 24 hour rotation window Sume sends one signature per live secret, newest first. And it refuses an empty secret outright, since HMAC with an empty key is valid and would let anyone who knows the scheme forge a delivery.

Tolerance choices for a Sume webhook verifier, from Sume SDK webhooks (read 2026-10-06)
SettingEffectUse it
300 (default)Rejects deliveries older or newer than 5 minutesProduction
Smaller, such as 60Tighter, needs good clock syncIf your hosts run NTP
0Skips the timestamp checkReplaying a fixture in a test only

What does the SDK check, and in what order?

The Sume SDK's verifyWebhook is async because it uses WebCrypto rather than node:crypto, so you must await it, and it returns false rather than throwing on a malformed delivery: a missing header, a bad timestamp and a wrong signature all end in the same failed verification. It enforces the replay window before it computes the HMAC and compares in constant time. Its input is the raw body, the headers, your signing secret and an optional toleranceSeconds (default 300).

That ordering is why a tolerance of zero is worse than a loose window. A 300 second window rejects anything older or newer than five minutes before any signature work. A zero skips that gate entirely and leaves only the signature, which a captured delivery already satisfies. If you hand-roll the check instead, as in the sample above, keep the same order: parse the timestamp, compare it to the clock, then compute and compare the HMAC.

The same scheme covers both Sume webhook surfaces. Generation-job webhooks (job.completed, job.failed) and Format run webhooks use the same sume-v1 signature, so one verifier and one tolerance setting protect both. Route on the event field after you have verified, and answer an unrecognized event with a 204 so a new event type does not turn into a retry storm.

How do I debug stale-timestamp rejections?

Keep your server clock honest. A verifier with a 300 second window fails every delivery if the host clock drifts past it, and the symptom looks like a bad secret. Compare x-sume-webhook-secret-fingerprint to the dashboard fingerprint first, and check the clock second, because the two failures look identical in a log that only says false. If you need to replay a real delivery, use the redeliver endpoint, which re-posts the terminal event with a fresh timestamp and signature, so you never need a zero. If a test needs a frozen timestamp, pass the now value into the verifier, as the sample above does, instead of loosening the window: the test stays deterministic and production keeps its protection.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume