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.

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.
| Option | Default | Use |
|---|---|---|
| body | required | The raw body: string, ArrayBuffer or a typed array |
| headers | required | Headers, Map or a plain object, case-insensitive |
| secret | required | Your webhook signing secret; empty means false |
| toleranceSeconds | 300 | Replay window; 0 skips the timestamp check |
| now | current time | Test 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
- AI video API fallback: retry on another model when a job fails
Chain seedance-2.5, seedance-2 and kling-3 on Sume: poll status_url, read the job error category, and resubmit the brief to the next model.
- Sume video callback not verifying? Compare the secret fingerprint
Every Sume job webhook carries x-sume-webhook-secret-fingerprint. Compare it with the dashboard, accept two signatures in rotation, refuse an empty secret.
- Caption inputs on Sume: script_text, words, cues or segments?
Four caption inputs and only one may be sent. When to use script_text with transcription, word timings, or phrase cues for a silent clip. Plus the errors.
- video_filter_crop_out_of_bounds: FFmpeg crop pixels to fractions
Sume video-filter crop takes fractions of the frame, not pixels. Convert FFmpeg crop=w:h:x:y with a runnable Python check of the bounds.
Written by Sume