How to test webhooks when a payment depends on the answer? Climb four rungs. Catch one request on a throwaway URL to see its shape, forward signed Stripe events to localhost with stripe listen, replay the same event twice against staging, then watch the first real live event return 200 in the Dashboard. Each rung proves something the one before cannot.

How to test webhooks: four rungs, from a throwaway URL to the first real event

Webhook testing has 4 rungs. A throwaway URL shows what the provider sends. The provider’s CLI or a tunnel delivers signed events to your handler on localhost. A staging endpoint with its own secret takes a replay and a forced failure. The first real live event returning 2xx, with the row changed, is the only proof of the live setup.

Testing the webhook is one step inside SaaS billing process best practices, and the order of the rungs is the point. It is also how to check webhooks an AI builder wired for you: every rung removes one class of cause before the next, so when rung 3 fails you already know the handler logic and the signature check work, and the fault sits in the deployed config. What each rung proves and cannot prove in the table is my reading of the rung, not a Stripe rule.

RungToolWhat it provesWhat it cannot proveEvidence to keep
1. InspectA throwaway receiver URL (Webhook.site, Svix Play, Webhook Relay’s bin)The sender fires, and what the method, headers and JSON body look likeAnything about your codeOne captured request, test-mode only
2. Localstripe listen forwarding to localhost, or a tunnel such as ngrokThe signature check and the handler logicThe deployed configThe CLI log line and the local row it changed
3. StagingThe deployed staging endpoint, with its own secret, plus Resend and a forced failureThe environment variables, the raw body on that host, the retry pathThe live secret and the live URLEvent ids, delivery attempts, row counts before and after
4. LiveThe live destination’s Event deliveries tabThat the live secret and live URL are rightNothing further: this is the proofThe event id, its time, the 2xx, the changed row

A plain curl POST with a made-up JSON body tests something too. Against a correct handler it fails, because the request carries no valid Stripe-Signature header. That makes it a useful negative test (the endpoint should refuse it) and a bad positive one: if a made-up body succeeds, the signature check is off.

Online webhook testers: what a throwaway URL is good for

An online webhook tester is a page that hands you a unique URL and displays every request sent to it. It answers one question, what the provider sends. It does not run your handler, and it should only ever receive test-mode data.

The names you will meet are Webhook.site, Svix Play, Webhook Relay’s bin, Beeceptor, Pipedream and Postman’s mock servers. Webhook.site and Webhook Relay’s bin create a free URL the moment the page loads, with no sign-up. The free online webhook test these pages run is good for two jobs: seeing a provider’s sample payload before you write the handler, and checking that a provider fires at all. Webhook Relay’s bin can also run a small transform that verifies an HMAC signature, but that checks the tester’s copy of the logic, not yours.

The free tiers have limits. Webhook.site’s free URLs expire after 7 days and accept a maximum of 100 requests, and because the free version runs without a login, the data is accessible to anyone who knows the URL’s ID. Webhook Relay’s free URLs expire after 48 hours, and its free bins are public to anyone with the URL.

That last point sets my working rule: a public tester is a third party, so send it test-mode events only, never live customer data, and never paste a signing secret or an API key into one. A tester also does not answer how to test a webhook URL you have built yourself: it replaces your URL rather than checking it, so your own route gets tested on rungs 2 to 4. A sample webhook for testing is better copied from the provider than invented: Stripe’s event object page prints a full example event with its top-level fields, id, type, data, created and livemode among them. Getting a webhook URL for testing from a tester is the easy part; deciding what may be sent to it is the part that matters.

Why it matters: the webhook is where a payment becomes access

For a subscription app, the webhook is where the app learns that money moved, renewed or failed. The payment happens at Stripe; the access change happens in your handler. If the handler never runs, or runs twice, the customer and your database disagree about what was bought. That is my reading of how these apps are built, and it is why an untested webhook shows up as a support email rather than an error.

What was not testedWhat the customer seesWhen you find out
The endpoint was never registered in live modeThey pay, and the account stays on the free planWhen the first paying customer writes in
The live endpoint runs with the sandbox secretThey pay, nothing unlocks, and every live delivery fails the signature checkWhen someone opens the live Event deliveries tab
The handler returns 200 and changes nothingThey pay, and the app looks unchangedWhen the customer writes in, or never
The same event arrives twice and grants twiceTwo credit grants, two welcome emailsAt reconciliation, if anyone reconciles

The second row catches people who reuse a URL: a live webhook destination has its own signing secret even when its URL matches a testing destination. The third row has its own diagnosis, in a Stripe webhook that is not updating the database.

In the apps I audited, only 1 of the 21 third-party apps was credited with a real test suite, and even it skipped sign-up, login and the payment webhook. Three of the audited apps had a real secret permanently in git history: a webhook signing secret, a live AI-provider key, and a Stripe test key with its webhook secret. Those 21 third-party apps come from my June and July 2026 audits, a selected set, not a random sample, so the numbers describe what I found there and not a rate for AI-built apps in general. The checks below cover the webhook part of the first pattern: the test that app skipped.

