Table of Contents
What Are Webhooks?
Webhooks are HTTP callbacks — automated POST requests sent from one service to another when an event occurs. Instead of polling an API repeatedly to check for updates, you register a URL and the service pushes data to you in real time.
Common examples include:
- Stripe sending payment confirmation after a successful charge
- GitHub notifying your CI/CD pipeline when code is pushed
- Shopify alerting your warehouse system when an order is placed
- Twilio forwarding incoming SMS messages to your application
Webhooks are the backbone of modern event-driven architectures. But they come with a unique debugging challenge: you don't control the sender.
Why Webhook Debugging Is Hard
Unlike regular API calls where you control both the request and response, webhooks present several challenges:
- You can't see the request easily. The webhook hits your server, but if something goes wrong, you may never see the payload that was sent.
- You can't replay on demand. Most providers send the webhook once (with retries on failure). If you missed it or your handler crashed, you have to trigger the event again.
- Local development is painful. Webhooks require a publicly accessible URL. Your
localhost:3000won't work. - Timing issues. Webhooks are asynchronous. The event might arrive before your system is ready to handle it, or arrive out of order.
- Signature verification is tricky. Most providers sign their payloads with HMAC. Getting the verification right requires exact byte-for-byte payload matching.
Common Webhook Problems
1. Endpoint Returns 500
The most common issue. Your handler throws an exception, the provider sees a 500, and retries the webhook. If the error persists, the provider eventually gives up and your data is lost.
// Common mistake: not handling missing fields
app.post('/webhook', (req, res) => {
const email = req.body.data.customer.email; // TypeError if nested object is null
// ...
});
2. Wrong Content-Type Handling
Some providers send application/json, others send application/x-www-form-urlencoded. If your server only parses one format, you'll get empty or malformed data.
3. Signature Verification Failures
You compute the HMAC but it doesn't match. Common causes:
- Using the parsed JSON body instead of the raw request body
- Wrong encoding (UTF-8 vs. Latin-1)
- Using the wrong secret (test mode vs. live mode)
- Body parser middleware modifying the raw payload before your verification runs
4. Duplicate Event Processing
Providers retry on timeout or 5xx responses. If your handler isn't idempotent, you'll process the same event twice — double-charging customers, sending duplicate emails, or creating duplicate records.
5. Timeout
Most providers expect a response within 5-30 seconds. If your handler does heavy processing synchronously, it'll timeout. The provider sees this as a failure and retries.
Debugging Techniques
Inspect the Raw Payload
The first step in any webhook debugging session: capture and inspect the exact payload. Don't rely on documentation alone — providers change their payloads, and edge cases are rarely documented.
// Quick debug endpoint to log the raw webhook
app.post('/webhook', (req, res) => {
console.log('Headers:', JSON.stringify(req.headers, null, 2));
console.log('Body:', JSON.stringify(req.body, null, 2));
res.status(200).send('OK');
});
Better approach: Use a webhook inspection tool like HookRelay to capture, display, and search through webhook payloads without modifying your code. You get a unique URL that captures every request, showing headers, body, query params, and timing.
Check Provider Logs
Most webhook providers have delivery logs in their dashboard:
- Stripe: Dashboard → Developers → Webhooks → select endpoint → see attempts
- GitHub: Settings → Webhooks → Recent Deliveries
- Shopify: Settings → Notifications → Webhooks
These logs show the exact payload sent, the response your server returned, and the HTTP status code.
Verify Your Endpoint Is Accessible
Before debugging your handler logic, confirm the provider can reach your endpoint:
# Test your endpoint is accessible from the internet
curl -X POST https://your-domain.com/webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'
Testing Webhooks Locally
The classic challenge: webhooks need a public URL, but you're developing on localhost. Here are your options:
Option 1: Tunnel Tools (ngrok, Cloudflare Tunnel)
These create a public URL that forwards to your local machine:
# Using ngrok
ngrok http 3000
# Gives you: https://abc123.ngrok.io -> localhost:3000
Drawbacks: free tiers have rate limits, URLs change on restart, and you need to update the provider's webhook URL every time.
Option 2: Webhook Relay Service
Services like HookRelay give you a permanent endpoint URL. Webhooks are captured in the cloud and can be forwarded to your local machine, replayed on demand, or inspected in a web UI.
# With HookRelay:
# 1. Create an endpoint (one-time setup)
# 2. Point your provider's webhook URL to your HookRelay endpoint
# 3. Webhooks are captured and visible in the dashboard
# 4. Forward to localhost when you're developing
# 5. Replay any captured webhook with one click
Key advantage: Your webhook URL never changes. When you're not developing, webhooks are still captured. When you come back, you can replay them against your updated code.
Option 3: Mock the Provider
Write a script that sends test webhook payloads to your local endpoint:
# Send a test Stripe webhook event
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=123,v1=abc..." \
-d '{
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_123",
"customer_email": "test@example.com"
}
}
}'
This works for basic testing but doesn't verify signature validation or handle edge cases in real payloads.
Signature Verification
Most webhook providers sign payloads using HMAC-SHA256. Here's how to verify signatures correctly:
Stripe Signature Verification
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
try {
// IMPORTANT: use req.body (raw buffer), not parsed JSON
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
// Fulfill the purchase
break;
}
res.json({ received: true });
} catch (err) {
console.error('Signature verification failed:', err.message);
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
Generic HMAC Verification
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8') // Use the RAW body string
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
);
}
Critical: Always use crypto.timingSafeEqual() instead of === for signature comparison. String equality is vulnerable to timing attacks that can leak the expected signature byte by byte.
Replaying Failed Webhooks
When a webhook fails, you need to reprocess it. Some approaches:
- Provider retry: Most providers retry automatically (Stripe retries for up to 3 days). But you can't control the timing.
- Manual replay: Use provider dashboards to resend specific events. Stripe and GitHub both support this.
- Capture and replay: Tools like HookRelay store every webhook payload. You can replay any historical webhook with one click, as many times as you need.
The capture-and-replay approach is particularly valuable during development: trigger the webhook once from the provider, then replay it dozens of times as you iterate on your handler.
Production Monitoring
Debugging in production is different from development. You need visibility without disrupting the live system:
- Log every webhook — at minimum, log the event type, a unique identifier, and the processing result
- Track processing time — slow handlers lead to timeouts and retries
- Monitor failure rates — a sudden spike in 4xx/5xx responses means something is wrong
- Implement idempotency — store processed event IDs and skip duplicates
- Use a dead letter queue — events that fail repeatedly should be captured for manual review
// Idempotent webhook handler
app.post('/webhook', async (req, res) => {
const eventId = req.body.id;
// Check if already processed
const existing = await db.get(
'SELECT id FROM processed_events WHERE event_id = ?',
eventId
);
if (existing) {
return res.status(200).json({ status: 'already_processed' });
}
// Process the event
await handleEvent(req.body);
// Mark as processed
await db.run(
'INSERT INTO processed_events (event_id) VALUES (?)',
eventId
);
res.status(200).json({ status: 'processed' });
});
Webhook Debugging Tools
Here's a comparison of popular approaches:
| Tool | Inspect | Replay | Local Forward | Persistent URL |
|---|---|---|---|---|
| HookRelay | Yes | Yes | Yes | Yes |
| ngrok | Basic | Paid only | Yes | Paid only |
| RequestBin | Yes | No | No | No |
| Console.log | Basic | No | N/A | N/A |
Stop guessing. Start seeing your webhooks.
HookRelay captures every webhook in real time. Inspect payloads, replay failed deliveries, and forward to localhost — all from one dashboard.
Get Started FreeSummary
Webhook debugging doesn't have to be painful. Here's the checklist:
- Capture the raw payload — don't rely on documentation alone
- Verify signatures correctly — use the raw body, not parsed JSON
- Make handlers idempotent — always check for duplicate event IDs
- Respond quickly — do heavy processing asynchronously
- Use proper tooling — a webhook inspection tool saves hours of guesswork
- Monitor in production — track failure rates and processing times
Whether you're integrating Stripe payments, GitHub CI/CD, or any other webhook-based service, having visibility into what's being sent to your endpoint is the difference between hours of frustration and minutes of productive debugging.