Webhooks are powerful tools for real-time integrations, but when they stop working, the debugging process can be frustrating. Unlike traditional API calls where you control when and how requests are made, webhooks are triggered by external services at unpredictable times, making issues harder to reproduce and diagnose.
This comprehensive guide walks you through the most common webhook problems and provides a systematic approach to debugging them. Whether you're dealing with signature verification failures, missing webhooks, timeouts, or mysterious errors, you'll learn proven techniques to identify and resolve issues quickly.
Understanding Webhook Debugging
Debugging webhooks requires a different mindset than debugging traditional code. You're dealing with:
- External triggers: You can't control when webhooks arrive
- Network complexity: Issues can occur at multiple layers (DNS, SSL, firewalls, load balancers)
- Asynchronous processing: Problems may not surface immediately
- Limited visibility: You often can't see what the provider is sending until it arrives
- Time sensitivity: Many webhook payloads include timestamps that must be validated
The key to effective webhook debugging is visibility. You need to see what's being sent, how your system is responding, and where the process breaks down.
Essential Debugging Tools
Request Inspection Tools
ngrok is the gold standard for webhook debugging during development. It creates a secure tunnel to your localhost, providing a public URL that forwards requests to your local server:
# Start ngrok tunnel
ngrok http 3000
# Copy the https URL (e.g., https://abc123.ngrok.io)
# Use this URL as your webhook endpoint
ngrok's web interface (http://127.0.0.1:4040) shows every incoming request with full headers, body, and response details.
webhook.site is perfect for quick testing without running a local server. It provides a unique URL that captures and displays all incoming webhook requests, allowing you to inspect payloads without writing any code.
RequestBin and Hookdeck offer similar functionality with additional features like request replay, payload transformation, and webhook routing.
Logging Strategies
Comprehensive logging is crucial for debugging webhooks. Here's a production-ready logging implementation:
// middleware/webhook-logger.js
import crypto from 'crypto';
export function webhookLogger(req, res, next) {
const webhookId = crypto.randomUUID();
const startTime = Date.now();
// Log incoming request
console.log(JSON.stringify({
type: 'webhook_received',
webhookId,
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
headers: sanitizeHeaders(req.headers),
bodySize: req.body ? JSON.stringify(req.body).length : 0,
ip: req.ip,
userAgent: req.get('user-agent')
}));
// Capture response
const originalSend = res.send;
res.send = function(data) {
const duration = Date.now() - startTime;
console.log(JSON.stringify({
type: 'webhook_processed',
webhookId,
timestamp: new Date().toISOString(),
statusCode: res.statusCode,
duration,
responseSize: data ? data.length : 0
}));
originalSend.call(this, data);
};
next();
}
function sanitizeHeaders(headers) {
const sanitized = { ...headers };
const sensitiveHeaders = ['authorization', 'x-api-key', 'x-webhook-signature'];
sensitiveHeaders.forEach(header => {
if (sanitized[header]) {
sanitized[header] = '[REDACTED]';
}
});
return sanitized;
}
Provider Dashboards
Most webhook providers offer dashboards showing webhook delivery attempts:
- Stripe: Dashboard > Developers > Webhooks > [your endpoint] shows recent deliveries
- GitHub: Settings > Webhooks > [your webhook] > Recent Deliveries
- Shopify: Settings > Notifications > Webhooks shows delivery status
- PayPal: Developer Dashboard > My Apps & Credentials > Webhooks
These dashboards typically show:
- Request payload sent
- Response received
- HTTP status code
- Delivery timestamps
- Retry attempts
Network Debugging Tools
curl is essential for manual webhook testing:
# Send test webhook with signature
curl -X POST https://your-api.com/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1234567890,v1=signature_here" \
-d '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_123"}}}'
# Test with verbose output to see full request/response
curl -v -X POST https://your-api.com/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"type":"test","data":{}}'
Postman provides a graphical interface for testing webhooks with features like environment variables, pre-request scripts, and test assertions.
Common Webhook Issues and Solutions
Issue 1: Signature Verification Fails
Signature verification is the most common webhook debugging challenge.
Cause 1: Incorrect Secret Key
Using the wrong signing secret is surprisingly common, especially when managing multiple environments.
// Solution: Environment-specific secrets
const getWebhookSecret = (provider, environment) => {
const secrets = {
stripe: {
development: process.env.STRIPE_WEBHOOK_SECRET_DEV,
production: process.env.STRIPE_WEBHOOK_SECRET_PROD
},
github: {
development: process.env.GITHUB_WEBHOOK_SECRET_DEV,
production: process.env.GITHUB_WEBHOOK_SECRET_PROD
}
};
const secret = secrets[provider]?.[environment];
if (!secret) {
throw new Error(`Missing webhook secret for ${provider} in ${environment}`);
}
return secret;
};
Cause 2: Wrong Hashing Algorithm
Different providers use different algorithms (HMAC-SHA256, SHA256, SHA1).
// Solution: Provider-specific verification
const verifySignature = (provider, payload, signature, secret) => {
switch (provider) {
case 'stripe':
// Stripe uses HMAC-SHA256 with timestamp
return verifyStripeSignature(payload, signature, secret);
case 'github':
// GitHub uses HMAC-SHA256
const computed = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return `sha256=${computed}` === signature;
case 'shopify':
// Shopify uses HMAC-SHA256 with base64 encoding
const computedShopify = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('base64');
return computedShopify === signature;
default:
throw new Error(`Unknown provider: ${provider}`);
}
};
Cause 3: Timestamp Window Expired
Some providers include timestamps in signatures to prevent replay attacks.
// Solution: Check timestamp tolerance
const verifyStripeSignature = (payload, signatureHeader, secret) => {
const [timestamp, signature] = signatureHeader.split(',')
.reduce((acc, pair) => {
const [key, value] = pair.split('=');
if (key === 't') acc[0] = value;
if (key === 'v1') acc[1] = value;
return acc;
}, []);
// Stripe recommends 5-minute tolerance
const TOLERANCE = 300; // 5 minutes in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
if (currentTimestamp - timestamp > TOLERANCE) {
throw new Error('Webhook timestamp too old');
}
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
};
Cause 4: Payload Encoding Issues
Always use the raw request body for signature verification, not the parsed JSON.
// Solution: Capture raw body
import express from 'express';
const app = express();
// Capture raw body for webhook routes
app.use('/webhooks', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
// Verify using raw body
app.post('/webhooks/stripe', (req, res) => {
const signature = req.headers['stripe-signature'];
try {
verifyStripeSignature(
req.rawBody, // Use raw body, not req.body
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
// Process webhook
res.json({ received: true });
} catch (err) {
console.error('Signature verification failed:', err);
res.status(400).json({ error: 'Invalid signature' });
}
});
Cause 5: Character Encoding Mismatches
Ensure consistent encoding (usually UTF-8) across your entire webhook pipeline.
// Solution: Explicit encoding
const verifySignature = (payload, signature, secret) => {
const computed = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8') // Explicit UTF-8 encoding
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(computed, 'utf8')
);
};
Issue 2: Webhooks Timing Out
Webhook providers typically timeout after 5-30 seconds if your endpoint doesn't respond.
Cause 1: Synchronous Processing
Processing webhooks synchronously blocks the response.
// Solution: Async processing with immediate response
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks', {
connection: { host: 'localhost', port: 6379 }
});
app.post('/webhooks/stripe', async (req, res) => {
// Verify signature first
verifySignature(req.rawBody, req.headers['stripe-signature']);
// Respond immediately
res.json({ received: true });
// Queue for background processing
await webhookQueue.add('process-stripe-webhook', {
type: req.body.type,
data: req.body.data,
id: req.body.id,
receivedAt: new Date().toISOString()
});
});
Cause 2: Slow Database Queries
Complex queries or missing indexes can cause timeouts.
// Solution: Optimize queries and use indexes
// Bad: Full table scan
const order = await db.orders.findOne({
stripePaymentIntent: event.data.object.id
});
// Good: Indexed lookup
const order = await db.orders.findOne({
stripePaymentIntent: event.data.object.id
}, {
index: 'stripePaymentIntent_idx'
});
Cause 3: External API Calls
Calling external APIs from webhook handlers adds latency and failure points.
// Solution: Defer external calls to background jobs
app.post('/webhooks/order', async (req, res) => {
const { orderId, status } = req.body;
// Respond immediately
res.json({ received: true });
// Queue external API calls
if (status === 'paid') {
await Promise.all([
emailQueue.add('send-confirmation', { orderId }),
analyticsQueue.add('track-conversion', { orderId }),
inventoryQueue.add('update-stock', { orderId })
]);
}
});
Cause 4: Insufficient Server Resources
Overloaded servers respond slowly or crash under webhook load.
// Solution: Implement rate limiting and load shedding
import rateLimit from 'express-rate-limit';
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests',
standardHeaders: true,
legacyHeaders: false
});
app.use('/webhooks', webhookLimiter);
// Load shedding when queue is full
app.post('/webhooks/stripe', async (req, res) => {
const queueSize = await webhookQueue.count();
if (queueSize > 10000) {
// Server overloaded, accept but delay processing
console.warn('Webhook queue overloaded:', queueSize);
return res.status(503).json({
error: 'Service temporarily unavailable',
retryAfter: 60
});
}
// Normal processing
res.json({ received: true });
await webhookQueue.add('process', req.body);
});
Issue 3: Not Receiving Webhooks
The most frustrating issue is when webhooks simply never arrive.
Cause 1: Endpoint Not Publicly Accessible
Your endpoint must be reachable from the internet.
# Test from external location
curl https://your-api.com/webhooks/test
# Common issues:
# - Running on localhost without tunnel
# - Firewall blocking requests
# - VPN/private network restrictions
# - Wrong port configuration
Cause 2: Incorrect Webhook Configuration
Double-check your webhook settings in the provider's dashboard.
// Solution: Configuration validation endpoint
app.get('/webhooks/validate', (req, res) => {
res.json({
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
accessible: true,
timestamp: new Date().toISOString(),
headers: req.headers
});
});
// Visit this endpoint to verify your URL is correct
Cause 3: Wrong HTTP Method
Most webhooks use POST, but some providers support other methods.
// Solution: Accept multiple methods if needed
app.all('/webhooks/custom', (req, res) => {
console.log(`Received ${req.method} request`);
if (req.method === 'GET') {
// Some providers use GET for verification
return res.send(req.query.challenge || 'OK');
}
if (req.method === 'POST') {
// Process webhook
return res.json({ received: true });
}
res.status(405).json({ error: 'Method not allowed' });
});
Cause 4: SSL Certificate Issues
Many providers require valid SSL certificates.
# Test your SSL certificate
curl -v https://your-api.com/webhooks/test
# Check certificate validity
openssl s_client -connect your-api.com:443 -servername your-api.com
Cause 5: Returning Wrong Status Code
Providers expect 2xx status codes to consider delivery successful.
// Solution: Always return 200 for successful receipt
app.post('/webhooks/stripe', async (req, res) => {
try {
verifySignature(req.rawBody, req.headers['stripe-signature']);
await webhookQueue.add('process', req.body);
// Always 200, even if queuing fails
res.status(200).json({ received: true });
} catch (err) {
// Only 4xx/5xx for actual errors
if (err.message.includes('signature')) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Default to 500 for unexpected errors
res.status(500).json({ error: 'Internal server error' });
}
});
Cause 6: Webhook Disabled or Expired
Some providers automatically disable webhooks after repeated failures.
// Solution: Monitor webhook health
const checkWebhookHealth = async (provider) => {
const recentFailures = await db.webhookLogs.count({
provider,
status: 'failed',
timestamp: { $gte: new Date(Date.now() - 3600000) } // Last hour
});
if (recentFailures > 10) {
console.error(`High webhook failure rate for ${provider}: ${recentFailures}`);
// Alert operations team
}
return { provider, failures: recentFailures, healthy: recentFailures < 10 };
};
Issue 4: Duplicate Processing
Processing the same webhook multiple times can cause serious issues.
Cause 1: No Idempotency Check
Without idempotency checks, retries cause duplicate processing.
// Solution: Store and check webhook IDs
const processWebhook = async (webhookEvent) => {
const { id, type, data } = webhookEvent;
// Check if already processed
const existing = await db.processedWebhooks.findOne({ webhookId: id });
if (existing) {
console.log(`Duplicate webhook ignored: ${id}`);
return { status: 'duplicate', webhookId: id };
}
// Process webhook
const result = await processWebhookData(type, data);
// Mark as processed
await db.processedWebhooks.insertOne({
webhookId: id,
type,
processedAt: new Date(),
result
});
return { status: 'processed', webhookId: id, result };
};
Cause 2: Race Conditions
Multiple webhook deliveries arriving simultaneously.
// Solution: Distributed locking
import Redlock from 'redlock';
const redlock = new Redlock([redisClient]);
const processWebhookWithLock = async (webhookEvent) => {
const lockKey = `webhook:${webhookEvent.id}`;
try {
// Acquire lock for 5 seconds
const lock = await redlock.acquire([lockKey], 5000);
try {
return await processWebhook(webhookEvent);
} finally {
await lock.release();
}
} catch (err) {
if (err.name === 'LockError') {
console.log(`Webhook ${webhookEvent.id} already being processed`);
return { status: 'locked', webhookId: webhookEvent.id };
}
throw err;
}
};
Cause 3: Database Constraint Issues
Missing unique constraints allow duplicate records.
-- Solution: Add unique constraint on webhook ID
CREATE UNIQUE INDEX idx_webhook_id ON processed_webhooks(webhook_id);
-- Ensure atomicity with upsert
INSERT INTO processed_webhooks (webhook_id, type, processed_at)
VALUES (?, ?, NOW())
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id;
Issue 5: Missing Data in Payload
Sometimes webhook payloads don't contain expected data.
Cause 1: Provider API Version Mismatch
Payload structure can change between API versions.
// Solution: Defensive data access with fallbacks
const extractOrderData = (payload, apiVersion) => {
// Handle different API versions
if (apiVersion === '2023-10-16') {
return {
orderId: payload.data?.object?.id,
amount: payload.data?.object?.amount_total,
currency: payload.data?.object?.currency
};
}
// Legacy format
return {
orderId: payload.order?.id || payload.id,
amount: payload.order?.total || payload.amount,
currency: payload.order?.currency || payload.currency || 'usd'
};
};
Cause 2: Webhook Event Type Limitations
Some event types include less data than others.
// Solution: Fetch additional data when needed
const enrichWebhookData = async (webhookEvent) => {
if (webhookEvent.type === 'payment_intent.succeeded') {
const paymentIntent = webhookEvent.data.object;
// Partial data in webhook, fetch full details
if (!paymentIntent.customer_details) {
const fullPaymentIntent = await stripe.paymentIntents.retrieve(
paymentIntent.id,
{ expand: ['customer'] }
);
return { ...webhookEvent, data: { object: fullPaymentIntent } };
}
}
return webhookEvent;
};
Cause 3: Optional Fields Not Included
Many fields are optional and may be null or undefined.
// Solution: Validate and provide defaults
import Joi from 'joi';
const webhookSchema = Joi.object({
id: Joi.string().required(),
type: Joi.string().required(),
data: Joi.object({
object: Joi.object({
id: Joi.string().required(),
amount: Joi.number().positive().required(),
currency: Joi.string().default('usd'),
customer: Joi.string().optional(),
metadata: Joi.object().optional()
}).required()
}).required()
});
const validateWebhook = (payload) => {
const { error, value } = webhookSchema.validate(payload, {
stripUnknown: true,
abortEarly: false
});
if (error) {
throw new Error(`Invalid webhook payload: ${error.message}`);
}
return value;
};
Issue 6: SSL/TLS Errors
SSL issues prevent webhook providers from reaching your endpoint.
Cause 1: Self-Signed Certificate
Most providers reject self-signed certificates.
# Solution: Use Let's Encrypt for free valid certificates
certbot certonly --standalone -d your-api.com
# Or use Cloudflare for automatic SSL
Cause 2: Expired Certificate
Certificates expire and must be renewed.
// Solution: Monitor certificate expiration
import https from 'https';
import tls from 'tls';
const checkCertificateExpiry = (hostname) => {
return new Promise((resolve, reject) => {
const socket = tls.connect(443, hostname, { rejectUnauthorized: false }, () => {
const cert = socket.getPeerCertificate();
socket.end();
if (!cert || !cert.valid_to) {
return reject(new Error('No certificate found'));
}
const expiryDate = new Date(cert.valid_to);
const daysUntilExpiry = Math.floor(
(expiryDate - new Date()) / (1000 * 60 * 60 * 24)
);
resolve({ expiryDate, daysUntilExpiry, valid: daysUntilExpiry > 0 });
});
socket.on('error', reject);
});
};
// Check daily and alert if expiring soon
setInterval(async () => {
const cert = await checkCertificateExpiry('your-api.com');
if (cert.daysUntilExpiry < 30) {
console.warn(`SSL certificate expiring in ${cert.daysUntilExpiry} days`);
}
}, 86400000); // 24 hours
Cause 3: Incomplete Certificate Chain
Missing intermediate certificates cause validation failures.
# Check certificate chain
openssl s_client -connect your-api.com:443 -showcerts
# Ensure your server sends the full chain
# nginx example:
ssl_certificate /path/to/fullchain.pem; # Not just cert.pem
ssl_certificate_key /path/to/privkey.pem;
Cause 4: Outdated TLS Version
Some servers use outdated TLS versions that providers reject.
// Solution: Configure minimum TLS version
import https from 'https';
const server = https.createServer({
minVersion: 'TLSv1.2',
maxVersion: 'TLSv1.3',
cert: fs.readFileSync('/path/to/cert.pem'),
key: fs.readFileSync('/path/to/key.pem')
}, app);
Step-by-Step Debugging Workflow
When a webhook issue occurs, follow this systematic debugging process:
Step 1: Verify the provider is sending webhooks
Check the provider's dashboard to confirm delivery attempts are being made.
Step 2: Confirm your endpoint is accessible
Use curl or Postman to send a test request from an external location.
Step 3: Check server logs
Look for incoming requests, errors, and response status codes.
Step 4: Inspect the request
Use ngrok or webhook.site to capture the actual request being sent.
Step 5: Test signature verification
Comment out signature verification temporarily to isolate authentication issues.
Step 6: Validate payload structure
Print the full payload and compare it to the provider's documentation.
Step 7: Test processing logic
Process the webhook manually using the captured payload to identify logic errors.
Step 8: Monitor response time
Use APM tools or logging to identify slow operations causing timeouts.
Step 9: Check for errors in background jobs
If using async processing, check your queue for failed jobs.
Step 10: Review recent changes
Correlate issues with recent deployments or configuration changes.
Logging Best Practices
What to Log
Log essential information for debugging without exposing sensitive data:
const logWebhook = (req, result) => {
const log = {
// Identification
webhookId: result.webhookId,
provider: req.headers['user-agent'],
// Request details
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
ip: req.ip,
// Headers (sanitized)
headers: {
contentType: req.headers['content-type'],
contentLength: req.headers['content-length'],
hasSignature: !!req.headers['x-webhook-signature']
},
// Payload info (not full payload)
payloadType: req.body?.type,
payloadId: req.body?.id,
payloadSize: JSON.stringify(req.body || {}).length,
// Processing result
status: result.status,
processingTime: result.duration,
error: result.error?.message
};
// Don't log full payloads or sensitive data
console.log(JSON.stringify(log));
};
Log Levels
Use appropriate log levels for different scenarios:
- DEBUG: Detailed request/response data (development only)
- INFO: Successful webhook processing
- WARN: Retries, duplicate webhooks, non-critical issues
- ERROR: Processing failures, signature verification failures
- FATAL: System-level failures affecting all webhooks
PII Redaction
Always redact personally identifiable information:
const redactSensitiveData = (payload) => {
const sensitiveFields = [
'email', 'phone', 'ssn', 'credit_card',
'password', 'api_key', 'access_token'
];
const redacted = JSON.parse(JSON.stringify(payload));
const redactObject = (obj) => {
for (const key in obj) {
if (sensitiveFields.some(field => key.toLowerCase().includes(field))) {
obj[key] = '[REDACTED]';
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
redactObject(obj[key]);
}
}
};
redactObject(redacted);
return redacted;
};
Structured Logging
Use structured logs for better querying and analysis:
import winston from 'winston';
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'webhooks.log' })
]
});
logger.info('Webhook processed', {
webhookId: 'evt_123',
provider: 'stripe',
type: 'payment_intent.succeeded',
duration: 125,
status: 'success'
});
Testing Strategies
Manual Testing
Create test scripts for common scenarios:
// test-webhook.js
import fetch from 'node-fetch';
import crypto from 'crypto';
const sendTestWebhook = async (payload, secret) => {
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
const response = await fetch('https://your-api.com/webhooks/stripe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Stripe-Signature': `t=${timestamp},v1=${signature}`
},
body
});
console.log('Status:', response.status);
console.log('Response:', await response.text());
};
// Run test
sendTestWebhook({
id: 'evt_test',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_test', amount: 1000 } }
}, process.env.STRIPE_WEBHOOK_SECRET);
Automated Testing
Use provider-specific testing tools:
# Stripe CLI for testing
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
Integration Tests
Test your webhook handlers with real payloads:
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from '../app';
describe('Stripe webhook handler', () => {
it('processes payment_intent.succeeded', async () => {
const payload = {
id: 'evt_test',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test',
amount: 2000,
currency: 'usd'
}
}
};
const signature = generateTestSignature(payload);
const response = await request(app)
.post('/webhooks/stripe')
.set('Stripe-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
expect(response.body).toEqual({ received: true });
// Verify processing
const order = await db.orders.findOne({ stripePaymentIntent: 'pi_test' });
expect(order.status).toBe('paid');
});
});
Monitoring and Alerting
Set up proactive monitoring to catch issues before they become critical:
// Monitor webhook processing rates
const monitorWebhookHealth = async () => {
const now = new Date();
const hourAgo = new Date(now - 3600000);
const stats = await db.webhookLogs.aggregate([
{ $match: { timestamp: { $gte: hourAgo } } },
{
$group: {
_id: { provider: '$provider', status: '$status' },
count: { $sum: 1 },
avgDuration: { $avg: '$duration' }
}
}
]);
for (const stat of stats) {
const { provider, status } = stat._id;
const { count, avgDuration } = stat;
// Alert on high failure rates
if (status === 'failed' && count > 10) {
console.error(`High failure rate for ${provider}: ${count} failures in last hour`);
}
// Alert on slow processing
if (avgDuration > 1000) {
console.warn(`Slow webhook processing for ${provider}: ${avgDuration}ms average`);
}
}
};
// Run every 5 minutes
setInterval(monitorWebhookHealth, 300000);
Debugging Checklist
Use this checklist when troubleshooting webhook issues:
Configuration:
- Webhook endpoint URL is correct in provider dashboard
- Webhook is enabled and active
- Correct events are selected
- Environment (test/production) matches configuration
Network & Infrastructure:
- Endpoint is publicly accessible from internet
- DNS resolves correctly
- SSL certificate is valid and not expired
- Firewall allows incoming HTTPS traffic
- Load balancer routes to correct backend
- CDN/proxy doesn't interfere with webhooks
Authentication:
- Using correct webhook signing secret
- Signature verification algorithm matches provider
- Using raw request body for verification
- Timestamp tolerance is appropriate
- Character encoding is consistent
Processing:
- Endpoint responds with 2xx status code
- Response is sent within timeout window (5-30s)
- Idempotency check prevents duplicates
- Heavy processing is deferred to background jobs
- Database queries are optimized
- External API calls are async
Logging & Monitoring:
- Comprehensive logs capture all webhook activity
- Sensitive data is redacted from logs
- Structured logging enables easy querying
- Error tracking captures exceptions
- Success/failure rates are monitored
- Alerts notify team of issues
Testing:
- Manual testing works with curl/Postman
- Provider's test webhooks are received
- Integration tests cover key scenarios
- Signature verification tested separately
Next Steps
Webhook debugging becomes easier with experience and the right tools. To continue improving your webhook implementations:
- Test locally with ngrok: Set up a local webhook testing environment to iterate quickly
- Implement comprehensive logging: Add structured logging to capture all webhook activity
- Create a test suite: Build integration tests for all webhook event types you handle
- Set up monitoring: Track webhook success rates and processing times
- Document your integration: Maintain runbooks for common debugging scenarios
For more webhook resources:
- Webhook Testing Strategies - Build reliable webhook test suites
- Provider-Specific Webhook Guides - Detailed guides for popular webhook providers
- Webhook Payload Generator - Generate test payloads for development
Conclusion
Debugging webhooks doesn't have to be painful. With the right tools, systematic processes, and comprehensive logging, you can identify and resolve webhook issues quickly. Remember that prevention is better than debugging: invest time in robust error handling, thorough testing, and proactive monitoring to catch issues before they impact users.
The key to successful webhook debugging is visibility. Make sure you can see what's being sent, how your system responds, and where failures occur. With these capabilities in place, debugging becomes a matter of following the trail from external trigger to final processing, identifying the exact point where things break down.
Start with the most common issues (signature verification, timeouts, accessibility), follow the debugging workflow systematically, and leverage provider-specific tools to inspect delivery attempts. Most webhook problems fall into a handful of categories, and with practice, you'll quickly recognize patterns and know exactly where to look.

