Replay a saved Sume webhook in tests: verifyWebhook says false

A recorded delivery fails verifyWebhook once it is older than 300 seconds. Pin the clock with now or use toleranceSeconds 0 in tests, never in production.

4 min readSume
All posts

A webhook you saved last week fails verifyWebhook today because the check includes the timestamp, and anything more than 300 seconds from now is outside the replay window. The signature is fine. In a test, pin the clock with the now option so the saved timestamp counts as current, or set toleranceSeconds: 0 to skip the timestamp check. Keep the default in production.

What does the timestamp check do?

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends the result as sume-v1=<hex>, with the timestamp in x-sume-webhook-timestamp. The window is enforced before the HMAC is computed at all, and the comparison is constant-time. The window exists so that a captured delivery cannot be replayed at your endpoint later.

verifyWebhook options. Source: Verifying webhooks, docs.sume.com/sdk/webhooks, read 2026-10-03.
OptionDefaultUse
bodyrequiredThe raw body: string, ArrayBuffer or a typed array
headersrequiredHeaders, Map or a plain object, case-insensitive
secretrequiredYour webhook signing secret; empty means false
toleranceSeconds300Replay window; 0 skips the timestamp check
nowcurrent timeTest seam: a function returning Unix seconds

Which fix should a test use?

Prefer now. It keeps the timestamp check running, so your test also proves that your receiver handles a stale delivery when you pass a later clock. toleranceSeconds: 0 is blunt: it removes replay protection entirely, so it belongs only in a fixture-driven test, never in a deployed receiver.

The test below signs its own fixture with a throwaway secret, then verifies it twice: once with the clock at the signing time, and once one hour later.

import { createHmac } from "node:crypto";
import { verifyWebhook } from "@sume-com/sdk";

const secret = "whsec_test_only";
const body = JSON.stringify({ event: "job.completed", job_id: "job_demo" });
const ts = 1785000000;
const sig = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
const headers = {
  "x-sume-webhook-timestamp": String(ts),
  "x-sume-webhook-signature": `sume-v1=${sig}`,
};

const fresh = await verifyWebhook({ body, headers, secret, now: () => ts });
const stale = await verifyWebhook({ body, headers, secret, now: () => ts + 3600 });
console.log({ fresh, stale }); // { fresh: true, stale: false }

Can I reuse a real captured delivery?

Yes, if you keep the exact raw bytes of the body and the two headers together. Re-serialising the JSON changes key order or whitespace and breaks the signature. Also remember that a captured delivery verifies only against the secret it was signed with: after a rotation, the old secret stops verifying once the 24-hour overlap closes, so a fixture signed with it needs the old secret kept in your test configuration.

For a live check of your endpoint rather than a fixture, the dashboard's Send test posts a signed webhook.test payload to a URL you type. It carries no job_id, so your handler should route on event and answer an unknown event with 204.

What else should a webhook test cover?

Three cases are worth a test each. A good signature inside the window should return true and your handler should store the event and answer 204. A tampered body should return false and you should answer 401. An unknown event value should answer 204, not a 500, because a newly added event type that turns into a retry storm is the failure the docs warn about.

Dedupe is the fourth. Retries arrive up to ten times, so key your store on request_id for run webhooks and job_id for job webhooks, and make the second delivery a no-op.

Is skipping the check ever right in production?

Only if something in front of your code has already verified the delivery. The SDK exports the pieces for that case: the header names, the sume-v1 version string and the default tolerance of 300. If your receiver verifies the signature itself, leave the window on. A verifier that skips it accepts any old capture for as long as the secret stays valid.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume