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.

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.
| Clock passed in | Seconds from timestamp | Result |
|---|---|---|
| 1790000100 | 100 | true |
| 1790000100 * 1000 (the bug) | about 1.79 trillion | false |
| 1790000301 | 301 | false |
| 1790000300 | 300 | true |
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
- A job id is not a run id: poll image and video generation via /v1/jobs
Image and video generate routes create jobs, not runs. Poll GET /v1/jobs/:id/status until terminal is true. waitForRun is for run ids only.
- Job id or run id? Which Sume endpoint to poll for each product
Jobs, Format runs, Actions and Agent Completions have different ids, poll URLs and webhook events. Which to poll for each product, and which SDK helper to call.
- terminal, result_ready or status: which check ends a Sume poll loop?
Stop on terminal, fetch on result_ready, branch on status. Three fields in the Sume status payload, three different jobs, and a Python loop that uses each once.
- sume jobs cancel needs --confirm-submit, and only queued jobs cancel
Sume CLI cancel is a write: sume jobs cancel <job_id> --confirm-submit. It works only before generation starts; later: 409 job_generation_already_started.
Written by Sume