Why Webhooks Are Hard to Debug
Debugging a REST API call is straightforward: you control both sides, you can add logging anywhere, and you can reproduce the request on demand. Webhooks are different:
- The sender is a third party — Stripe, GitHub, Shopify. You can't step through their code.
- The trigger is an event — to reproduce it, you often have to perform a real action (place an order, push a commit, make a payment).
- Delivery is asynchronous — by the time you realise something went wrong, the event happened minutes ago and the provider's retry window may have closed.
- Failures are silent — a 500 from your handler doesn't tell you what went wrong; you have to dig through logs that may not even exist yet.
This guide covers each stage of the debugging process: verifying delivery, inspecting payloads, fixing signature errors, handling duplicate events, and replaying past failures. If you also need to forward webhooks to localhost, that's covered in a separate guide.
Step 1 — Verify the Event Was Actually Sent
Before looking at your handler, confirm the provider actually sent the event. Every major webhook provider has a delivery log:
- Stripe: Developers → Webhooks → [your endpoint] → delivery attempts log
- GitHub: Settings → Webhooks → [your webhook] → Recent Deliveries
- Shopify: Partners Dashboard → App → Webhooks → delivery history
The delivery log shows whether the provider sent the event, what HTTP status your endpoint returned, and the full request and response bodies. Start here — before looking at your server logs — to answer the first question: did the event leave the provider?
Common findings at this stage:
- Event not listed — the trigger condition wasn't met (wrong event type selected, filter not matching)
- Event sent, 200 received — provider side is fine; the bug is downstream in your business logic
- Event sent, 4xx received — your handler rejected the event (auth failure, signature error, missing field)
- Event sent, 5xx received — your handler crashed while processing it
- Event sent, timeout — your handler took too long to respond (Stripe times out at 30 seconds, Shopify at 5 seconds)
Step 2 — Inspect the Raw Payload
If your handler is returning an error, you need to see exactly what it received. The fastest way to do this is to use a webhook inspection tool to capture the raw request before your handler processes it.
Create a free endpoint at WebhookWhisper and temporarily point your provider's webhook URL there instead of your handler. Trigger the event again. WebhookWhisper shows you:
- The full raw request body (pretty-printed JSON)
- Every header, including signature headers like
Stripe-Signature,X-Hub-Signature-256,X-Shopify-Hmac-Sha256 - The exact timestamp the event arrived
- The HTTP method (always POST for webhooks)
This gives you the ground truth: exactly what the provider sent, with nothing processed by your code yet. You can now compare this to what your handler is actually receiving.
Alternatively, you can keep your production URL and use WebhookWhisper's forwarding to relay events to your handler and inspect them simultaneously — the event lands in the inspector and in your handler in parallel.
Step 3 — Debug Signature Verification Failures
Signature verification failures are the most common webhook debugging problem. The error usually looks like:
# Stripe
No signatures found matching the expected signature for payload
# GitHub
X-Hub-Signature-256 mismatch
# Shopify
HMAC verification failed
The root causes, in order of frequency:
1. Body was parsed before verification
All webhook signature schemes sign the raw bytes of the request body. If your framework JSON-parses the body before your handler reads it, the bytes may change (key ordering, whitespace) and the signature check fails.
In Express/Node.js, the fix is to apply express.raw() specifically on the webhook route, before any express.json() middleware:
// ✅ Correct — raw body on webhook route
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']
const event = stripe.webhooks.constructEvent(req.body, sig, secret)
// ...
}
)
// ❌ Wrong — global json() middleware runs first
app.use(express.json())
app.post('/webhooks/stripe', (req, res) => {
// req.body is now a JS object, not a Buffer — signature check will fail
})
In FastAPI (Python), use await request.body() rather than await request.json() for the raw bytes.
2. Wrong signing secret
Stripe creates a separate whsec_ signing secret for each webhook endpoint. If you have multiple endpoints (staging, production, local dev) and copy the wrong secret into your environment variables, signature verification will fail silently every time.
Check: the whsec_ value in your STRIPE_WEBHOOK_SECRET env var must match the signing secret shown for the specific endpoint URL you're testing — not any other endpoint, and not your Stripe secret key (sk_test_).
3. Timestamp tolerance exceeded
Stripe includes a timestamp in the Stripe-Signature header and rejects events where the timestamp is more than 5 minutes old (to prevent replay attacks). If you're replaying old events — from Stripe's dashboard retry button or from WebhookWhisper's replay — and your code checks constructEvent() without disabling the tolerance, you'll get a timestamp validation error.
For testing with replayed events, you can temporarily disable the timestamp check:
// Pass 0 to disable timestamp tolerance (testing only — never in production)
stripe.webhooks.constructEvent(payload, sig, secret, 0)
WebhookWhisper's replay function regenerates the request with the current timestamp when forwarding, so this issue doesn't occur when using WebhookWhisper replay.
Step 4 — Handle Duplicate Events
All major webhook providers use at-least-once delivery guarantees. If your endpoint returns a 5xx or times out, the provider retries — which means your handler can receive the same event multiple times.
The symptom: orders fulfilled twice, customers double-charged, emails sent in duplicate.
The fix: make your handler idempotent using the event ID as a deduplication key. Check if you've already processed this event before doing any work:
async function handleStripeEvent(event) {
// Check if we've already processed this event
const existing = await db.webhookEvents.findOne({ stripeEventId: event.id })
if (existing) {
console.log(`Duplicate event ${event.id} — skipping`)
return { received: true } // Return 200 so Stripe stops retrying
}
// Record that we're processing this event
await db.webhookEvents.insert({ stripeEventId: event.id, processedAt: new Date() })
// Now do the actual work
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object)
break
// ...
}
return { received: true }
}
Use the event's unique ID field for deduplication: event.id for Stripe, X-GitHub-Delivery header for GitHub, id field in the payload for Shopify.
Step 5 — Debug Timeout Errors
Providers time out webhook delivery if your endpoint doesn't respond quickly enough:
- Stripe: 30 seconds
- Shopify: 5 seconds
- GitHub: 10 seconds
If your handler does heavy synchronous work — database writes, sending emails, calling external APIs — it will hit these limits under load. The correct pattern is to acknowledge the webhook immediately and do the work asynchronously:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature first
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], secret)
// ✅ Respond immediately — don't wait for processing
res.json({ received: true })
// ✅ Enqueue the work asynchronously
await queue.add('process-stripe-event', { eventId: event.id, type: event.type })
})
WebhookWhisper's delivery log shows the response time for each forwarding attempt, so you can identify slow handlers during testing before they cause timeout issues in production.
Step 6 — Replay Failed Events
Once you've identified and fixed a bug, you need to re-process the events that failed while the bug was present. Two options:
Provider's retry mechanism
Most providers retry failed webhooks automatically for a period of time (Stripe retries over 3 days with exponential backoff). If you fix the bug quickly, some events will be retried and succeed. But you can't control the timing, and events older than the retry window are lost.
WebhookWhisper replay
WebhookWhisper stores every received event for up to 14 days (Starter plan). After fixing your handler, open any past event in the inspector and click Replay. WebhookWhisper re-sends the exact payload (with fresh timestamps) to your handler — you can replay individual events or batch-replay a range.
This is the fastest recovery path: fix the code, restart the server, replay every failed event in one go from the dashboard.
Webhook Debugging Checklist
When a webhook isn't working, go through this list in order:
- ☐ Check the provider's delivery log — was the event sent at all?
- ☐ Check the HTTP status your endpoint returned — 2xx, 4xx, or 5xx?
- ☐ Inspect the raw payload with a webhook inspector — is the data what you expect?
- ☐ Verify you're using the raw request body for signature verification (not parsed JSON)
- ☐ Verify the signing secret matches the specific endpoint, not another one
- ☐ Check for idempotency — are you deduplicating by event ID?
- ☐ Check response time — are you responding before the provider's timeout?
- ☐ After fixing, replay failed events from WebhookWhisper or provider dashboard
Related Guides
- How to Test Stripe Webhooks Without Deploying
- Forward Webhooks to Localhost Without ngrok
- Webhook Provider Directory — Events & Payload Reference
- Best webhook.site Alternative for Debugging
Tools for Webhook Debugging
- WebhookWhisper — public HTTPS URL, real-time inspection, forwarding to localhost, event replay, delivery log
- Provider dashboards (Stripe, GitHub, Shopify) — canonical source of delivery status and retry state
- Server logs — your application's own logging for what happened inside the handler
- curl / HTTPie — for manually constructing and sending test payloads when you know the exact payload shape
The most effective debugging setup is a WebhookWhisper endpoint as the registered URL, with forwarding to your local handler. You get visibility into both what arrived (inspector) and what your handler did with it (delivery log), without deploying anything to a public server.
Set up your free debugging endpoint and have your first event inspected in under a minute.