Skip to content
BuildThenTestedSee packages

Patterns

Stripe webhook signature: the one-minute test

How to confirm your Stripe webhook rejects unsigned events, what a correct handler returns, and why replayed events need an idempotency check as well.

A webhook is the address on your server that Stripe calls to report events, such as a completed checkout or a paid invoice. Your app listens there and does something useful: marks an order paid, activates a plan, adds credits. The question this post answers is whether your app checks that the caller really is Stripe.

Why the check exists

Stripe signs every webhook it sends. The request carries a Stripe-Signature header, which is a hash (HMAC with SHA-256) of the timestamp and the raw request body, computed with a per-endpoint secret that starts whsec_. Your handler recomputes that hash with the same secret and rejects the request when it does not match. Stripe's official libraries do this in one call, usually named constructEvent or construct_event, and they default to rejecting timestamps more than five minutes old.

Stripe's documentation is direct about what happens without the check: an attacker could send fake events to trigger fulfillment or access. In practice that means anyone who finds the webhook URL can post a made-up checkout.session.completed event and get a plan activated for a user of their choosing, without paying. Stripe also publishes a list of its IP addresses for allowlisting as a second layer, and lets you roll an endpoint secret with up to 24 hours of overlap.

Two days after announcing a SaaS built with Cursor and no hand-written code, its founder reported that people were bypassing the subscription (Leo (Enrichlead founder), 2025). The founder's posts do not say how, so we cannot attribute it to a webhook. It is the outcome an unsigned webhook produces, though, and the test below takes a minute.

The one-minute test

You need your live webhook URL. Find it in the Stripe Dashboard under Developers, then Webhooks. It usually ends in something like /api/stripe/webhook or /functions/v1/stripe-webhook.

Send a request with no signature header. In a terminal:

curl -i -X POST https://yourapp.com/api/stripe/webhook -H "Content-Type: application/json" -d '{"type":"checkout.session.completed"}'

Read the first line of the response.

  • A correct handler answers with HTTP 400 and a short error, because there is no Stripe-Signature header to verify.
  • A bad result is HTTP 200, or any response that suggests the body was read as an event. Check your database or admin view afterwards: if anything changed, the handler acted on a forged event.

If you would rather not use a terminal, ask your builder's chat to show the handler file and look for two things. The first is a call to constructEvent that receives the raw body, the header and a whsec_ value. The second is an environment variable such as STRIPE_WEBHOOK_SECRET that is set and not blank. A handler that parses the body with req.json() or JSON.parse and never calls a construct-event function fails the test.

Testing the correct path with the Stripe CLI

The Stripe CLI is Stripe's command-line tool. It gives you a way to confirm that signed events do work after you fix the handler.

  1. Run stripe listen --forward-to localhost:3000/api/stripe/webhook while your app runs locally. The CLI prints a whsec_ secret for this session; put it in your local environment variable.
  2. In a second terminal, run stripe trigger checkout.session.completed. The CLI creates a test event and forwards it with a valid signature.
  3. Your handler should return HTTP 200 and do its job once. The CLI output shows the status code for each delivery.

One detail catches people: the signature is computed over the raw body. Frameworks that parse JSON before your code runs will change the bytes, and verification fails with a valid event. Next.js route handlers give you the raw body through req.text(). Stripe's documentation on signature errors covers the other common causes.

Replay and idempotency

Stripe states that events may arrive more than once and out of order, and that it retries failed deliveries for up to three days in live mode. So a handler that adds credits on every invoice.paid event will add them twice when a duplicate arrives. Idempotency means an operation can run again without changing the result a second time.

Two mechanisms cover it. Store the id of every event you have processed and skip ids you have seen. On the sending side, pass an Idempotency-Key header on charge and subscription calls; Stripe keeps the first result for at least 24 hours and returns it for retries with the same key. To test, open a recent event in the Stripe Dashboard, click Resend, and look at the user's balance or order list. A second credit or order for one payment is a bad result.

The full records are at Stripe webhook accepted without signature verification and No idempotency on payment handlers. Our Ready-to-charge check runs the unsigned test, a wrong-secret test, a forged event against a test account and a replay, and reports what changed.

What to do this week

Patterns in this post

Questions

what should a stripe webhook return without a signature

A client error, HTTP 400, before any business logic runs. Stripe's libraries throw when the Stripe-Signature header is missing or does not match the endpoint secret, and the handler should return that error rather than read the body as an event.

how do i test my stripe webhook locally

Install the Stripe CLI and run stripe listen with --forward-to pointing at your local webhook route. Copy the whsec_ value it prints into your local environment, then run stripe trigger with an event type such as checkout.session.completed. The CLI shows the status code your handler returned.

why does stripe send the same webhook event twice

Stripe's documentation states that events can be delivered more than once and out of order, and that failed deliveries are retried for up to three days in live mode. Handlers should record processed event ids and skip repeats so a redelivery does not add credits or orders again.