Tools: How to Test Stripe Webhooks Without Deploying to Production
Source: Dev.to
You're building a checkout flow. Payments work. Now Stripe needs to tell your app what happened — payment succeeded, subscription renewed, charge disputed. That happens through webhooks: Stripe sends an HTTP POST to your server. Problem is, your app runs on localhost:3000. Stripe can't reach that.
So how do you test webhooks during development? Let me walk through the three approaches I've used, what annoys me about each, and the workflow I've settled on. Approach 1: Stripe CLI The official way. Install it, log in, forward events to your local server: bashstripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Then trigger mock events: bashstripe trigger payment_intent.succeeded It works, but the friction adds up. The mock events have fake data — generic customer IDs, placeholder amounts. If your handler does anything real with the payload (update a database, send a confirmation), mock data doesn't test that logic. The tunnel dies when you close the terminal. Every restart gives you a new signing secret, so your signature verification breaks until you remember to update .env. And if your handler returns a 500, the CLI output doesn't show you exactly what went wrong. Skip the CLI, expose your server directly: You get a public URL, paste it into Stripe's webhook settings, and now you're receiving real events from actual test-mode payments. That's better than mock data. But the free URL changes every session. You restart ngrok, you update the Stripe dashboard, you restart ngrok again, you update again. If your handler crashes mid-request, the webhook is gone — you either wait hours for Stripe's exponential backoff retry or manually reconstruct the payload. And once you close ngrok, the request history vanishes. Approach 3: Capture first, process later This is what I actually use now. Instead of pointing Stripe at my local server, I point it at a persistent endpoint that captures and stores every webhook. Then I inspect payloads on my own time and replay them to localhost when I'm ready. I built a tool called HookLab for this. Here's the workflow: bashcurl -X POST https://hooklab-webhook-testing-and-debugging.p.rapidapi.com/api/v1/endpoints \ -H "Content-Type: application/json" \ -H "X-RapidAPI-Key: YOUR_KEY" \ -H "X-RapidAPI-Host: hooklab-webhook-testing-and-debugging.p.rapidapi.com" \ -d '{"name": "stripe-test"}' You get back a public URL like https://hooklab-webhook-testing-and-debugging.p.rapidapi.com/hook/ep_V1StGXR8_Z5j. Paste that into Stripe's webhook settings. Make a test payment in the Stripe Dashboard using card 4242 4242 4242 4242. Stripe sends the webhook, HookLab captures it. bashcurl https://hooklab-webhook-testing-and-debugging.p.rapidapi.com/api/v1/endpoints/ep_V1StGXR8_Z5j/requests \ -H "X-RapidAPI-Key: YOUR_KEY" \ -H "X-RapidAPI-Host: hooklab-webhook-testing-and-debugging.p.rapidapi.com" Full headers, full body, Stripe-Signature included, timestamp, everything. No console.log archaeology. Replay it to your local server: bashcurl -X POST https://hooklab-webhook-testing-and-debugging.p.rapidapi.com/api/v1/replay \ -H "Content-Type: application/json" \ -H "X-RapidAPI-Key: YOUR_KEY" \ -H "X-RapidAPI-Host: hooklab-webhook-testing-and-debugging.p.rapidapi.com" \ -d '{"request_id": "req_abc123", "target_url": "http://localhost:3000/api/webhooks/stripe"}' Same headers, same body, same method — sent straight to your handler. It crashes? Fix the bug, replay again. No waiting for Stripe's retry. Same real webhook, as many times as you need. Why capture-and-replay is worth it The three most common Stripe webhook bugs all get easier to find:
Signature verification failures. Something between Stripe and your code is modifying the body — middleware parsing JSON, a proxy re-encoding, a framework adding whitespace. With captured webhooks you can see the exact raw body Stripe sent and compare it to what your handler receives. Wrong event types. You're handling charge.succeeded but Stripe sends payment_intent.succeeded for your integration. Capture one checkout flow and you'll see every event Stripe fires, in order. Data structure surprises. You wrote event.data.object.customer.email but customer is a string ID, not an expanded object. Real captured payloads show you the actual structure before you write the handler. Gotchas that catch everyone
Regardless of which approach you use: Return 200 immediately. Stripe expects a response within 5-10 seconds. Do your heavy processing async. Otherwise Stripe thinks delivery failed and retries, causing duplicates.
Handle duplicates. Stripe uses at-least-once delivery. Use event.id as an idempotency key — check if you already processed it before doing anything. Test with real test-mode transactions. stripe trigger is fine for checking your endpoint is reachable. But don't consider your integration tested until you've processed webhooks from actual test checkouts. The payloads are different in ways that matter. If you want to try the capture-and-replay workflow, HookLab has a free tier on RapidAPI — 100 calls/day and 3 endpoints, which is plenty for testing a Stripe integration. What's your webhook testing setup? I'm curious what other people use — drop a comment Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse