Handling Stripe Webhooks Locally

Introduction
If you’re building anything with Stripe — payments, subscriptions, invoices, or billing logic — webhooks are not optional. They’re the backbone of any reliable Stripe integration.
In this guide, we’ll focus on one specific Stripe concept: receiving and testing Stripe webhooks locally
You’ll learn how to:
- Understand what Stripe webhooks are and why they matter
- Subscribe to Stripe webhook events
- Build a secure webhook handler with Node.js + Express
- Test Stripe webhooks locally
- Use Tunnelwise to expose your localhost for real webhook testing
This post is written for developers who want practical, copy-paste-friendly examples.
What Is a Stripe Webhook?
A Stripe webhook is an HTTP endpoint that Stripe calls whenever something happens in your Stripe account.
Examples:
- A payment succeeds
- A payment fails
- A subscription renews
- An invoice is paid
- A customer cancels a subscription
Instead of asking Stripe’s API “did something happen?”, Stripe pushes events to your server automatically.
Official docs:
Why You Must Use Webhooks with Stripe
A very common beginner mistake is relying only on the client-side response (e.g. payment_intent.confirm()).
Stripe explicitly warns against this:
“Do not rely on client-side events alone. Use webhooks to handle asynchronous payment events.”
— Stripe Docs
Why?
- Payments can succeed after the user closes the browser
- Bank redirects and 3DS flows are asynchronous
- Network issues happen
- Retries and delayed confirmations are common
Official docs:
Common Stripe Webhook Events
Here are some of the most commonly used Stripe webhook events:
| Event | When it fires |
|---|---|
payment_intent.succeeded | A payment completes successfully |
payment_intent.payment_failed | A payment fails |
checkout.session.completed | Stripe Checkout finishes |
customer.subscription.created | Subscription created |
customer.subscription.updated | Subscription updated |
customer.subscription.deleted | Subscription canceled |
invoice.paid | Invoice paid |
invoice.payment_failed | Invoice failed |
Full list: Stripe Event Types Reference
Creating a Stripe Webhook Endpoint
You create Stripe webhooks from the Stripe Dashboard:
- Go to Developers → Webhooks
- Click Add endpoint
- Enter your public HTTPS URL
- Select the events you want to receive
- Save the endpoint and copy the Signing Secret
Stripe Dashboard Webhook Setup
⚠️ Stripe does not allow http://localhost — you’ll need a public HTTPS URL (we’ll solve this later with Tunnelwise).
Building a Stripe Webhook Handler (Node.js + Express)
1. Install dependencies
npm install express stripe
2. Basic Express server
Stripe requires the raw request body to verify signatures. If you use express.json() globally, the signature verification will fail.
// server.js
const express = require("express")
const Stripe = require("stripe")
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const app = express()
// ⚠️ IMPORTANT: Define the webhook route BEFORE app.use(express.json())
// This ensures we get the raw body for signature verification.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["stripe-signature"]
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET)
} catch (err) {
console.error("❌ Webhook signature verification failed.", err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// ⚡ Acknowledge receipt immediately (Stripe expects a 200 OK within 10s)
res.json({ received: true })
// Handle the event asynchronously
handleEvent(event)
})
// Regular middleware for other routes
app.use(express.json())
async function handleEvent(event) {
console.log(`✅ Processing event: ${event.type}`)
switch (event.type) {
case "payment_intent.succeeded":
const paymentIntent = event.data.object
// TODO: Fulfill the order, send email, etc.
break
case "customer.subscription.deleted":
const subscription = event.data.object
// TODO: Revoke access to the product
break
default:
console.log(`Unhandled event type ${event.type}`)
}
}
app.listen(3000, () => console.log("Listening on port 3000"))
Advanced: Handling Idempotency and Retries
In a production environment, Stripe might send the same event more than once (e.g., if your server didn't respond in time).
To avoid double-charging or duplicate logic, follow these tips:
1. Store Event IDs
Record the event.id in your database. If you receive an event with an ID you've already processed, ignore it.
2. Respond Fast
Send the 200 OK status immediately. If you have heavy processing (like generating a PDF), move it to a background worker or process it after sending the response.
3. Version Consistency
Webhook payloads depend on your Stripe API Version. Always ensure your library version matches the version set in your Stripe Dashboard.
Testing Stripe Webhooks
Testing properly is critical before going live.
1. The Stripe CLI (Highly Recommended)
The CLI is the gold standard for local development. It bypasses firewalls and manages signing secrets automatically.
stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
Then, trigger a mock event:
stripe trigger payment_intent.succeeded
2. Trigger Events from Stripe Dashboard
If the Stripe CLI isn't available or you prefer a visual interface, using the Stripe Dashboard directly is the second best option for testing webhooks.
Stripe lets you send test events directly from your Dashboard:
- Go to Developers → Webhooks
- Select your webhook endpoint
- Click Send test event
- Choose an event type (e.g.
payment_intent.succeeded)
Stripe sends a realistic payload instantly.
Official docs:
3. Viewing Webhook Logs
Stripe gives you full visibility: Developers → Webhooks → Events
You can:
- See delivery attempts
- Inspect request payloads
- View response codes
- Retry failed events
Official docs:
Testing Locally with Tunnelwise
Stripe requires public HTTPS endpoints. While the CLI is great for triggers, sometimes you need to test the actual flow (like a real Checkout session or a mobile app redirect).
Tunnelwise provides a public HTTPS URL for your localhost, making it perfect for real-world webhook testing on macOS.
1. Start your server
node server.js
2. Start Tunnelwise
Run Tunnelwise app and create a new tunnel or start the existing one for this service.
Once started, Tunnelwise will give you a public HTTPS URL, e.g.
https://blue-hawk.tunnelwise.io.
3. Register your webhook
Copy the https://blue-hawk.tunnelwise.io URL and paste it into the Stripe Dashboard under Webhook Endpoints.
Now, you are ready to test real Stripe events hitting your local server!
Stripe Webhook Best Practices
- Always verify webhook signatures: never trust a payload without checking
stripe-signature - Respond quickly (offload heavy work): don't make Stripe wait; use queues for long tasks
- Handle retries and duplicate events: use
event.idfor idempotency - Keep webhook secrets secure: keep your
STRIPE_WEBHOOK_SECRETin an environment variable
Wrap-up
Webhooks are the backbone of reliable Stripe integrations.
- Verify every request signature (
stripe-signature) - Respond quickly (
200 OK) and process heavy work asynchronously - Deduplicate using
event.idand persist processing state - Keep Stripe API and library versions aligned
For local and end‑to‑end testing:
- Use Stripe CLI to simulate events rapidly
- Use Tunnelwise to expose localhost over HTTPS for real flows (Checkout, 3DS, mobile redirects)
Next in this series:
- Subscription lifecycle with webhooks
- Payment failures and automatic retries
- Idempotency patterns and production hardening
Happy coding! 🚀