The signature itself was fine. Your server decided the event was too old. That's either your clock or your code verifying the request late, and the quick fix people share (setting the tolerance to 0) quietly switches off replay protection.
Every webhook request carries a Stripe-Signature header like t=1729680000,v1=.... The t= value is a Unix timestamp, and it's part of the signed payload, "so an attacker can't change the timestamp without invalidating the signature" (Stripe docs: Preventing replay attacks). Stripe's libraries "have a default tolerance of 5 minutes between the timestamp and the current time" (source). In stripe-node that's DEFAULT_TOLERANCE: 300 seconds, and when the event is older the library throws Timestamp outside the tolerance zone (stripe-node source).
The check only looks one way. stripe-node computes the age as the current time minus t and rejects it only when the age is greater than the tolerance (source); stripe-python likewise only rejects a timestamp older than the current time minus the tolerance (stripe-python source). So with those libraries, a server clock running fast by more than 5 minutes triggers the error; a clock running slow does not.
constructEvent in a worker minutes later, the age is measured at that later moment. stripe-python's own docstring describes the intended order: validate incoming webhooks "before storing them for later processing" (source).Stripe-Signature header from your logs to your endpoint fails once it's more than 5 minutes old. That's the replay protection working as designed (source).Not a cause: Stripe's retries. After downtime, retried deliveries don't arrive with the old timestamp. Stripe "generates the timestamp and signature each time we send an event to your endpoint. If Stripe retries an event (for example, your endpoint previously replied with a non-2xx status code), then we generate a new signature and timestamp for the new delivery attempt" (source). If retries fail with this error, look at your clock, not at the retry.
timedatectl shows whether the system clock is synchronized. To see the skew directly, log now - t for each request (code below).constructEvent in the request handler, return 2xx, and queue the verified event for the slow work. Stripe asks you to "quickly return a successful status code (2xx) before any complex logic that could cause a timeout" anyway (Stripe docs: Receive events).constructEvent(payload, header, secret, tolerance, cryptoProvider, receivedAt) takes tolerance in seconds and falls back to 300 when you don't pass it (source). Leave it out.Stripe lists it as a common mistake: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely." (source). In stripe-node the check is tolerance > 0 && timestampAge > tolerance, and the source comment says to "Set tolerance to 0 to skip timestamp verification" (source). stripe-python skips it too when tolerance is 0 (source). The signature is still checked, but anyone who captures one real request can replay it forever. A huge tolerance (hours, days) does nearly the same thing. Fix the clock instead.
// Before constructEvent: how old is this request by our clock?
const sig = req.headers['stripe-signature'] || '';
const t = Number((sig.match(/(?:^|,)t=(\d+)/) || [])[1]);
console.log('stripe webhook age (s):', Math.floor(Date.now() / 1000) - t);
// A live Stripe delivery should be a few seconds old. Hundreds of seconds = clock or queue problem.
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
stripe listen --forward-to localhost:4242/webhook, use the signing secret it prints, then stripe trigger payment_intent.succeeded in another terminal (Stripe docs: Test webhooks with the Stripe CLI). Each forwarded event should verify with an age of a few seconds.stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp }), which builds a t=...,v1=... header for any timestamp (source). Sign a test payload with a timestamp 10 minutes in the past and confirm your endpoint answers 400.// test: a 10-minute-old signed request must be rejected
const payload = JSON.stringify({ id: 'evt_test', object: 'event' });
const header = stripe.webhooks.generateTestHeaderString({
payload, secret: 'whsec_test', timestamp: Math.floor(Date.now() / 1000) - 600,
});
// stripe.webhooks.constructEvent(payload, header, 'whsec_test') throws "Timestamp outside the tolerance zone"
The other two signature errors are covered in "No signatures found matching the expected signature" (raw body, wrong secret). And if your handler skips verification entirely, see can someone fake a Stripe payment?
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkThe signature checked out, but the t= timestamp in the Stripe-Signature header is older than the allowed tolerance, 300 seconds (5 minutes) by default in Stripe's libraries, measured against your server's clock.
No. Stripe says a tolerance of 0 disables the recency check entirely, so a captured request could be replayed at any time. Sync your server clock with NTP and keep the default.
No. Stripe generates a new signature and timestamp for every delivery attempt, including retries.
Verify when the request arrives, then queue the verified event. If you verify minutes later, the timestamp may already be outside the tolerance.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-10-23. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.