Webhooks

Debugging Webhooks: Why Isn't Mine Working?

Webhooks fail silently more often than they should. Here's a systematic way to find out what's actually going wrong.

April 20258 min read

The Particular Cruelty of a Broken Webhook

When a regular API call fails, you get something back — a status code, an error message, a stack trace if you're lucky. You have something to work with. Webhooks don't give you that luxury. When something breaks on the receiving end, you're just sitting there staring at a screen that isn't updating, wondering whether the request ever arrived, whether your code even ran, or whether you misspelled the URL three days ago and never noticed.

The good news is that most webhook failures have a small number of common causes, and there's a logical order to rule them out. Start from the outside — did the request arrive at all? — and work inward toward your application code. Resist the urge to immediately rewrite your handler. Nine times out of ten the problem is something environmental, not a code bug.

The debugging order that saves the most time:

Check the service's delivery log first. Then your tunnel. Then signature verification. Then your own server logs. Work outside-in, not the other way around.

Before You Touch Any Code, Check the Delivery Log

Every major service that sends webhooks keeps a delivery log. This is your first stop, full stop. It tells you exactly what was sent, when, what HTTP status your server returned, and what the response body was. If the request shows up there with a 400 or 500 next to it, your server received the request — the problem is in your handler. If there's no entry at all, or all attempts show a connection error, the problem is in your infrastructure or tunnel.

In the Stripe dashboard, go to Developers → Webhooks → click your endpoint → Recent Deliveries. You'll see every attempt, the response code, the response body your server sent back, and a "Resend" button that'll be useful later. For GitHub webhooks, it's under Settings → Webhooks → click your webhook → Recent Deliveries. Same idea.

The response body section is particularly useful. If your server threw an exception, the error message likely ended up in the response body. That's often all you need to identify the problem without ever opening your own logs.

If the delivery log is empty or shows zero attempts

Either the event you're waiting for hasn't fired, or you're not subscribed to the right event type. Double-check which events your endpoint is subscribed to. Stripe won't deliver payment_intent.succeeded to an endpoint that only subscribed to charge.succeeded.

The Tunnel Problem: ngrok Died and You Didn't Notice

In local development, webhooks can't reach localhost directly — the sending service is out on the internet and your laptop is behind a router. You need a tunnel. The most common culprit when a webhook suddenly "stops working" during development isn't your code, it's that the tunnel dropped.

ngrok is the most widely used option. When you restart ngrok on the free plan, you get a new URL. If you registered the old URL with Stripe or GitHub, those requests are now going nowhere. The fix is either to use a paid ngrok account (static domains) or update the webhook URL in the service dashboard every time you restart the tunnel.

Quick check: open the ngrok web interface at http://localhost:4040. It shows every request that came through the tunnel in real time. If you trigger a webhook event and nothing shows up there, the request didn't make it past the service. If it does show up, but your app isn't responding correctly, the problem is in your code.

Stripe CLI is worth mentioning here too. If you're working with Stripe, stripe listen --forward-to localhost:3000/webhooks/stripe is often more reliable than ngrok during development because it handles the tunnel and forwards to whatever port your app is on without needing to re-register URLs.

Tunnel health check:

Run curl http://localhost:4040/api/tunnels to see your active ngrok tunnels in JSON. If it returns an error, ngrok isn't running. If the public URL in the response doesn't match what you registered, that's your problem.

Signature Verification Is Silently Killing Your Requests

If you have HMAC signature verification set up — which you absolutely should in production — a verification failure will typically return a 400 immediately, before any of your actual handler logic runs. This is correct behavior. But it's also the source of a lot of confusing failures, particularly the first time you wire it up.

The most common mistake is verifying the signature against req.body after your JSON body parser has already parsed it. Signature verification must happen against the raw bytes of the request body, before any parsing. The HMAC was computed over the original byte stream. If you parse the JSON first and then re-serialize it, you might get a different string — different whitespace, different key ordering — and the signatures won't match.

In Express, the fix is to use the express.raw() middleware on your webhook route specifically, rather than letting the global express.json() middleware parse it first:

// WRONG — body has already been parsed by the time this runs
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
  // req.body is a JS object here, not the raw buffer
  stripe.webhooks.constructEvent(req.body, sig, secret); // fails
});

// CORRECT — preserve raw bytes for this route
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // req.body is a Buffer here — this is what you need
    const event = stripe.webhooks.constructEvent(req.body, sig, secret);
    // ...
  }
);

If you're using a framework that parses request bodies globally (Next.js API routes, for example), you'll need to disable body parsing for the webhook route and handle it manually. In Next.js App Router, that means using the native Request object: await request.text() or await request.arrayBuffer() — before passing it to the verification function.

