When I test a normal feature locally, my browser talks to my backend on the same laptop.

Webhooks are different. Stripe or GitHub starts the request, and localhost means their machine, not mine. A localhost webhook URL will never reach my laptop from Stripe.

For local testing, I need a public URL that eventually reaches the process on my laptop.

The mental model

A normal local request looks like this:

browser on my laptop -> http://localhost:3000/api/thing

A webhook delivery looks like this:

provider's server -> https://my-public-url.example.com/webhooks/provider

When I test webhooks locally, I want the provider’s server to hit a public HTTPS URL that forwards to my laptop:

provider's server
  -> https://dev-my-app.example.com/webhooks/provider
  -> tunnel
  -> http://localhost:3000/webhooks/provider

Without the public URL, the provider has nowhere to send the request.

So I start by checking everything before the handler:

  • the provider cannot reach the public URL
  • the tunnel is down
  • the tunnel points to the wrong local port
  • the app route is wrong
  • the route rejects POST
  • JSON parsing changed the raw body before signature verification
  • the secret is from a different endpoint
  • the handler returns too slowly
  • the handler returns 200, but your async work failed after that

If any of those are wrong, I fix that before reading the handler code.

Start by proving the URL is reachable

Before touching provider settings, I check the public URL from outside my app.

curl -i https://dev-my-app.example.com/webhooks/stripe

For a webhook endpoint, GET might return 404 or 405. That’s fine. I only care that the hostname resolves, TLS works, and the request reaches my app.

Then I send a fake POST:

curl -i \
  -X POST \
  -H 'Content-Type: application/json' \
  --data '{"ping":true}' \
  https://dev-my-app.example.com/webhooks/stripe

If this never appears in my local logs, I stop there and fix the tunnel, hostname, port, or route before touching provider settings.

For my own projects I usually use Cloudflare Tunnel with a stable dev subdomain. Cloudflare’s docs also show quick tunnels for development, which give you a public trycloudflare.com hostname.

I use a named tunnel with my own dev hostname because provider dashboards remember URLs, and random URLs turn local testing into dashboard bookkeeping.

Do not start with the provider dashboard

I open the provider dashboard after I know my URL reaches my local server.

I want this order:

  • curl reaches the public URL
  • the public URL reaches my local server
  • my local server logs the route, method, headers, and status
  • then I configure the provider

When I skip that order, I waste time reading provider delivery logs for a URL my laptop never received.

A tiny temporary log helps:

app.post("/webhooks/stripe", express.raw({ type: "*/*" }), (req, res) => {
  console.log("stripe webhook", {
    method: req.method,
    path: req.path,
    contentType: req.header("content-type"),
    stripeSignature: Boolean(req.header("stripe-signature")),
    bytes: req.body.length,
  });

  res.sendStatus(200);
});

I keep that log noisy while wiring things up. Once requests arrive reliably, I cut it back.

The raw body problem

Most webhook providers sign the raw request body.

Most signature failures I see locally come from losing those original bytes.

Stripe’s docs are explicit: signature verification needs the raw body, the Stripe-Signature header, and the endpoint secret. GitHub signs deliveries with X-Hub-Signature-256 when you configure a secret. Shopify signs HTTPS deliveries with X-Shopify-Hmac-SHA256 generated from the raw request body.

If your framework parses JSON before your webhook code runs, the bytes you verify may no longer be the bytes the provider signed.

In Express, this is the usual mistake:

app.use(express.json());

app.post("/webhooks/stripe", (req, res) => {
  // req.body is already parsed.
  // Stripe signature verification can fail here.
});

Use a raw body parser for the webhook route:

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("stripe-signature");

    const event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );

    console.log("received", event.type);
    res.sendStatus(200);
  },
);

app.use(express.json());

In Express, put the webhook route before app.use(express.json()). In other frameworks, use whatever hook gives the handler the raw request body.

The same rule applies outside Express. Before verifying the signature, get the original request body in the exact form the provider expects.

Local webhook secrets are often different

I always check which endpoint created the secret.