How it works: build the endpoint, get the secret, send signed events

The five steps below run in the order of the work: a route that can receive, a destination in Stripe that points at it, the secret that ties the two, the CLI to deliver events to your laptop, and a replay to prove a repeat is harmless.

How to receive a webhook: the endpoint in five lines

A webhook endpoint is a public HTTPS route that accepts a POST, and receiving one safely takes 5 lines of behavior: read the raw body, verify the signature, reject failures with a 4xx, return 2xx quickly, and do the work afterward keyed on the event id so a repeat changes nothing.

In Stripe’s case the provider sends an HTTPS POST whose body is a JSON event, signed in a Stripe-Signature header, and Stripe’s webhooks guide requires registered endpoints to be publicly accessible HTTPS URLs. The definition, how a webhook differs from an API call, and the downsides belong to a different question, what is a webhook. To create a webhook receiver, you write the half of the exchange the provider cannot write for you:

  1. 01 A public HTTPS route that accepts POST requests.
  2. 02 Read the raw body before any JSON parsing touches it.
  3. 03 Verify the signature with this endpoint's own secret, and reject a failure with a 4xx.
  4. 04 Return a 2xx quickly, before any slow work.
  5. 05 Do the work after that, keyed on the event id, so a repeat delivery is harmless.

The list is my rule, built on Stripe’s page: Stripe asks for the raw body, a quick 2xx before complex logic, and verification with the payload, the Stripe-Signature header and the whsec_ secret, and Stripe’s own sample returns 400 when verification fails. When you ask an AI builder to implement a webhook route, give it these five lines and read the result against them. Most of the effort to build a webhook endpoint sits in lines 2 and 3, because a framework that parses JSON first breaks the signature check.

To configure webhooks on the sender’s side, most providers ask for the same three things: the URL, the events to send, and a secret. That is my reading across providers; Stripe generates the secret for you. The safe way to use webhooks is to treat each one as a notification to verify, not a command to trust. The signature check is also the core control in webhooks security, a bigger subject than testing.

On Supabase, one setting gets in the way. Supabase Edge Functions verify a Supabase JWT by default; a Stripe webhook function needs verify_jwt = false under [functions.<name>] in supabase/config.toml, after which the handler is fully responsible for authenticating the caller. Supabase’s Edge Functions auth guide shows the pattern for external providers such as Stripe: skip the wrapper’s credential check, then verify the provider’s signature inside the handler.

How to create a Stripe webhook, with a Stripe webhook example

Start in the browser. To create a webhook on Stripe, Stripe’s guide gives this click path for the Dashboard, read on 2026-10-03:

  1. Open the Webhooks tab in Workbench.
  2. Click Create an event destination, and select Your account.
  3. Select the API version for the events object you want to consume.
  4. Select the event types the app handles, not all events.
  5. Select Continue, then Webhook endpoint as the destination type.
  6. Click Continue, enter the Endpoint URL (the HTTPS address of your deployed route) and an optional description.
  7. On the webhook settings page, click Reveal secret and copy the whsec_ value.

Stripe notes that Workbench replaces the older Developers Dashboard, and it advises against listening for all events because the extra traffic puts undue strain on your server. Which events a subscription app needs is its own question, the one behind what is the subscription lifecycle in Stripe. Do the whole path twice, once in a sandbox and once in live mode: they are separate destinations with separate secrets, for the reason given under the Why it matters table.

Here is a Stripe webhook example handler for Node and Express. It is written from Stripe’s webhook quickstart and was not run for this page. STRIPE_WEBHOOK_SECRET is the environment-variable name Stripe uses in its own webhook samples, in every language.

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.sendStatus(400); // refuse anything that fails the check
  }
  switch (event.type) { /* one case per event type you subscribed to */ }
  res.sendStatus(200);
});

Two choices differ from Stripe’s sample. The quickstart hard-codes a test key, and this version reads the secret key from the environment instead. The quickstart also skips verification when no endpoint secret is set; this version always verifies, so a missing variable refuses events rather than trusting them. If the check fails on a framework that parses the body first, the raw-body fix for Express, Next.js, Astro and the rest is in why Stripe webhook signature checks fail.

Where to find the Stripe webhook secret, and which one you need

The Stripe webhook secret is the whsec_ value shown on each webhook destination’s page in the Dashboard, and there are usually 3 in play: the one stripe listen prints for localhost, the sandbox destination’s, and the live destination’s. It is not an API key, and each one only verifies events for its own endpoint.

