# Stripe webhook mistakes that lose or repeat payments

> A Stripe webhook endpoint is a public URL that moves money and access. Five coding mistakes let a forged request or a plain retry do the wrong thing.

Published 2026-10-28 · https://aiskills402.com/blog/stripe-webhook-mistakes

A Stripe webhook endpoint is an ordinary web address that Stripe calls when something happens in your account: a payment succeeds, a subscription renews, a dispute opens. Whatever your handler does next, such as shipping a product, switching on a plan or crediting a balance, is real. That makes the endpoint a target for two kinds of trouble. Someone outside Stripe can post a made-up event to it, and Stripe itself can legitimately deliver the same event more than once. Many webhook bugs that cost money come from five habits in the handler code: parsing the body too early, skipping the signature check, turning off the age check, ignoring event ids, and doing slow work before answering. This guide goes through each, says why it hurts, and closes with a test for your own route.

## Why the endpoint needs care

Stripe's documentation is blunt about the forged case: if you do not verify, a stranger can post invented events and make your code ship orders, open accounts or rewrite data ([Stripe, webhooks](https://docs.stripe.com/webhooks)). The address of your endpoint is not a secret, so you cannot rely on nobody finding it.

The repeated case is just as normal. Stripe keeps trying to deliver an event for up to three days when your endpoint does not accept it, and it does not promise that events arrive in creation order ([Stripe, webhooks](https://docs.stripe.com/webhooks)). Retries are a feature. A handler written without them in mind will, sooner or later, act twice: two shipments, two credits, two welcome emails.

## Mistake one: the body is changed before the signature check

Every webhook request carries a `Stripe-Signature` header. To check it, the library needs the untouched request body, the value of that header, and the secret that belongs to this particular endpoint. Stripe warns that any manipulation of the raw body makes verification fail, and that some frameworks parse or rewrite the body before your code sees it, for example by reordering keys, stripping whitespace or changing the encoding ([Stripe, signature troubleshooting](https://docs.stripe.com/webhooks/signature)).

The typical form of this bug is a JSON middleware applied to the entire application before the webhook route is reached. The route then receives an object, the developer turns it back into text to satisfy the library, and the bytes differ from what was signed. In a Node application using Express, the troubleshooting page says the JSON middleware must be registered after the webhook route, because middleware runs in the order it is added ([Stripe, signature troubleshooting](https://docs.stripe.com/webhooks/signature)). The fix is to give that one route the body in the form it arrived, unparsed, and let every other route keep parsing JSON as usual.

A nasty side effect: when verification keeps failing in development, people are tempted to remove the check "for now". That leads straight to the next mistake.

## Mistake two: trusting the event with no signature check

This one shows up in several shapes. There is no verification at all. Or the check runs only when a secret variable happens to be set, so a missing configuration value quietly turns the protection off in production. Or the check runs, fails, is logged, and the handler carries on anyway. In each shape an outsider can post a convincing JSON document and have your code treat it as a payment.

The rule is simple: the first thing the handler does is verify, and a failure ends the request with an error status before any business logic runs. A missing secret should stop the application from starting, not silently relax the route.

## Mistake three: the age check is switched off or stretched

The signature includes a timestamp, so an attacker cannot alter it without invalidating the signature. What the signature alone cannot stop is replay: a genuine request, captured once, sent again later. That is the job of the tolerance, the biggest difference permitted between the signed time and the clock now. Stripe's libraries default to five minutes ([Stripe, webhooks](https://docs.stripe.com/webhooks)), and the documentation says not to pass a tolerance of zero because that disables the recency check entirely ([Stripe, webhooks](https://docs.stripe.com/webhooks)).

Developers usually touch this setting while chasing a clock problem on a server or a test machine. The right answer to that problem is to fix the clock, using network time synchronisation, not to widen the window to hours or remove it. Leaving the argument out is correct, because the default is already sensible.

## Mistake four: no memory of event ids

Stripe's guidance for duplicates is to keep a record of the event ids you have handled and skip any that show up again ([Stripe, webhooks](https://docs.stripe.com/webhooks)). The same page warns against using the creation time to decide order or whether an event was handled, because several events can share a timestamp, and it says to track ids instead.

The pattern that holds up is to make the record of the event and its effect travel together. If the effect lives in your own database, write the event id (in a table where that column must be unique) and the effect in one transaction: both happen or neither does, and a repeat finds the row and returns success. If the effect lives elsewhere, such as a mail service, store the id with the status "processing" first, do the work, then mark it "done". A repeat that finds "done" returns success and does nothing. A repeat that finds "processing" for longer than a time limit you choose is treated as a crashed attempt and runs again, so the work itself must be safe to run twice. Doing the check as "look up, then insert later" leaves a gap where two simultaneous deliveries both pass. Storing the bare id as the very first step, with no status, fails in the other direction: a crash before the work finishes means every retry is skipped and the order is never fulfilled. Doing the side effect first and recording the id afterwards lets a crash in the gap repeat the work.

Not every handler needs this. Setting a field to a fixed value ends in the same state however often it runs. Sending an email, creating a record, adding credit or calling a shipping service does not, and those are the actions to put behind the id check. Stripe also notes that occasionally two distinct event objects describe one underlying change, so for those compare the id of the object inside the event together with the event type ([Stripe, webhooks](https://docs.stripe.com/webhooks)).

## Mistake five: slow work before the success answer

Stripe expects a successful status code quickly, before any complicated logic that might time out. Its own example is returning a success response before marking an invoice as paid in an accounting system ([Stripe, webhooks](https://docs.stripe.com/webhooks)). If your handler calls another service, sends mail or loops over many records first, a timeout looks to Stripe like a failure, and the retry arrives while the first attempt may still be running. Combined with mistake four, that is how orders get fulfilled twice.

Stripe recommends an asynchronous queue, since a burst of deliveries can overwhelm a handler that does everything inline ([Stripe, webhooks](https://docs.stripe.com/webhooks)). The shape is: verify, record the event id, put the work on a queue, answer success. A worker then does the slow part, and it should itself be safe to run twice.

## A check you can run

Open the webhook route and answer five questions from the code, not from memory or comments.

1. Trace where the body variable comes from, up to the verification call. Did any parser, copy or serialiser touch it on the way?
2. What happens when verification throws? The handler must stop and return an error status.
3. Is a tolerance argument present? If so, is it a few minutes, not zero and not hours?
4. List every action that is harmful when repeated. Is the event id stored before each of them?
5. List everything that runs before the success status is returned. Is any of it slow?

To make the check able to fail, test it with bad input: post a body with a wrong signature, post a correctly signed body with a very old timestamp, and deliver the same signed event twice in a row. The first two should be rejected, and the third should produce exactly one side effect.

## When this does not apply

Here we look only at how a handler trusts and processes an incoming event. It says nothing about amounts, currencies, refunds, tax or the rest of your payment logic, and it does not cover endpoints that receive events from other providers, where the signing scheme differs. The details come from Stripe's public documentation as read on the date above, and the library defaults can change, so confirm them on the pages cited. No new experiments were run for this write-up. If you use a managed integration where the vendor's own library receives and verifies events for you, some of these mistakes cannot be made, though the id and queue advice still holds. Some applications also use extra protections such as source address allow-lists; those are worth having but do not replace the signature check.

Webhook retries are a close cousin of any client that tries again after a failure, and our piece on [retry and backoff mistakes](/blog/retry-backoff-mistakes) covers the sending side of the same problem.

## Checking your own handler

Before a webhook route goes live, you can get another pair of eyes on it. Our [stripe-webhook-review](https://aiskills402.com/skills/stripe-webhook-review) skill reads one handler, in any language or framework, and reports only these five problems: a body altered before verification, a missing signature check, a tolerance that is off or huge, no stored event ids before side effects, and slow work before the success answer. Each finding is one line with the place in the code, the reason and the fix, and a handler without these problems gets a single line saying so.