Stripe is the easiest place to see this. A local stripe listen session gives you a signing secret for that forwarding session. A webhook endpoint created in the Stripe dashboard has its own signing secret. If I use the dashboard endpoint secret while testing stripe listen, signature verification fails.

Same practical rule for other providers: check which endpoint generated the secret.

My .env usually makes that explicit:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_from_the_current_endpoint

Not:

WEBHOOK_SECRET=some_secret_i_found_somewhere

I name it after the provider and endpoint so I don’t grab the wrong secret later.

Webhooks are not browser requests

I keep browser debugging separate from webhook debugging.

For a browser call, I look at CORS, cookies, origins, preflight requests, and frontend dev servers.

A webhook delivery is server-to-server. The provider’s server sends an HTTP request to your endpoint. There is no browser enforcing CORS on that delivery.

CORS can still show up if you build a local admin page that calls your webhook route from the browser. In that case, your browser is calling your route. Stripe, GitHub, and Shopify are not delivering a webhook.

I separate the two tests:

# Webhook-style server request
curl -i -X POST https://dev-my-app.example.com/webhooks/provider

and:

browser app -> backend API

I don’t debug those with the same checklist.

Return fast, do work after

A webhook handler should usually do three things synchronously:

  • verify the signature
  • store enough information to process the event once
  • return a success response

Then a job or background worker does the real work.

If my handler sends email, calls three APIs, writes five records, and then returns 200, every local test has too many places to fail.

I prefer a small local first pass:

app.post("/webhooks/stripe", rawBody, async (req, res) => {
  const event = verifyStripeEvent(req);

  await db.webhookEvent.upsert({
    where: { provider_event_id: event.id },
    create: {
      provider: "stripe",
      provider_event_id: event.id,
      type: event.type,
      payload: event,
    },
    update: {},
  });

  res.sendStatus(200);

  queue.enqueue("process-stripe-event", { eventID: event.id });
});

In my apps, the handler acknowledges receipt after verification and durable storage, then processes separately.

Expect duplicate and out-of-order deliveries

Local testing often hides this because you click “send test webhook” once and watch one request.

In production, Stripe can retry failed deliveries, GitHub lets me replay recent deliveries, and related events can arrive in a different order from the one I expected.

Stripe documents duplicate events and says not to depend on event ordering. Shopify includes a delivery ID you can use to detect duplicates. GitHub has delivery IDs and replay tooling in its webhook UI.

My table usually has at least:

provider
provider_event_id
event_type
received_at
processed_at
status
payload

For GitHub, the provider event ID might be the delivery ID. For Stripe, it is usually the event object’s id. Save the ID you need to make processing idempotent.

If I receive the same event twice, the second request should not create a second subscription, send a second welcome email, or mark an order twice.

The diagnostic checklist I use

When a local webhook fails, I go through this list in order:

  • Can the provider reach the hostname? Check DNS, TLS, tunnel status, and whether the public URL maps to the correct local port.
  • Is the provider using the exact URL? Watch for missing path prefixes, trailing slash 3xx responses, old random tunnel URLs, and staging vs local domains.
  • Does the route accept POST? Providers won’t call your nice browser-only GET route.
  • Does the app log the request before verification? If not, the request is dying before your handler.
  • Am I reading the raw body? Signature verification should happen before JSON parsing changes the body.
  • Is the secret from this endpoint? Dashboard endpoint, CLI forwarding session, app-level secret, and old rotated secret are different things.
  • Am I returning a 2xx quickly? 3xx, 4xx, 5xx, and timeouts are failures for most providers.
  • Can I replay the same delivery? Use the provider’s resend/replay tool, or save the raw request in local logs while debugging.
  • Is the processing idempotent? Assume duplicates and out-of-order events.

When I follow that order, I usually find the bad assumption quickly.

A good local setup feels boring

The local setup I use:

  • a stable public dev hostname
  • a tunnel that points to the right local port
  • a webhook route that logs receipt before doing work
  • provider-specific signature verification using the raw body
  • explicit endpoint secret names in .env
  • durable event storage
  • a way to replay the event

At that point, the work is normal backend code: receive the request, verify it, store the event, process it, and make it safe to run twice.

I fix reachability first, then the handler.