Debugging a webhook is supposed to be simple. The provider sends a POST. Your code receives it. You log the payload, you fix the bug, you move on.
In practice it's a parade of small problems: the provider can't reach localhost, you don't know what payload arrives, the signature verification fails for opaque reasons, the event you need to trigger only happens once a week, and you spend an hour chasing a body-parsing bug that turns out to be a Content-Type mismatch.
This is the playbook for debugging webhooks locally without that pain.
The Five Problems
Every local webhook debugging session bumps into the same five problems:
The provider can't reach localhost. You need a public URL.
You don't know what the provider actually sends. Docs lie, fields change.
Signature verification fails. Often a body-parsing issue.
You can't trigger the event on demand. "Charge succeeded" needs a real charge.
You can't replay an event. First-time debugging is wasted.
Each has a clean fix.
Problem 1: The Public URL
Two clean solutions, depending on what you need:
Option A: ngrok / localtunnel
Spin up a tunnel from your laptop to a public URL. Providers POST to the public URL; ngrok forwards to localhost.
ngrok http 3000
→ https://abc-123.ngrok-free.app forwards to localhost:3000
Use this when you're iterating on your real handler.
Option B: Capture-then-replay
Instead of running your handler live, capture the payload with a tool like the YoBox Webhook Tester, then replay it against your local handler with curl or a test script. This decouples seeing the payload from running the handler.
After capturing in YoBox, copy the body to /tmp/payload.json
curl -X POST http://localhost:3000/webhooks/stripe \
-H 'Content-Type: application/json' \
-H 'Stripe-Signature: t=...,v1=...' \
--data-binary @/tmp/payload.json
Use this when you want to iterate fast without re-triggering provider events.
Problem 2: What Does the Provider Actually Send?
Docs are out of date. Schemas change. The only ground truth is what the provider actually sends. Capture it.
Generate a URL in the YoBox Webhook Tester, paste it into the provider's webhook config (Stripe dashboard, GitHub repo settings, etc.), trigger the event, and read the raw request. You'll see:
Full headers (including signature)
Query string
Body (JSON, form-encoded, whatever)
Timing
This is how you discover that "the docs say customer.id but the actual payload puts it at data.object.customer." Don't code against docs; code against captures.
Problem 3: Signature Verification Failing
Free tool
Open Webhook Tester
Capture & inspect HTTP requests in real time.
Open
The single most common local webhook bug is "signature doesn't match." 90% of the time, the cause is the body being parsed before verification.
Most signature schemes (Stripe, GitHub, Shopify, Slack) hash the raw request body. If you have Express's express.json() middleware globally enabled, it parses the body into an object first and the original byte sequence is lost. The hash you compute won't match.
Fix: Use raw body parsing for webhook routes specifically.
// Express
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks/stripe', (req, res) => {
const signature = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, signature, secret);
// ...
});
// Fastify
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
done(null, body);
});
// Next.js App Router
export async function POST(req: Request) {
const rawBody = await req.text(); // Don't JSON.parse first
const signature = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(rawBody, signature, secret);
}
Other signature pitfalls:
Use timing-safe comparison. crypto.timingSafeEqual, not ===.
Reject old timestamps. Most schemes include a timestamp; reject if older than 5 minutes to prevent replay.
Don't trim whitespace. The body is exactly what was sent. Don't normalize it.
Problem 4: Can't Trigger the Event on Demand
Webhook events for things like charge.disputed or subscription.canceled are hard to trigger in real life. Solutions:
Use the provider's CLI / test mode
Stripe: stripe trigger payment_intent.succeeded and many more
GitHub: the "Redeliver" button on past deliveries
Shopify: test webhooks from the admin
Twilio: simulate inbound messages from the console
Replay captured payloads
Capture a real production event (in a test environment) with YoBox Webhook Tester, save the body, replay it against your local handler. You can now iterate on the handler against a real payload without re-triggering it.
Write a payload generator
For events without good triggering, write a small script that constructs the payload your handler expects and POSTs it. Useful in unit tests.
Problem 5: Can't Replay Events
The provider sends the event once. You catch a bug. Now what?
The clean pattern is: capture all events to a log, replay from the log.
In production, every webhook handler should write the raw payload to a queue or log before processing. In development, the YoBox Webhook Tester does this for you — every captured event is browsable and copy-pasteable.
Once you have the payload, replay it as many times as you need:
for i in {1..10}; do
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
--data-binary @/tmp/payload.json
done
Use this to test idempotency: the same payload should produce the same database state, no matter how many times it arrives.
A Full Local Workflow
The workflow we use:
Capture first. Generate a YoBox Webhook Tester URL, paste into provider, trigger event, read payload.
Save the payload to /tmp. This is your fixture.
Write a curl one-liner that POSTs the fixture to your local handler.
Iterate on the handler. Each change, replay the fixture. No re-triggering needed.
Once handler works on fixture, run end-to-end against ngrok. Confirm signature verification works with a real signature.
Run a load test with the curl loop. Confirm idempotency.
Deploy to staging. Configure staging webhook URL. Confirm in real env.
This decouples getting the payload from iterating on the handler, which is the single biggest productivity win.
Local Webhook Debugging Checklist
When something's wrong:
[ ] Is the public URL reachable? (curl https://your-tunnel-url from another machine)
[ ] Did the provider actually send? (Check provider's webhook log)
[ ] What did it send? (Capture in YoBox and inspect)
[ ] Does signature verification pass? (Log raw body length and computed hash)
[ ] Is the body parsed before verification? (Check middleware order)
[ ] Is the handler returning 2xx in under 10s? (Check provider's retry log)
[ ] Is the handler idempotent? (Run the curl loop)
FAQ
Can I debug webhooks without ngrok?
Yes — use YoBox Webhook Tester for capture, then replay payloads against localhost with curl. You only need ngrok if you want the provider to call your real handler live.
Why does Stripe's signature fail on Vercel?
Almost always because Next.js / Express body parsing converts the body before verification. Use raw body in the webhook route.
How long should I keep captured webhooks for debugging?
Save what you need to /tmp or commit fixtures to your repo. Don't rely on capture services for long-term storage.
Can I run webhook debugging in CI?
Yes — use the YoBox Webhook Tester API to capture and assert in tests. See "Webhook Testing Complete Guide".
Is it safe to use a public capture tool with production data?
Avoid it. Production webhooks contain real PII / financial data. Use staging environments or self-hosted capture for production debugging.
Bottom Line
Local webhook debugging is fast when you capture first, replay against fixtures, and verify signatures against the raw body. YoBox Webhook Tester handles the capture; curl handles the replay; your handler handles the work. Don't try to debug live every time — capture once, iterate fast.
YoBox Team
Builder behind YoBox — a privacy-first toolbox for developers and QA engineers covering disposable email, webhook capture, regex, secure passwords, Docker, and end-to-end testing.
Top comments (0)