A webhook signing secret is a per-endpoint key your receiver uses to check that a request came from Stripe and was not sent or modified by a third party. To get the Stripe webhook secret key for a registered destination, open the Webhooks tab in Workbench, select the endpoint, and reveal it; Stripe’s guide prints the button both as Reveal secret and as Click to reveal.

SecretWhere it comes fromWhere it goes
The local listener’sPrinted by stripe listen when it starts; the same value across restartsYour local environment file only
The sandbox destination’sThe destination’s settings page in Workbench, in the sandboxThe staging host’s environment variables
The live destination’sThe destination’s settings page in Workbench, in live modeThe production host’s environment variables

Webhook signing secrets are per endpoint and separate from API keys, so the same URL has different test and live secrets. That makes the value you need neither the secret API key nor the publishable key, and Stripe’s API keys page adds that only publishable keys are safe to expose outside your backend. Every signing secret lives in the host’s environment variables, never in the repository and never in the client bundle.

Creating a webhook secret is not a step on Stripe: Stripe generates a unique secret for each endpoint. A provider that asks you to supply one wants a long random string, generated once and stored like a password; that is my reading of the usual setup. The secret’s prefix, a check that keeps failing, rolling a secret and a leaked one are signature problems rather than testing problems, and the signature-check article linked in the section above covers them.

Stripe CLI forward webhook events to localhost: stripe listen and stripe trigger

The Stripe CLI forwards sandbox webhook events to localhost with one command, stripe listen --forward-to localhost:4242/webhook, which also prints that endpoint’s whsec_ signing secret. A second terminal runs stripe trigger with an event name to send a signed test event through the same path.

Install the CLI and run stripe login once. On CLI versions newer than v1.50.0, Stripe’s login reference says an Administrator or IAM Admin must enable CLI access before you can authenticate. To test Stripe webhooks locally you need no registered URL and no destination in the Dashboard, and the secret stripe listen prints does not change between restarts. The four commands, from the stripe listen reference and Stripe’s webhooks guide:

stripe login
stripe listen --forward-to localhost:4242/webhook
stripe listen --events=payment_intent.succeeded --forward-to localhost:4242/webhook
stripe trigger payment_intent.succeeded   # run in a second terminal

The third line uses the --events flag to forward only the event types you name, which keeps the log readable. Stripe CLI trigger commands create test fixtures before causing webhook deliveries. The stripe trigger reference adds that a trigger may fire other events too: triggering payment_intent.succeeded also triggers payment_intent.created. Those fixtures belong to no user in your app, so a handler that looks the customer up finds nobody; that is the “cannot find your user” case in the not-updating article above, and it means a trigger proves the signature path, not the lookup.

To send a test webhook from Stripe without the CLI, Stripe’s guide says to trigger an event your destination subscribes to by creating the object yourself in the Dashboard, in a sandbox. Stripe’s webhooks guide, read on 2026-10-03, describes no separate send-test button.

On forwarding live events: the CLI reference lists a --live flag (“Make a live request. Requests run in a sandbox by default.”). My working rule is to leave it alone for webhooks. Live events carry real customers, and the last rung belongs to the deployed endpoint, not a laptop.

Providers with no CLI need a tunnel that gives localhost a public HTTPS URL. Stripe’s own guide names ngrok for this. GitHub’s guide to testing webhooks describes the same flow with a webhook proxy URL from smee.io that forwards deliveries to your machine. My working rule: close the tunnel when you finish, and never register a tunnel URL in live mode.

Test webhook replay handling: send the same event twice

Webhook replay handling is tested by resending one delivered event and counting the result. The pass condition is a single business effect: one row, one email, one grant. Stripe can retry failed live webhook deliveries for up to three days and does not guarantee event order. The handler has to expect repeats.

Stripe says as much in its guide: endpoints might occasionally receive the same event more than once. Run the test on staging, in a sandbox:

  1. 01 Pick one event in staging that was delivered with a 2xx, and note its event id and the row, email or credit it produced.
  2. 02 Resend that event with Stripe's own resend, from the event in the Dashboard or from the CLI. Never re-post a saved copy of the request.
  3. 03 Count the business effect before and after: one subscription row, one email, one credit grant. Two of anything fails.
  4. 04 Make the handler throw once, resend, and watch the delivery show as Failed. Then remove the error and resend again, or let Stripe retry it.

Dashboard Resend works up to 15 days after event creation; the CLI stripe events resend <event_id> --webhook-endpoint=<endpoint_id> works up to 30 days. A saved request is the wrong tool: Stripe’s libraries have a default tolerance of 5 minutes between the signature timestamp and the current time, and Stripe signs each delivery attempt afresh.

In live mode Stripe retries a failed webhook delivery for up to 3 days with exponential backoff; in a sandbox it retries three times over a few hours. A manual resend of an event that already failed does not stop those automatic retries, even when the resend gets a 2xx, so a handler can see the same event again after you thought you were done. For step 4 to pass, the event’s delivery attempts must show a failed attempt followed by a 2xx one; a single green attempt proves nothing about the retry path.

