Back to Blog
Development

Handling Stripe Webhooks Locally

Gonzalo Buszmicz
Gonzalo Buszmicz
Tunnelwise Developer
Dec 20, 2025
8 min read
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:

EventWhen it fires
payment_intent.succeededA payment completes successfully
payment_intent.payment_failedA payment fails
checkout.session.completedStripe Checkout finishes
customer.subscription.createdSubscription created
customer.subscription.updatedSubscription updated
customer.subscription.deletedSubscription canceled
invoice.paidInvoice paid
invoice.payment_failedInvoice failed

Full list: Stripe Event Types Reference


Creating a Stripe Webhook Endpoint

You create Stripe webhooks from the Stripe Dashboard:

  1. Go to Developers → Webhooks
  2. Click Add endpoint
  3. Enter your public HTTPS URL
  4. Select the events you want to receive
  5. 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:

  1. Go to Developers → Webhooks
  2. Select your webhook endpoint
  3. Click Send test event
  4. 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.id for idempotency
  • Keep webhook secrets secure: keep your STRIPE_WEBHOOK_SECRET in 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.id and 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! 🚀

Ready to try Tunnelwise?

Start tunneling your localhost to the world in seconds