The request usually succeeds first. The signature check is where the time disappears.
Most Stripe integrations do not fail on the first API call.
You create the customer. You create the PaymentIntent. You confirm it. Everything looks fine. Then the webhook arrives and your handler says invalid signature.
That is usually the moment the debugging session starts.
The annoying part is that the failure often has nothing to do with Stripe itself. It is usually your local setup. The payload got parsed too early. The raw body changed. The signature was generated against bytes your app never saw.
One common example in Node/Express is using express.json() on the webhook route. That middleware parses and re-serializes the body, which means the bytes you verify are no longer the bytes Stripe signed.
Instead of this:
app.post("/webhooks/stripe", express.json(), (req, res) => {
const sig = req.headers["stripe-signature"];
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
res.sendStatus(200);
});
you usually need the raw body on that route:
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["stripe-signature"];
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
res.sendStatus(200);
});
The hard part is not just fixing the route once. It is trusting that the whole flow still works after the fix:
- the webhook arrives
- the signature verifies
- your handler updates state correctly
- retries do not double-process the event
That is why webhook testing feels weirdly slippery. The API call and the webhook handler are two different systems, and most local setups only make one of them easy to observe.
What to test instead of the webhook in isolation
What has helped me most is testing the full flow instead of testing the webhook in isolation:
- trigger the upstream action
- inspect the exact webhook payload and headers
- verify the handler against the raw body
- confirm the final state change after the webhook is processed
That last step matters more than most examples admit. A verified signature is good. A verified signature plus the correct final state is what actually tells you the integration works.
Why this bug sticks around
Webhook bugs are slippery because the API call and the webhook handler live on two separate timelines.
The API request can succeed immediately. The event can arrive later. Your logs for one may be clean while the other is quietly failing. That is why developers end up jumping between local tunnels, dashboard events, request logs, and app state, trying to piece together what actually happened.
The hard part is rarely "can I trigger a Stripe event?" The hard part is "can I trust the whole PaymentIntent plus webhook path enough to ship it?"
A more useful local testing setup
If you want a shortcut, use a webhook sandbox or a test environment that lets you inspect the full Stripe flow end-to-end instead of only replaying one event at a time.
The useful part is not the mock payload. It is being able to see the request, the event, and the resulting state in one place.
For Stripe-specific workflow context, this is also why a runnable Stripe portal is more useful than example requests alone.
Curious what other people use here. Do you mostly replay events, tunnel to localhost, or test the full PaymentIntent plus webhook path every time?
Top comments (0)