URL Problems Are Embarrassingly Common

Trailing slashes, HTTP vs HTTPS, wrong path segment — URL mismatches are responsible for more "the webhook isn't working" tickets than anyone wants to admit. Some servers redirect /webhooks/stripe/ to /webhooks/stripe with a 301. Most webhook senders don't follow redirects. If your server does that redirect, the sender gets a 301, treats it as an error, and retries.

The fastest way to verify reachability is to hit the URL with curl yourself:

curl -v -X POST https://yourapp.com/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

The -v flag in curl gives you the full request/response including redirects. If you see a 301 or 302, that's your problem. If you get back a 404, your path is wrong. A 405 (Method Not Allowed) means the route exists but doesn't accept POST.

Also worth checking: if you're behind a reverse proxy like nginx, make sure the proxy_pass directive is forwarding to the right upstream. Misconfigured proxy rules have a way of eating webhook requests without leaving any obvious trace.

Middleware Eating the Request Before It Reaches Your Handler

Your application code may be perfectly correct, but if the request never makes it to your handler function, it doesn't matter. Middleware runs first, and any of it can short-circuit the request with a non-200 response.

Authentication middleware is the most common culprit. If you have JWT validation or session checking running globally across all routes, and your webhook route is caught by it, the service sending the webhook will get a 401 or 403. It has no auth token — it's not a user. Your webhook routes need to be exempted from authentication middleware.

Body size limits are another one. Express has a default body size limit of 100kb. Some webhook payloads — particularly GitHub push events on a repo with many commits — can exceed that. You'll get a 413 (Payload Too Large), and your handler will never see the request. Bump the limit on your webhook routes if you're receiving large payloads.

CSRF protection is another quiet killer if you're using it. Webhooks aren't browser requests, they don't carry CSRF tokens, and if your CSRF middleware intercepts them you'll get a 403. Exempt your webhook routes.

Quick isolation test

Temporarily add a log statement at the absolute beginning of your route definition, before any middleware that's specific to that route. If the log fires when you send a test request via curl but not when the webhook arrives, the problem is upstream of your route — in global middleware or your server configuration.

The Request Arrives but Nothing Happens

The delivery log shows 200. Your server returned success. But the thing that should have happened — the database update, the email, the Slack notification — didn't. This is the most insidious failure mode because everything looks fine from the outside.

Start by adding a log at the very first line of your handler, before any conditional logic. Something like console.log('Webhook received', req.body.type). If this doesn't appear when you trigger an event, your handler is being bypassed — re-read the middleware section above.

If the log does appear, the problem is inside your handler. Walk through the logic with the actual payload. Are you checking the event type with a condition that might not match? Stripe sends payment_intent.succeeded — if your condition checks for payment.succeeded, nothing will ever match. Log the event type you're actually receiving and compare it against what you're checking for.

Unhandled promise rejections are another common cause. If your handler does async work and an awaited call throws, and you don't have proper error handling, the exception might get swallowed — especially if you've already sent the 200 response. Wrap your async handler logic in a try/catch and log any errors explicitly.

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  // Acknowledge immediately — always
  res.status(200).json({ received: true });

  // Then do your async work — errors here won't affect the response
  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, secret);
    console.log('Processing event:', event.type);

    if (event.type === 'payment_intent.succeeded') {
      await handlePaymentSuccess(event.data.object);
    }
  } catch (err) {
    // Log it — don't silently swallow it
    console.error('Webhook handler error:', err);
  }
});

Replaying Webhooks with curl Once You've Captured a Payload

Once you've captured a real webhook payload — from the delivery log or from ngrok's request inspector — you can replay it locally without needing the live service to send it again. This is invaluable for iterating quickly on your handler code.

Grab the raw JSON body from the delivery log, save it to a file (call it payload.json), and replay it like this:

curl -X POST http://localhost:3000/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=TIMESTAMP,v1=SIGNATURE" \
  -d @payload.json

The @filename syntax in curl reads the body from a file. This means you can iterate on your handler without triggering a real payment or waiting for a real event. For development and testing, this is the fastest feedback loop available.

Before saving the payload as a fixture, it's worth formatting and inspecting it properly. Webhook payloads copied from delivery logs are often minified or hard to read. JsonFormatter.ai is handy for pretty-printing the raw JSON body so you can understand the structure before writing your handler. Paste the payload in, see the full tree, and you'll know exactly which fields to access.

One caveat on signature verification: if you're replaying a captured payload, the original signature was computed at the original timestamp. Most services (Stripe included) reject signatures older than a few minutes as a replay attack prevention measure. During development you can either disable signature checking on your local server, or use the Stripe CLI's replay feature which re-signs with your current test secret.

Keep Reading