How Do You Stop a Replayed Webhook From Re-Triggering Your Bot?
If your bot acts on incoming webhooks, a captured and re-sent request can trigger the same action again. Stripe's and Slack's own docs show the fix: verify the signature, check a timestamp window, and track event IDs. Here is how to apply that to a bot trigger, with a worked example and the failure modes to test for.
AI-assisted editorial: researched and drafted with AI using the primary sources linked below. Examples are illustrative; they are not measured product results. Editorial policy.
Key takeaways
- Signature verification alone is not replay protection: a captured, validly signed request still verifies. You need a timestamp check and an event-ID dedup store on top.
- Stripe's libraries default to a 5-minute timestamp tolerance; Slack recommends the same window. Set your bot's tolerance explicitly rather than trusting a framework default you haven't checked.
- Store processed event IDs for at least as long as your timestamp tolerance window, keyed by the provider's event ID, not by your own generated request ID.
Why signature checks alone don't stop a replay
A bot that reacts to webhooks — a payment confirmation, a Slack slash command, a GitHub push event — trusts that each incoming request represents something that happened once. A replay attack breaks that assumption: someone captures a valid, signed request and sends it again later. The signature still matches, because nothing about the payload or the secret changed. If your bot's only check is 'does the signature verify', a replayed request passes and your bot repeats whatever action it took the first time.
Stripe's own webhook documentation describes this directly: a replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. Slack's documentation makes the same point from the other side — signing lets your app confidently verify a request came from Slack, but Slack's guidance treats verification and freshness as separate steps, not one.
The fix has two parts: a time window, then an ID check
Part one is a timestamp check. Stripe includes a timestamp inside the signed header, so an attacker can't alter it without invalidating the signature. Stripe's libraries use a default tolerance of 5 minutes between the timestamp and the current time, and Stripe explicitly warns against setting that tolerance to 0, because doing so disables the recency check entirely. Slack's signing scheme works the same way: the timestamp is part of what gets hashed, so a replayed request with a stale timestamp fails the freshness check even though the signature itself is mathematically valid.
Part two is event-ID deduplication, because the timestamp window still leaves a gap: a request captured and replayed within that same 5-minute window passes both the signature check and the freshness check. The only thing left to catch it is whether you've seen that specific event ID before. Stripe's retry behavior matters here too — if Stripe retries a webhook because your endpoint returned a non-2xx status, Stripe generates a new signature and timestamp for that new delivery attempt, so a legitimate retry is distinguishable from a replay: retries carry fresh event metadata, true replays reuse the original one.
Worked example: a bot that reacts to a payment webhook
Say your bot runs a routine that marks an order fulfilled when it receives a payment.succeeded webhook. Illustrative design: on receipt, extract the signature header and payload timestamp. Reject anything where the timestamp is more than 5 minutes from your server clock — matching the tolerance Stripe's own libraries use by default. Recompute the HMAC signature over the raw body and compare it to the header using a constant-time comparison; a byte-for-byte parsed-and-reserialized body will not match, so the raw bytes matter.
If both checks pass, look up the event ID in a store keyed by that ID with a short TTL. If the ID is already there, the request is either a retry you already handled or a replay — either way, don't run the fulfillment action again; just return success. If the ID is new, record it and run the action. The store only needs to hold IDs for as long as your timestamp tolerance, since anything older is already rejected by the freshness check before deduplication would even matter.
For a Slack-triggered bot the shape is the same, substituting Slack's X-Slack-Signature and X-Slack-Request-Timestamp headers and Slack's own basestring format. Slack's documentation shows signed secrets replacing the older verification-token model specifically so your app can verify authenticity without a shared token sitting in every request body.
Sources: Receive Stripe events in your webhook endpoint, Verifying requests from Slack.
What breaks this, and how to catch it in testing
Clock drift is the first failure path. Both Stripe's and Slack's freshness checks compare your server clock to the provider's. If your server clock drifts, you'll start rejecting legitimate requests, not just replays — Stripe's documentation recommends NTP synchronization for exactly this reason.
Body re-serialization is the second. If a JSON-parsing middleware re-encodes the payload before your signature check runs, the recomputed HMAC won't match the original even for a genuine request, because the signature was computed over the exact raw bytes Stripe or Slack sent.
Tolerance set to zero, or left at a framework default you haven't checked, is the third. A zero tolerance looks stricter but actually removes the recency check per Stripe's own warning. Test your bot's webhook handler against three fixtures: a fresh valid request (should pass), the same request replayed after your tolerance window expires (should fail on timestamp), and the same request replayed inside the window (should fail on event-ID dedup, not on signature or timestamp). If your bot only has the first two cases covered, you haven't tested the part that actually stops a same-window replay.
The decision: what to build before connecting a webhook-triggered bot
Before wiring any webhook to a bot action, decide three things up front rather than defaulting to whatever a framework ships with. First, your timestamp tolerance — 5 minutes matches both Stripe's and Slack's own defaults, and deviating from it should be a deliberate choice, not an oversight. Second, your event-ID store and its TTL, sized to that tolerance window. Third, how your bot distinguishes a legitimate provider retry from a replay — for Stripe-style providers, a genuine retry carries a new signature and timestamp, so your dedup key should be the provider's event ID, not a hash of the payload, since identical payloads can legitimately recur.
If your bot's current webhook handler only checks the signature, treat that as an open gap, not a minor one: a captured request from any point before your tolerance window closes will still execute your bot's action again.
Primary sources
Sources checked 2026-10-05. Standards and product documentation can change; follow the linked version when implementing.
BotBento is in development. Suggest a correction.