Date.now() is milliseconds: the Sume webhook timestamp is seconds

A Node webhook verifier that compares Date.now() to x-sume-webhook-timestamp rejects every delivery. The one-line fix and a four-case test around 300 s.

4 min readSume
All posts

x-sume-webhook-timestamp is a Unix timestamp in seconds, and Sume's replay window is 300 seconds. Date.now() returns milliseconds. A verifier that writes Math.abs(Date.now() - ts) > 300 compares a number around 1.79 trillion with one around 1.79 billion, so every delivery looks about 56 years stale and is rejected, even though the signature is perfectly valid.

It is an easy bug to ship because the signature code is correct and the failure is silent: your endpoint answers with a rejection, Sume retries the delivery (10 attempts, 30 seconds apart), and the dashboard fills with failures that all look like an outage on your side.

A verifier that takes the clock as an argument

Injecting nowSeconds makes the boundary testable without waiting. The function refuses an empty secret, rejects timestamps more than 300 seconds away in either direction, compares the signature with timingSafeEqual, and accepts any entry of a comma-separated header (Sume dual-signs for 24 hours after a secret rotation). In production pass Math.floor(Date.now() / 1000); the sample feeds fixed values.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify({ body, headers, secret, nowSeconds }) {
  if (!secret) throw new Error("empty secret");
  const ts = Number(headers["x-sume-webhook-timestamp"]);
  if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > 300) return false;
  const want = createHmac("sha256", secret).update(`${ts}.`).update(body).digest();
  return String(headers["x-sume-webhook-signature"]).split(",").some((entry) => {
    const got = Buffer.from(entry.trim().replace(/^sume-v1=/, ""), "hex");
    return got.length === want.length && timingSafeEqual(got, want);
  });
}

const body = Buffer.from('{"event":"job.completed","job_id":"job_123"}');
const sig = "sume-v1=5ba7a215439d4b856d34fa60b0bd467778efa0d98eca14f659fcb5e74b1ca1f1";
const headers = { "x-sume-webhook-timestamp": "1790000000", "x-sume-webhook-signature": sig };
const secret = "whsec_test_123";
console.log("seconds ok :", verify({ body, headers, secret, nowSeconds: 1790000100 }));
console.log("ms (bug)   :", verify({ body, headers, secret, nowSeconds: 1790000100 * 1000 }));
console.log("skew 301 s :", verify({ body, headers, secret, nowSeconds: 1790000301 }));
console.log("skew 300 s :", verify({ body, headers, secret, nowSeconds: 1790000300 }));

What the four cases print

Run against the shared test vector (timestamp 1790000000) on Node 22.14.0.

Verifier outputs on the test vector (read 2026-10-07)
Clock passed inSeconds from timestampResult
1790000100100true
1790000100 * 1000 (the bug)about 1.79 trillionfalse
1790000301301false
1790000300300true

The fix

Divide before comparing: const nowSeconds = Math.floor(Date.now() / 1000). Name the variable with its unit, as above, so the mistake is visible in review. The same trap exists in any language whose clock call returns milliseconds, such as Java's System.currentTimeMillis(); Python's time.time() returns seconds already.

The SDK's verifyWebhook takes toleranceSeconds (default 300) and does the clock math for you, which is the simplest way to avoid hand-rolling this.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume