Stripe Guide

How to Test Stripe Webhooks Locally

Learn a practical Stripe webhook testing workflow: capture real deliveries, inspect raw payloads, forward requests to localhost, and replay saved events after code changes.

To test Stripe webhooks locally you need to route Stripe event deliveries through a stable public URL, capture each request so you can inspect it, and then forward that same request to your localhost development server. This approach lets you see the exact payload Stripe sends before your code ever touches it, which eliminates most of the guesswork that makes webhook debugging frustrating.

The fastest path is to create a public endpoint with HookNexus, paste that URL into the Stripe Dashboard, trigger a test event, confirm the delivery landed, and then pipe the traffic into your local route with the CLI. The rest of this guide walks through every step, including the Express.js handler code you need to verify Stripe signatures correctly.

Why local Stripe webhook testing is harder than it looks

Stripe can only deliver webhooks to a publicly reachable URL. Your localhost:3000 development server does not qualify, and Stripe will silently fail or return a connection error if you try. That single constraint creates a chain of secondary problems that trip up even experienced developers.

First, Stripe signs every webhook delivery with an HMAC in the Stripe-Signature header. The signature is computed against the raw request body, so if any middleware parses or transforms the body before you verify the signature, validation fails with the confusing stripe.webhooks.constructEvent error. This is the single most common issue developers hit, and it has nothing to do with the endpoint URL itself.

Second, Stripe operates in two completely separate modes: test mode and live mode. Each mode has its own set of API keys, webhook signing secrets, and event streams. Registering a webhook endpoint while viewing the live-mode dashboard and then triggering events in test mode means your endpoint never receives anything. The dashboard does not warn you about this mismatch clearly enough.

Third, once you have the delivery working, you still need a way to replay the exact same event after making code changes. Re-triggering a checkout.session.completed event means creating a brand-new Checkout Session, completing the flow, and waiting for Stripe to fire the event again. That overhead adds up fast during an iterative debugging session.

A proper local testing setup solves all three problems: a public URL that accepts traffic, raw body preservation for signature checks, and a captured request history you can replay on demand.

What you need before starting

Before you begin, make sure you have:

  • A Stripe account with access to test mode. You do not need a live-mode account to follow this guide.
  • A Stripe webhook signing secret (starts with whsec_). You will find this in the Stripe Dashboard after registering your endpoint.
  • Node.js 18+ installed locally (for the Express.js handler example).
  • The HookNexus CLI installed globally:
npm install -g hooknexus
hooknexus login
  • A working local server listening on a known port (this guide assumes http://localhost:3000/api/webhooks/stripe).

If you do not have a HookNexus account yet, the free tier includes enough endpoints and request history to complete this entire workflow. You can create an account through the webhook debugger.

Step-by-step local testing workflow

This section walks through the complete flow from creating an endpoint to handling the event in your application code.

Step 1 — Create a public webhook endpoint

Open your terminal and create a new endpoint with the HookNexus CLI:

hooknexus endpoints create

The CLI returns an endpoint URL like https://api.hooknexus.com/h/abc123. Copy this URL. It is your stable public address that Stripe will deliver events to. Every request that hits this URL is captured and stored so you can inspect it later in the HookNexus debugger.

You can also create the endpoint through the web dashboard if you prefer a visual interface.

Step 2 — Register the endpoint in Stripe Dashboard

  1. Open the Stripe Dashboard and make sure you are in test mode (toggle in the top-right corner).
  2. Navigate to Developers > Webhooks.
  3. Click Add endpoint.
  4. Paste the HookNexus endpoint URL into the Endpoint URL field.
  5. Under Select events to listen to, choose the specific events your application handles. For a checkout integration, common choices are:
    • checkout.session.completed
    • invoice.paid
    • invoice.payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted
  6. Click Add endpoint to save.

After saving, Stripe shows the endpoint details page. Click Reveal under Signing secret to copy the whsec_... value. You will need this in your application to verify webhook signatures.

Avoid selecting “all events” unless your handler explicitly needs them. Receiving every event type adds noise to your debugging workflow and makes it harder to isolate the payload you care about.

Step 3 — Trigger a test event

Stay in test mode and create an action that fires the event you selected. For checkout.session.completed, the easiest approach is:

  1. Create a Checkout Session using the Stripe API or your application’s checkout flow.
  2. Complete the payment using a Stripe test card number like 4242 4242 4242 4242.
  3. After the payment succeeds, Stripe fires the checkout.session.completed event to your registered endpoint.

Alternatively, you can use the Send test webhook button on the endpoint detail page in the Stripe Dashboard. This sends a synthetic event with example data. It is useful for verifying connectivity, but the payload structure may differ slightly from a real event.

Step 4 — Inspect the captured request

Open the HookNexus dashboard or use the CLI to check your endpoint’s request history:

hooknexus endpoints list

Look at the captured request and verify:

  • Status code: HookNexus returns 200 to Stripe, confirming delivery succeeded.
  • Headers: Check for Stripe-Signature (the HMAC signature header), Content-Type: application/json, and Stripe-Event-Id.
  • Body: The JSON body contains the event object. Confirm the type field matches the event you expected (e.g., checkout.session.completed) and that the data.object contains the expected resource.
  • Timing: Note the delivery timestamp. If you see multiple entries for the same event, Stripe may be retrying because a previous attempt received a non-2xx response.

This inspection step is critical. If the request never arrived, the problem is on the Stripe-to-endpoint delivery side (wrong URL, wrong mode, network issue), not in your local code. Fix the delivery first before debugging your handler.

For a more detailed view, you can also review the full integration guide at the HookNexus Stripe integration docs.

Step 5 — Forward to localhost

Once you have confirmed the delivery works, start forwarding live traffic to your local server. Refer to the CLI forwarding documentation for advanced options.

hooknexus forward ENDPOINT_ID --to http://localhost:3000/api/webhooks/stripe

Replace ENDPOINT_ID with the actual ID from step 1. The CLI opens a persistent connection and pipes every incoming request to your local route in real time. You will see each forwarded request logged in your terminal.

Keep this terminal session running while you develop. Every new Stripe delivery is forwarded to your local server automatically. If your local server is not running or returns an error, HookNexus still captures the original request so you can replay it later.

For more background on the forwarding workflow, see the forward webhooks to localhost guide.

Step 6 — Handle the webhook in your application

Here is a complete Express.js webhook handler that verifies the Stripe signature and processes the event. The key detail is that you must pass the raw request body to stripe.webhooks.constructEvent, not the parsed JSON.

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

const app = express();

app.post(
  "/api/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

    let event;

    try {
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      console.error("Signature verification failed:", err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case "checkout.session.completed":
        const session = event.data.object;
        console.log("Checkout completed for session:", session.id);
        // Fulfill the order, update database, send confirmation email
        break;

      case "invoice.paid":
        const invoice = event.data.object;
        console.log("Invoice paid:", invoice.id);
        // Record successful payment, extend subscription
        break;

      case "invoice.payment_failed":
        const failedInvoice = event.data.object;
        console.log("Payment failed for invoice:", failedInvoice.id);
        // Notify customer, flag account
        break;

      default:
        console.log("Unhandled event type:", event.type);
    }

    res.status(200).json({ received: true });
  }
);

app.listen(3000, () => {
  console.log("Webhook handler listening on port 3000");
});

The critical line is express.raw({ type: "application/json" }). This middleware ensures the body reaches your handler as a raw Buffer instead of a parsed JavaScript object. If you use express.json() globally, it will parse the body before your webhook route runs, and signature verification will fail every time. See the signature verification troubleshooting guide for a deeper explanation.

If you are using Next.js App Router instead of Express, disable body parsing for the webhook route:

// app/api/webhooks/stripe/route.js
import Stripe from "stripe";
import { NextResponse } from "next/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  let event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error("Signature verification failed:", err.message);
    return NextResponse.json(
      { error: `Webhook Error: ${err.message}` },
      { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed":
      console.log("Checkout session completed:", event.data.object.id);
      break;
    case "invoice.paid":
      console.log("Invoice paid:", event.data.object.id);
      break;
    default:
      console.log("Unhandled event type:", event.type);
  }

  return NextResponse.json({ received: true });
}

Notice that Next.js App Router does not parse the body automatically when you call request.text(), so signature verification works without extra configuration.

Common problems during local Stripe testing

Signature verification fails

This is almost always caused by body parsing middleware running before your webhook route. The Stripe SDK computes the expected signature from the raw bytes of the request body. If express.json() or a similar parser converts the body into an object and back, the byte representation changes and the signature no longer matches.

Fix: use express.raw({ type: "application/json" }) on your webhook route specifically, or ensure no global body parser applies to the webhook path. In Next.js, read the body with request.text() instead of request.json().

For a full walkthrough, see Stripe webhook signature verification failed.

Event never reaches your endpoint

Check these in order:

  1. Mode mismatch: Confirm both the Stripe Dashboard and the action that triggers the event are in the same mode (test or live).
  2. URL typo: Verify the endpoint URL in Stripe matches the HookNexus endpoint URL exactly.
  3. Event filter: Check that the specific event type you are triggering is included in the endpoint’s event selection.
  4. Stripe delivery logs: Open the endpoint in the Stripe Dashboard and check the Webhook attempts tab for delivery status and error messages.

Wrong event type

If your handler receives an event but the type field is not what you expected, make sure you triggered the correct action. For example, creating a Checkout Session fires checkout.session.created, not checkout.session.completed. The completed event only fires after the customer finishes the payment.

Test mode vs live mode confusion

Stripe maintains completely separate webhook endpoint lists for test mode and live mode. An endpoint registered in live mode will never receive test-mode events, and vice versa. Always confirm the mode toggle in the top-right corner of the Stripe Dashboard matches the mode of your API keys and the events you are generating.

Your webhook signing secret (whsec_...) is also mode-specific. Using a live-mode signing secret to verify a test-mode delivery will fail signature verification even if the body is intact.

When to use replay instead of re-triggering

Once you have captured a webhook delivery, you do not need to re-trigger the original action in Stripe every time you change your handler code. Instead, replay the captured request directly from HookNexus. Replay sends the exact same headers and body to your localhost route, which means you get a pixel-perfect reproduction of the original delivery.

Replay is particularly valuable when:

  • The original trigger is expensive or slow (completing a full Checkout Session flow).
  • You are iterating on error handling and need to hit the same failure path repeatedly.
  • You want to test idempotency by processing the same event multiple times.

Read the full guide on replaying Stripe webhook events locally for detailed instructions.

Quick checklist

  • HookNexus CLI installed and authenticated (hooknexus login)
  • Public endpoint created with hooknexus endpoints create
  • Endpoint URL registered in Stripe Dashboard (correct mode selected)
  • Specific event types selected (not “all events”)
  • Test event triggered and delivery confirmed in HookNexus
  • Raw request body, headers, and event type inspected before writing handler code
  • CLI forwarding started with hooknexus forward ENDPOINT_ID --to http://localhost:3000/api/webhooks/stripe
  • Webhook handler uses raw body for signature verification (no JSON parsing before constructEvent)

Frequently asked questions

Is Stripe CLI enough for local webhook testing?

Stripe CLI (stripe listen --forward-to) is a solid tool for Stripe-only workflows. It creates a temporary tunnel and forwards events directly. However, it does not persist request history after the session ends, does not let you replay events after code changes, and only works with Stripe. If your application receives webhooks from multiple providers or you need a persistent inspection and replay workflow, a dedicated webhook debugger gives you more flexibility.

Why not just debug from the Stripe Dashboard?

The Stripe Dashboard shows delivery attempts and response codes, which is useful for confirming that Stripe sent the event. But it does not let you forward the request to localhost, inspect the raw body in a debugging context, or replay the delivery into your development server. Dashboard debugging tells you what Stripe did; local debugging tells you what your code did with it.

When should I add replay to my workflow?

Add replay as soon as you start making iterative changes to your webhook handler. The first delivery confirms the event structure and your initial handling logic. Every subsequent code change benefits from replay because you skip the trigger step entirely and send the exact same payload through your updated handler. This cuts the feedback loop from minutes to seconds.

Do I need a different signing secret for local testing?

No. You use the same signing secret (whsec_...) from the Stripe Dashboard endpoint configuration regardless of whether the request comes directly from Stripe or is forwarded through HookNexus. HookNexus preserves the original headers, including Stripe-Signature, so your verification code works identically in both scenarios.

Next steps

You now have a complete local Stripe webhook testing workflow: public endpoint, event capture, inspection, localhost forwarding, and signature verification. From here: