Webhook Debugging Guide

Stripe webhook signature error: "Timestamp outside the tolerance zone"

Short answer

Your secret is right and the body is intact. The SDK computed the HMAC, found a match in Stripe-Signature, and then rejected the request anyway because the t= value in that header is more than 300 seconds before your server's clock. Every official Stripe library ships with that 5-minute default (DEFAULT_TOLERANCE = 300) and reports it as a signature verification error, worded like this:

Node, PHP, Java:  Timestamp outside the tolerance zone
Python:           Timestamp outside the tolerance zone (1759536000)
Ruby:             Timestamp outside the tolerance zone (2026-10-08 14:00:00)
.NET:             The webhook cannot be processed because the current timestamp is outside of the allowed tolerance.
Go:               webhook.ErrTooOld

This is a different failure from No signatures found matching the expected signature for payload, which means the HMAC itself didn't match. If you're seeing that one, start with the signature verification failed guide instead. Here the signature was fine; the delivery was old.

Why Stripe signs a timestamp

Stripe's signed string is {t}.{raw body}, so the timestamp can't be changed without breaking the signature. Stripe's docs describe the point: a captured request plus its valid signature could otherwise be re-sent to your endpoint forever, and the timestamp lets your application reject one that is too old. Stripe's note on this is specific: don't use a tolerance value of 0, because it disables the recency check entirely.

One thing to know before looking for the cause: Stripe generates a new timestamp and signature for every delivery attempt, including automatic retries, Resend in the Dashboard (available for 15 days after the event) and stripe events resend <event_id> --webhook-endpoint=<we_id> from the CLI (30 days). An event's created field has nothing to do with this check. If Stripe sent the request, the timestamp is the moment it sent it, and the only thing that can make it stale is what happens after it arrives.

The four causes

# How old is the t= in a header you are looking at?
T=1759536000
echo $(( $(date +%s) - T ))

# Is this Mac's clock right? Prints the offset without changing anything.
sntp time.apple.com

Passing a tolerance in each SDK

Every library takes the tolerance in seconds as an extra argument. Widening it is fine for development; in production it is the only thing standing between you and a replay of a real payment event, so keep it close to the default.

// Node: fourth argument, in seconds
const event = stripe.webhooks.constructEvent(rawBody, sig, secret, 600);

# Python
event = stripe.Webhook.construct_event(payload, sig_header, secret, tolerance=600)

# Ruby
event = Stripe::Webhook.construct_event(payload, sig_header, secret, tolerance: 600)

// PHP
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret, 600);

// Java
Event event = Webhook.constructEvent(payload, sigHeader, secret, 600L);

// Go
event, err := webhook.ConstructEventWithTolerance(payload, header, secret, 10*time.Minute)

// .NET
var stripeEvent = EventUtility.ConstructEvent(json, header, secret, tolerance: 600);

Turning the check off entirely differs by language: Node, Python, PHP and Java skip it for 0; Ruby's check is if tolerance && ..., and 0 is truthy in Ruby, so pass tolerance: nil; Go has webhook.ConstructEventIgnoringTolerance. Stripe's own guidance is not to do this outside tests.

Verifying from a queue

If the worker is the problem, move the check, not the window. Verify at receipt, then store the verified event and process it whenever you like. Python's stripe.WebhookSignature.verify_header(payload, sig_header, secret, tolerance) exists for exactly that: validate at the door, then use construct_event_without_verification downstream. Node's constructEvent also takes a sixth argument, receivedAt, in milliseconds, so the age is measured against the moment the request arrived rather than the moment the worker got to it:

// Record when the request arrived, before it goes on the queue.
const receivedAt = Date.now(); // milliseconds

// Later, in the worker: the age is measured at receivedAt, not now.
const event = stripe.webhooks.constructEvent(
  rawBody, sig, secret, undefined, undefined, receivedAt
);

Replaying a saved request: re-sign it

The timestamp is inside the signed string, so a replay needs a fresh header, not a wider tolerance. stripe-node ships a helper that signs any payload with any secret and timestamp:

const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// `body` is the saved raw body, byte for byte. `secret` is the whsec_ for the endpoint.
const header = stripe.webhooks.generateTestHeaderString({
  payload: body,
  secret,
  timestamp: Math.floor(Date.now() / 1000),
});
// Send `body` with `Stripe-Signature: ${header}`.

The body has to be the exact bytes you captured; the helper hashes what you give it. Hand-rolled versions for Node, plus the same shape for Slack and Polar, are in the replay guide.

Where WebhookMon fits

WebhookMon checks the same 300-second window on every Stripe event it captures and says so in words: Signature timestamp is 12 minutes old, outside the 5-minute tolerance. Because it holds the endpoint's whsec_, its Replay re-signs with the current time by default for Stripe, so a captured event from this morning verifies at your local server now. The original forward and the replay sit side by side with the status each one got from your server, which makes the clock-versus-queue question a two-second check rather than a guess.

WebhookMon's Signature tab for a Stripe invoice.paid event: Signature timestamp is 12 minutes old, outside the 5-minute tolerance, with the stripe-signature header, the timestamp and the configured secret WebhookMon comparing the original forward of that Stripe event, which got 400 Bad Request, with a re-signed replay that got 200 OK
Download WebhookMon free trial Learn more

Related guides