How to Debug Failed Webhooks
A webhook isn't arriving. Maybe it never was. Maybe it stopped working after a deploy. Maybe it works for some events but not others. You're staring at your logs and the webhook provider's dashboard, and neither is telling you enough.
Debugging webhooks is frustrating because the failure can be on the sender's side, the receiver's side, or somewhere in the network between them — and you often only control one end. This guide gives you a systematic approach to finding and fixing the problem, with concrete tools and techniques for each failure mode.
Step 1: Confirm the Webhook Was Actually Sent
Before debugging your receiver, verify the sender actually fired the event. This sounds obvious, but it eliminates an entire category of problems upfront.
Check the provider's dashboard. Most webhook providers (Stripe, GitHub, Shopify, etc.) have a webhook delivery log in their dashboard showing every event they attempted to send, the response they received, and whether they're planning to retry. Start here.
Check the provider's event log. Sometimes the event itself was never created. If you're expecting a payment.completed webhook but the payment actually failed, there's no webhook to debug. Verify the triggering action actually occurred.
Check your webhook configuration. Verify the endpoint URL is correct (watch for trailing slashes, http vs https, typos in the path). Verify the correct event types are subscribed. Verify the webhook is active and not paused or disabled.
If the provider shows the webhook was sent and received a non-2xx response (or timed out), the problem is on your end. Move to Step 2.
If the provider shows no delivery attempts, the event either wasn't created or your webhook subscription is misconfigured. Fix the configuration and trigger a test event.
Step 2: Reproduce Locally
The fastest way to debug a webhook is to see the exact request hitting your code. There are several tools that make this easy.
Use a Request Inspector
Services like RequestBin, Webhook.site, or ngrok's built-in inspector let you see the exact headers and body of incoming requests.
Temporarily point your webhook URL at one of these tools to verify the sender is sending what you expect. Compare the actual payload structure to what you're trying to parse.
Tunnel to Localhost
For interactive debugging, use a tunnel to route webhooks to your local development server:
# ngrok
ngrok http 3000
# Your webhook URL becomes: https://abc123.ngrok.io/webhooks
# localtunnel
lt --port 3000
# Your webhook URL becomes: https://random-slug.loca.lt
Update the webhook URL in the provider's settings to your tunnel URL, trigger an event, and watch it hit your local server with full debugger access.
Replay with cURL
If you have the payload from the provider's delivery log, replay it locally:
curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: v1=abc123..." \
-H "X-Webhook-Timestamp: 1700000000" \
-d '{"event":"payment.completed","data":{"id":"pay_123","amount":5000}}'
This lets you iterate quickly without triggering real events every time. For signature verification debugging, you'll need to either compute a valid signature with your secret or temporarily disable verification in your dev environment.
Step 3: Check the Common Failure Modes
Most webhook failures fall into a handful of categories. Work through these in order.
Your Server Is Returning an Error
Check your application logs for exceptions or errors on the webhook endpoint. The most common issues:
JSON parsing errors. Your code expects a specific structure but the payload is different. Maybe the provider changed their schema, or you're handling a different event type than expected.
// Fragile — breaks if structure changes
const amount = req.body.data.object.amount; // TypeError if any level is missing
// Defensive — handle missing fields gracefully
const amount = req.body?.data?.object?.amount;
if (amount === undefined) {
console.error('Unexpected payload structure:', JSON.stringify(req.body));
return res.status(200).json({ received: true }); // still acknowledge
}
Unhandled event types. If you subscribe to all events but only handle payment.completed, make sure unknown event types return 200 instead of throwing an error. The provider will keep retrying if you return 500 for events you don't care about.
app.post('/webhooks', (req, res) => {
const event = req.body;
switch (event.type) {
case 'payment.completed':
handlePaymentCompleted(event);
break;
case 'payment.failed':
handlePaymentFailed(event);
break;
default:
// Don't throw — just acknowledge unknown events
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
});
Database errors. Your handler tries to insert a record but hits a constraint violation, connection timeout, or deadlock. Check your database logs alongside your application logs.
Signature Verification Is Failing
This is extremely common after initial setup or when rotating secrets. Symptoms: your endpoint returns 401 or 403 for every webhook.
Verify you're using the raw body, not parsed JSON. This is the most common cause. See our webhook security guide for details, but the short version: if your framework parses JSON and you re-serialize it for verification, whitespace and key ordering differences will break the signature.
// Wrong — re-serialized JSON may differ from what was signed
const body = JSON.stringify(req.body);
// Right — use the raw bytes as received
const body = req.rawBody;
Verify you're using the correct secret. If you have multiple webhook endpoints or environments, it's easy to mix up secrets. Log the first 4 characters of the secret you're using (never log the full secret) and compare it to what's configured on the provider's side.
Check the signing algorithm. Some providers use HMAC-SHA256, others use HMAC-SHA1, and some use RSA signatures. Make sure your verification code matches.
Check the signed content format. Providers sign different things. Some sign just the body. Others sign timestamp.body. Others sign webhook_id.timestamp.body. Check the provider's documentation for the exact format.
Your Endpoint Is Timing Out
The provider sends the webhook, waits for a response, and gives up after their timeout (typically 5–30 seconds). Your server is processing the event but takes too long to respond.
The fix is to always acknowledge immediately and process asynchronously:
// Wrong — processing before responding
app.post('/webhooks', async (req, res) => {
await processPayment(req.body); // takes 10+ seconds
await updateDatabase(req.body);
await sendConfirmationEmail(req.body);
res.status(200).json({ received: true }); // too late, provider already timed out
});
// Right — acknowledge first, process in background
app.post('/webhooks', async (req, res) => {
res.status(200).json({ received: true }); // respond immediately
// Process in background (or better: push to a queue)
try {
await processPayment(req.body);
await updateDatabase(req.body);
await sendConfirmationEmail(req.body);
} catch (err) {
console.error('Webhook processing failed:', err);
}
});
For production systems, push the event to a message queue (Redis, SQS, RabbitMQ) from the webhook handler and process it in a separate worker. This decouples receipt from processing entirely.
SSL/TLS Issues
If the provider can't establish a TLS connection to your endpoint, the webhook will fail before your application code is ever reached.
Common causes: expired SSL certificate, self-signed certificate (most providers reject these), misconfigured certificate chain (intermediate certificates missing), or your server only supports outdated TLS versions.
Check your certificate from the outside:
# Check certificate validity and chain
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com
# Quick check for expiration
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates
DNS Issues
If the provider can't resolve your hostname, the webhook fails at the DNS level. This won't show up in your server logs because the request never reaches your server.
# Verify DNS resolution
dig yourdomain.com
nslookup yourdomain.com
# Check from multiple locations using online tools
# (mxtoolbox.com, whatsmydns.net)
Recent DNS changes (migration, new subdomain) can take time to propagate. If you recently changed DNS records, the provider's DNS cache might still have old values.
Firewall or WAF Blocking
Your Web Application Firewall or hosting provider's firewall might be blocking the webhook requests. Symptoms: some webhooks work (from your own testing) but the provider's attempts are blocked.
Check your WAF rules and firewall logs. Common causes: the provider's IP addresses aren't allowlisted, rate limiting is triggering on webhook retries, or the WAF is flagging webhook payloads as suspicious (especially if they contain HTML or script-like content).
Most providers publish their sending IP ranges. Allowlist these in your firewall.
Load Balancer Health Checks
If your load balancer marks your webhook-handling instances as unhealthy, it may return 502 or 503 to the webhook provider. Check your load balancer's target health and access logs.
Step 4: Debug with Request/Response Logging
If you've ruled out the obvious causes, add detailed logging around your webhook handler to capture exactly what's happening:
app.post('/webhooks', (req, res) => {
const startTime = Date.now();
console.log(JSON.stringify({
type: 'webhook_received',
method: req.method,
path: req.path,
headers: {
'content-type': req.headers['content-type'],
'x-webhook-signature': req.headers['x-webhook-signature'] ? '[present]' : '[missing]',
'x-webhook-id': req.headers['x-webhook-id'],
'user-agent': req.headers['user-agent'],
'content-length': req.headers['content-length'],
},
body_preview: JSON.stringify(req.body).substring(0, 500),
raw_body_length: req.rawBody?.length,
}));
try {
// Your processing logic here
processEvent(req.body);
const duration = Date.now() - startTime;
console.log(JSON.stringify({
type: 'webhook_processed',
webhook_id: req.headers['x-webhook-id'],
duration_ms: duration,
status: 200,
}));
res.status(200).json({ received: true });
} catch (err) {
const duration = Date.now() - startTime;
console.error(JSON.stringify({
type: 'webhook_error',
webhook_id: req.headers['x-webhook-id'],
error: err.message,
stack: err.stack,
duration_ms: duration,
}));
// Still return 200 if possible to prevent unnecessary retries
// Only return 500 if you WANT the provider to retry
res.status(500).json({ error: 'Internal processing error' });
}
});
This gives you a complete picture: what came in, how long processing took, and exactly where it failed.
Step 5: Verify End-to-End with a Test Event
Most webhook providers offer a way to send test events. Use this to verify your full pipeline works:
- Send a test event from the provider's dashboard
- Verify it arrives at your endpoint (check your logs)
- Verify signature verification passes
- Verify your processing logic handles it correctly
- Verify the provider shows a successful delivery (2xx response)
If the test event works but real events don't, the problem is likely in your processing logic for specific event types, or there's a payload structure difference between test and real events.
Debugging Checklist
Work through this list systematically when a webhook isn't working:
- Provider's dashboard shows the webhook was sent
- Endpoint URL is correct (protocol, domain, path, no typos)
- Correct event types are subscribed
- Webhook is active, not paused or disabled
- SSL certificate is valid and chain is complete
- DNS resolves correctly from external networks
- Firewall / WAF isn't blocking the provider's IPs
- Server is returning 2xx within the timeout window
- Signature verification uses raw body, not re-serialized JSON
- Correct secret is configured (compare first 4 chars)
- Signing algorithm matches the provider's documentation
- Handler returns 200 for unknown event types
- Processing is async (not blocking the response)
- Database operations in the handler aren't failing silently
- Load balancer is routing to healthy instances
- Application logs show the request arriving
Prevention: Avoiding Webhook Failures Before They Happen
Once you've fixed the immediate issue, set yourself up to catch problems faster next time:
Monitor delivery health. Track success rates and alert when they drop. See our monitoring guide for a detailed setup.
Use idempotent processing. Handle duplicate deliveries gracefully so that retries don't cause double-processing.
Return 200 quickly. Always acknowledge receipt before doing heavy processing. Push work to a background queue.
Rotate secrets proactively. Don't wait for a secret to be compromised. Rotate on a schedule and support dual active secrets during transitions.
Test regularly. Periodically send test events through your production webhook endpoints to verify they're still working. Don't wait for customers to tell you.
Skip the Debugging Entirely
Most webhook debugging comes down to: "what did the sender send, what did my server do with it, and where did it break?" WebhookStream gives you a complete delivery log with every request and response, real-time failure alerts, and one-click replay for failed events — so you spend less time debugging and more time building.