Because a resend does not cancel Stripe’s own retries, two copies of one event can be in flight together, so my working rule adds one more run: resend the same event from two terminals at the same moment and count again. If step 3 or that run shows two effects, the fix is in the handler’s design, which comes down to how to make a webhook handler idempotent.

How to check your own app

A webhook is working when 7 things are true: the live destination exists, recent deliveries show 2xx, a bad signature is refused, a resent event changes nothing, a failure is retried, the live secret is its own, and the first real event changed the account.

To check if a webhook is working in production, run these seven in order and keep the evidence each one names:

  1. 01 The destination exists in live mode, with the right URL and the right event list. Evidence: the endpoint id.
  2. 02 The most recent deliveries show 2xx in the Event deliveries tab. Evidence: a screenshot or the event ids with their status codes.
  3. 03 A request with no valid signature is refused: a plain curl POST to the URL gets a 4xx and changes nothing. Evidence: the log line and the unchanged row.
  4. 04 A resent event produces one effect. Evidence: the row count before and after.
  5. 05 A forced handler error shows as a failed delivery, and the resend or retry then succeeds. Evidence: the delivery attempts list.
  6. 06 The production host holds the live destination's secret, not the sandbox one. Evidence: check 2 passing on live events.
  7. 07 The first real event from a real customer is delivered with a 2xx and the account state changed. Evidence: the event id, its time and the row.

Check 2 answers how to check webhook status: Workbench lists each event as Delivered, Pending or Failed, and clicking one shows the HTTP status code of each attempt. Reading a non-2xx or an empty log is a debugging job, and the signature-check article above has a section on reading the Dashboard first.

Check 3 is the one I would never skip. In my June and July 2026 audits, a point-of-sale app’s list of public paths already included the webhooks folder, so the first webhook anyone added there would ship with no authentication. The lesson I take from it: a route that is public by design stays safe only while its signature check refuses everything else, and check 3 is how you prove that it does.

Check 6 needs no peeking at the value. A mismatched secret fails every live signature, so check 2 passing on live events is the proof; never read the secret out or paste it anywhere to compare. For check 7, wait for a real customer. A test purchase with your own card in live mode is not the test this list asks for.

After the first real event lands, set an alert on failed deliveries so the next problem is not found by a customer. The whole move from test mode to live, of which the webhook is one part, is in the Stripe test mode to live checklist and in the payment go-live checklist. When check 2 is green and the data is still wrong, the not-updating article above is the next stop.

In the Production Hardening Sprint, deliverable 5.1 is verified this way: “Confirm valid events succeed and invalid or altered payloads are rejected.” Deliverable 5.2 is verified this way: “Replay and concurrently deliver events; confirm one intended business effect.”

Where the sprint fits

No deliverable is called webhook testing. The two nearest are 5.1, Webhook signature verification, which verifies the payment provider’s signatures before processing webhook payloads, and 5.2, Idempotent webhook handling, which makes every webhook handler safe to repeat, including concurrent delivery and its downstream side effects. Area 05, Payments and billing, holds 10 of the 123 deliverables in the published sprint scope. The fee covers our engineering work; hosting, paid tools, and API usage remain in your accounts, and we explain any required third-party costs before enabling them.

Common questions about testing webhooks

How can I test webhooks using Postman?

Postman can test one side of a webhook at a time. It can send a POST with a sample body to your endpoint, which a correct Stripe handler refuses because the request has no valid signature, and a Postman mock server can stand in for the receiving URL, since mock servers are deployed to the Postman cloud and are always available to handle requests.

A mock server answers with the saved example that best matches the request, so it plays the receiver; it never runs your handler code. Postman’s mock servers page explains how those examples are matched.

Are webhooks free?

Yes, on Stripe’s side: its pricing page lists no separate charge for webhooks (checked 2026-10-03). On your side, a webhook costs whatever your host charges to run the route for each request, and the online testers are free only within the limits given in the testers section.

Should webhooks be secret?

The URL does not need to be secret; the signing secret does. Anyone can find or guess a route, so security comes from the signature check refusing every request the provider did not sign, not from hiding the path. That is my reading of how provider signatures are designed.

How to trigger a webhook?

Do the action that causes the event in test mode, or use the provider’s trigger tool. For Stripe that is stripe trigger with an event name, which sends a signed test event to whatever stripe listen is forwarding to.

What happens if a webhook fails?

The provider retries it on its own schedule, and nothing in your app changes until a delivery succeeds. Stripe keeps retrying a failed live delivery for up to three days and a sandbox one three times within a few hours; the Event deliveries tab shows each failed attempt and when the next retry is due. If every attempt fails, the change only reaches your database when you resend the event or a reconciliation job catches the gap.