Webhooks are fundamentally harder to debug than REST APIs. With a REST call, you initiate the request — you control the timing, you can retry it, you can inspect the full round-trip in a browser's network tab. With webhooks, the third-party service initiates the request. Your server is the receiver.
That asymmetry creates a specific problem during development: your localhost:3000 is not reachable from Stripe's servers, GitHub's push event system, or Shopify's order hooks.
The traditional solution is ngrok — a tunneling tool that creates a public URL forwarding to your local machine. It works, but it has friction: an account, a client binary, expiring URLs on the free tier, and a rotating domain that breaks any webhook registration that hardcodes the URL.
This guide covers the alternatives.
What You Actually Need When Debugging a Webhook
Before choosing a tool, get specific about what you're trying to do:
Scenario A: Inspect the raw payload You want to see exactly what headers, body, and HTTP method a service sends. You don't need to process it yet — you just need to capture it.
Scenario B: Test your handler locally
You have a webhook handler at localhost:3000/webhook. You want to receive a real payload from Stripe/GitHub and run your actual code against it.
Scenario C: Replay a specific event Something broke in production. You want to replay the exact payload that caused the failure against a local or staging handler.
These are different problems requiring different tools.
Option 1: Browser-Based Webhook Inspector (No Install)
For Scenario A — inspecting raw payloads — a browser-based webhook inspector is the fastest path. Generate a unique URL, configure it as your webhook endpoint in the external service, and watch requests appear in real-time.
DeveloperLab's Webhook Tester does this without any account or install:
- Click Generate Temporary Webhook URL — you get a URL like
https://developerlab.dev/bucket/abc123xyz - Paste that URL into your service's webhook settings (Stripe, GitHub, Shopify, etc.)
- Trigger an event (complete a test payment, push a commit, create an order)
- The full request appears in your browser: HTTP method, all headers, complete JSON body, query params, response time
The URL stays active for 4 hours and accepts GET, POST, PUT, PATCH, and DELETE. No tunnel, no binary, no expiring token.
What to look at first when a payload arrives:
Content-Type: confirms the body format (application/json,application/x-www-form-urlencoded)X-Signature/X-Hub-Signature-256/Stripe-Signature: the HMAC signature you'll need to verifyUser-Agent: identifies the sending service and its version- Body structure: shapes your data model before writing a single line of handler code
Option 2: Local Tunnel with a Persistent Domain
When you need your actual application code to run against the payload (Scenario B), you need a publicly accessible URL that forwards to localhost.
Cloudflare Tunnel (free, no account required for quick tunnels)
# One-liner — no binary install required
npx cloudflared tunnel --url http://localhost:3000
You get a public trycloudflare.com URL that forwards to port 3000. It's free, doesn't require a Cloudflare account, and the URL is stable for the session.
For a persistent named tunnel (free Cloudflare account required):
cloudflared tunnel create my-webhook-tunnel
cloudflared tunnel route dns my-webhook-tunnel webhooks.yourdomain.com
cloudflared tunnel run my-webhook-tunnel
This gives you a stable, permanently named URL — ideal for webhooks in staging environments where rotating URLs would require re-registration.
Localtunnel (open source)
npx localtunnel --port 3000 --subdomain myapp-webhooks
# → https://myapp-webhooks.loca.lt
Fully open source. Subdomain is first-come-first-served, so it may be taken. Less reliable uptime than Cloudflare Tunnel.
Smee.io (GitHub-specific)
If you're debugging GitHub webhooks specifically, smee.io is purpose-built for it:
npm install -g smee-client
smee --url https://smee.io/your-channel --path /webhook --port 3000
Smee acts as a relay — events are buffered and replayed, so you can restart your local server without missing events.
Option 3: Replay with Saved Payloads
For Scenario C — replaying specific events — the best approach is to capture the raw payload once, save it, and use curl or a test helper to replay it against your handler.
Step 1: Capture the real payload
Use a webhook inspector (Option 1) to capture the exact headers and body of a real event. Copy the raw JSON body and save it to a file:
# stripe-payment-success.json
{
"id": "evt_1234",
"type": "payment_intent.succeeded",
"data": {
"object": {
"amount": 4200,
"currency": "usd",
"status": "succeeded"
}
}
}
Step 2: Replay against your local handler
curl -X POST http://localhost:3000/webhook/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1720000000,v1=fake-sig-for-testing" \
-d @stripe-payment-success.json
For testing with real signature verification disabled, you can temporarily accept all signatures in test mode. For testing with real signatures, Stripe's stripe-cli can generate valid test signatures.
Using stripe-cli for Stripe webhooks
# Install once
brew install stripe/stripe-cli/stripe
# Forward to local server with real signature injection
stripe listen --forward-to localhost:3000/webhook/stripe
# Trigger specific events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
The Stripe CLI injects real Stripe-Signature headers, so your HMAC verification code runs as it would in production.
Inspecting Webhook Signatures
Most services sign their payloads using HMAC-SHA256. The signature is in a request header. Here's how the major services format theirs:
| Service | Header | Format |
|---|---|---|
| Stripe | Stripe-Signature |
t=timestamp,v1=signature |
| GitHub | X-Hub-Signature-256 |
sha256=signature |
| Shopify | X-Shopify-Hmac-Sha256 |
Base64-encoded signature |
| Twilio | X-Twilio-Signature |
Base64-encoded HMAC |
| Slack | X-Slack-Signature |
v0=signature |
A robust Node.js signature verifier for GitHub:
import { createHmac, timingSafeEqual } from 'crypto';
export function verifyGitHubSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`;
// timingSafeEqual prevents timing attacks
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// In your Express handler:
app.post('/webhook/github', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-hub-signature-256'] as string;
const body = req.body.toString();
if (!verifyGitHubSignature(body, sig, process.env.GITHUB_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(body);
// process event...
res.status(200).json({ ok: true });
});
Important: Use express.raw() rather than express.json() for webhook handlers. HMAC is computed over the raw request body bytes. If Express parses and re-serializes the JSON, the bytes change and signature verification fails.
Debugging Checklist
When a webhook isn't arriving or is failing verification, work through this list:
Not arriving at all:
- Is the endpoint URL publicly accessible? (Test with
curlfrom a remote machine) - Is the service sending to the correct URL? (Check the service's delivery log)
- Is a firewall or security group blocking inbound requests?
- Did the service's delivery attempt get a non-2xx response and stop retrying?
Arriving but signature fails:
- Are you reading the raw body bytes, not parsed JSON? (
express.raw(), notexpress.json()) - Is the webhook secret correct? (Compare with what's in the service dashboard)
- Are you using
timingSafeEqualto compare, not===? - Is the timestamp within the tolerance window? (Stripe rejects signatures older than 5 minutes)
Arriving but payload looks wrong:
- Use a webhook inspector to capture the raw headers + body before your handler touches it
- Check
Content-Type— some services sendapplication/x-www-form-urlencoded, not JSON - Is the body nested under a different key? (Shopify wraps payloads, Stripe has a
data.objectstructure)
Storing Payloads for Future Replay
In staging environments, it's worth logging every incoming webhook payload to a database or structured log before processing it. This gives you a permanent replay library:
app.post('/webhook/:provider', express.raw({ type: '*/*' }), async (req, res) => {
// 1. Log raw payload first
await db.webhookLog.create({
provider: req.params.provider,
headers: JSON.stringify(req.headers),
body: req.body.toString(),
receivedAt: new Date(),
});
// 2. Then process
await processWebhook(req);
res.status(200).json({ ok: true });
});
Combine this with a simple admin endpoint to replay any stored event against your current handler code — you've built a lightweight webhook replay system without any external tooling.