Webhook Debugging Guide

Webhook timestamp expired when replaying a captured event

Short answer

You saved a webhook payload and its headers, and posting it back to your own server later fails signature verification, even though the secret is correct. That's expected: Stripe, Slack and Polar all sign a timestamp along with the body, and reject anything older than a tolerance window, usually about 5 minutes, to stop exactly this kind of replay. The fix is to compute a new signature with the current timestamp before resending, not to widen the tolerance in production.

Which providers this applies to

Re-signing by hand: Stripe example

Stripe's signed content is {timestamp}.{raw body}, HMAC-SHA256 hex-encoded:

const crypto = require('crypto');

function resignStripe(body, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${body}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  return `t=${timestamp},v1=${signature}`;
}

Set that as the Stripe-Signature header on the replayed request, alongside the original, unmodified body. Slack's v0:{timestamp}:{body} and Polar's Standard Webhooks {id}.{timestamp}.{body} follow the same shape with a different secret format and header layout.

What doesn't need this

GitHub's X-Hub-Signature-256 and Shopify's X-Shopify-Hmac-Sha256 are computed over the body alone, so a saved payload from any point in the past replays cleanly — the age of the event has never been part of what's verified for those two.

The easier way: WebhookMon

WebhookMon already holds your endpoint's secret, so its Replay button can re-sign with the current timestamp automatically for Stripe, Polar and Slack — one click instead of a script you rewrite each time you need it. GitHub and Shopify replay byte for byte, since re-signing wouldn't change anything for them.

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

Related guides