Why Your Webhook Returns 200 But Still Fails (and How to Actually Debug It)
Webhooks are deceptively simple until they break silently. The provider says "delivered," your endpoint returns 200, and yet the thing that was supposed to happen never happens. This post walks through the real reasons that occurs and a repeatable way to debug it without adding ten more log lines.
The problem, concretely
You wired up a Stripe webhook for checkout.session.completed. In the Stripe dashboard the event shows as delivered. Your handler logs show a clean 200 response. But the order never gets marked paid.
Three things tend to be happening, and all of them are invisible if you only read your own logs:
- Signature verification is silently failing, and your code is catching the error, logging nothing useful, and still returning 200 so the provider stops retrying.
- You verified against the parsed body instead of the raw request bytes. Most frameworks JSON-parse and re-serialize the body before you ever touch it, which changes the bytes and breaks the HMAC.
- The event you think you're handling isn't the one arriving — a different event type, a test-mode key, or a payload shape you didn't expect.
The common thread: you cannot fix what you cannot see. You need eyes on the actual request that hit your server.
Step 1: Capture the raw request
Before touching your handler, capture a real event somewhere you can inspect every byte. Point the webhook at a capture URL, trigger one event, and read the exact headers and body that arrived. Now the Stripe-Signature header, the Content-Type, and the raw payload are all in front of you.
Step 2: Verify against the raw body
The single most common Stripe webhook bug is verifying a re-serialized body. The fix is to hand the verifier the untouched raw bytes:
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Note: express.raw, NOT express.json — you need the original bytes
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
try {
const event = stripe.webhooks.constructEvent(
req.body, // raw Buffer, not a parsed object
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// handle event.type here
return res.json({ received: true });
} catch (err) {
console.error("Signature failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
}
);
If you swap express.raw for express.json, the signature will fail every time, and a careless catch that returns 200 will hide it forever. Logging err.message and returning a non-2xx status is what surfaces the truth.
Step 3: Replay instead of re-triggering
Once you have a captured event, stop creating new test events by hand. Re-issuing a checkout or pushing a commit just to fire a webhook is slow and non-deterministic. Replay the same captured request against your endpoint so the payload and headers are identical on every run. You iterate in seconds, and you can confirm the fix against the exact event that was failing.
Step 4: Simulate edge cases with custom responses
Providers retry on non-2xx. To test your retry handling and idempotency, set a custom response (say, a 500) and watch how your integration behaves when the first delivery "fails." This is how you catch double-processing bugs before they hit production.
Putting it together
The debugging loop is: capture the raw request, verify against the untouched bytes, replay until your handler is green, then simulate failure responses to harden it. You can stitch this together with a throwaway endpoint and a lot of logging, or use a dedicated inspector that gives you an instant capture URL, full header and body inspection, one-click replay, and built-in Stripe and GitHub signature verification in one place. HookLab is one tool that does exactly that, and it's free to try if you want to skip the boilerplate.
Either way, the principle is the same: make the request visible, and the bug stops hiding.
Full disclosure: I build HookLab, a webhook testing tool with instant capture URLs, request inspection, custom responses, replay and signature verification. It is free to try at https://gethooklab.dev.
