A 2025 Merge.dev survey found that 62% of API integration failures originate from webhook delivery issues, yet only 23% of engineering teams have dedicated webhook monitoring in place.
Part of our Performance & Scalability series
Read the complete guideA 2025 Merge.dev survey found that 62% of API integration failures originate from webhook delivery issues, yet only 23% of engineering teams have dedicated webhook monitoring in place. Webhooks are deceptively simple — an HTTP POST from one system to another — but in production, they fail in subtle and frustrating ways.
This guide covers the entire webhook lifecycle: from understanding why webhooks fail, to building robust debugging workflows, to implementing production-grade monitoring that catches issues before your users do.
Key Takeaways
- Webhooks fail silently — unlike API calls where your code gets an error response, webhook failures happen on the sender's side and your system never knows the request was attempted unless you have monitoring.
- The five most common webhook failures are: endpoint unreachable (DNS/network), SSL certificate issues, timeout (processing took too long), incorrect payload parsing, and signature verification failure.
- Idempotency is mandatory — webhooks can be delivered more than once due to retries, so your handler must produce the same result whether it processes a payload once or ten times.
- Signature verification using HMAC-SHA256 is the industry standard for webhook security — never process unverified payloads in production.
- Structured logging and alerting catch issues within minutes, while dead letter queues ensure no event is permanently lost.
1. Webhook Architecture Fundamentals
Before debugging, understand how webhooks flow through systems:
┌──────────┐ HTTP POST ┌──────────────┐ Queue ┌──────────────┐
│ Source │ ──────────────────>│ Your Server │ ────────────> │ Processor │
│ (Stripe, │ Headers + JSON │ (Receiver) │ Async job │ (Handler) │
│ Shopify) │ │ │ │ │
└──────────┘ └──────────────┘ └──────────────┘
│ │ │
│ Expects 2xx within │ Verify signature │ Business logic
│ 5-30 seconds │ Parse payload │ Database writes
│ │ Enqueue for processing │ Trigger side effects
│ Retries on failure │ Return 200 immediately │ Log completion
└────────────────────────────────┘ └──────────────────
Critical design principle: Acknowledge the webhook (return 200) as fast as possible, then process it asynchronously. Most webhook senders have aggressive timeouts (5-30 seconds) and will retry if your endpoint does not respond in time.
2. The Five Most Common Webhook Failures
Failure 1: Endpoint Unreachable
# Symptoms: Sender shows "connection refused" or "DNS resolution failed"
# Common causes:
# - Firewall blocking the sender's IP range
# - DNS misconfiguration after domain migration
# - Load balancer health check failing
# - Server crashed or not started
# Diagnostic steps:
# 1. Test connectivity from outside your network
curl -X POST https://your-domain.com/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"test": true}' \
-v # Verbose output shows connection details
# 2. Check DNS resolution
nslookup your-domain.com
dig your-domain.com +short
# 3. Check if the port is listening
nc -zv your-domain.com 443
# 4. Check firewall rules (if you have server access)
sudo ufw status
sudo iptables -L -n | grep 443
Failure 2: SSL Certificate Issues
# Symptoms: "SSL handshake failed", "certificate expired", "self-signed cert"
# Webhook senders REQUIRE valid SSL certificates
# Check certificate expiry
echo | openssl s_client -servername your-domain.com -connect your-domain.com:443 2>/dev/null | openssl x509 -noout -dates
# Check full certificate chain
openssl s_client -connect your-domain.com:443 -showcerts < /dev/null 2>/dev/null
# Common fix: renew Let's Encrypt certificate
# sudo certbot renew --force-renewal
# sudo systemctl reload nginx
Failure 3: Timeout
# Symptoms: Sender retries because your endpoint took too long
# Solution: Acknowledge immediately, process asynchronously
# BAD: Processing inline (may take 10+ seconds)
@app.route('/webhooks/stripe', methods=['POST'])
def bad_webhook_handler():
event = parse_stripe_event(request)
process_payment(event) # Database queries, external API calls
send_confirmation_email(event) # SMTP call
update_inventory(event) # More database queries
return jsonify({'status': 'ok'}), 200 # Too late — Stripe already timed out
# GOOD: Acknowledge immediately, process in background
@app.route('/webhooks/stripe', methods=['POST'])
def good_webhook_handler():
# Verify signature FIRST (fast)
verify_stripe_signature(request)
# Enqueue for background processing
task_queue.enqueue('process_stripe_event', request.json)
# Return 200 within milliseconds
return jsonify({'status': 'received'}), 200
Failure 4: Payload Parsing Errors
# Symptoms: 400/500 errors, "unexpected token", type errors
# Cause: Assumptions about payload structure that break with API updates
# BAD: Assuming structure
def process_order(payload):
customer_email = payload['data']['object']['customer']['email'] # KeyError!
# GOOD: Defensive parsing
def process_order(payload):
try:
data = payload.get('data', {})
obj = data.get('object', {})
customer = obj.get('customer', {})
# Customer might be a string (ID) or an object
if isinstance(customer, str):
customer_email = None # Need to fetch from API
else:
customer_email = customer.get('email')
if not customer_email:
logger.warning(f"No customer email in webhook: {payload.get('id')}")
return
except Exception as e:
logger.exception(f"Failed to parse webhook payload: {e}")
raise
Failure 5: Signature Verification Failure
import hmac
import hashlib
def verify_stripe_signature(request):
"""Verify Stripe webhook signature."""
payload = request.data # Raw bytes, NOT parsed JSON
sig_header = request.headers.get('Stripe-Signature', '')
webhook_secret = os.environ['STRIPE_WEBHOOK_SECRET']
# Parse the signature header
elements = dict(item.split('=', 1) for item in sig_header.split(','))
timestamp = elements.get('t')
signature = elements.get('v1')
if not timestamp or not signature:
raise ValueError('Missing signature components')
# Compute expected signature
signed_payload = f'{timestamp}.{payload.decode("utf-8")}'
expected = hmac.new(
webhook_secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError('Signature verification failed')
# Check timestamp freshness (prevent replay attacks)
import time
tolerance = 300 # 5 minutes
if abs(time.time() - int(timestamp)) > tolerance:
raise ValueError('Webhook timestamp too old')
Common signature verification mistakes:
- Parsing the JSON before verifying (alters the raw body)
- Using the wrong secret (test vs live mode)
- Not using constant-time comparison (
hmac.compare_digest) - Framework middleware modifying the request body before your handler sees it
3. Debugging Tools
ngrok — Expose Local Endpoints
# Install and expose local port
ngrok http 3001
# Output:
# Forwarding https://abc123.ngrok-free.app -> http://localhost:3001
# Use this URL as your webhook endpoint in the sender's dashboard
# ngrok provides a web inspector at http://127.0.0.1:4040
# - See every request/response pair
# - Replay failed webhooks
# - Inspect headers and bodies
webhook.site — Quick Testing
# 1. Go to https://webhook.site — get a unique URL
# 2. Configure that URL as your webhook endpoint
# 3. Trigger events and see payloads in real-time
# 4. Copy the payload format for your handler development
curl — Manual Testing
# Simulate a Stripe checkout.session.completed webhook
curl -X POST http://localhost:3001/api/billing/webhook \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1616161616,v1=abc123..." \
-d '{
"id": "evt_test_123",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_456",
"customer": "cus_test_789",
"amount_total": 4999,
"currency": "usd",
"metadata": {
"product_id": "42",
"user_id": "7"
}
}
}
}'
# Watch for the response status and body
Structured Logging
import structlog
import json
from datetime import datetime
logger = structlog.get_logger()
def log_webhook_event(request, response_status, processing_time_ms, error=None):
"""Log every webhook with full context for debugging."""
log_data = {
'event': 'webhook_received',
'timestamp': datetime.utcnow().isoformat(),
'source': detect_webhook_source(request),
'event_type': request.json.get('type', 'unknown'),
'event_id': request.json.get('id', 'unknown'),
'method': request.method,
'path': request.path,
'content_length': request.content_length,
'response_status': response_status,
'processing_time_ms': processing_time_ms,
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent', ''),
}
if error:
log_data['error'] = str(error)
log_data['error_type'] = type(error).__name__
logger.error('webhook_failed', **log_data)
else:
logger.info('webhook_processed', **log_data)
4. Retry Strategies
Most webhook senders implement automatic retries with exponential backoff. Stripe retries up to 3 times over 24 hours. Shopify retries up to 19 times over 48 hours. Your system should be designed to handle duplicate deliveries through idempotency keys, and you should implement your own retry queue for processing failures so that transient errors like database timeouts do not permanently lose events.
Sender Retry Policies
| Platform | Max Retries | Timeout Window | Backoff Pattern |
|---|---|---|---|
| Stripe | 3 | 24 hours | Exponential |
| Shopify | 19 | 48 hours | Exponential |
| GitHub | 3 | 1 hour | Fixed (10 min) |
| PayPal | 15 | 3 days | Exponential |
| Twilio | 1 | Immediate | None |
Building Your Own Retry Queue
// Node.js retry queue with exponential backoff
const Bull = require('bull');
const webhookQueue = new Bull('webhooks', {
redis: { host: 'localhost', port: 6379 },
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
removeOnComplete: 100,
removeOnFail: false, // Keep failed jobs for analysis
},
});
// Producer: enqueue webhook for processing
async function enqueueWebhook(eventType, payload, source) {
await webhookQueue.add(eventType, {
payload,
source,
receivedAt: new Date().toISOString(),
idempotencyKey: payload.id || `${source}-${Date.now()}`,
});
}
// Consumer: process webhooks
webhookQueue.process('checkout.session.completed', async (job) => {
const { payload, idempotencyKey } = job.data;
// Check idempotency
const processed = await redis.get(`webhook:${idempotencyKey}`);
if (processed) {
console.log(`Skipping duplicate webhook: ${idempotencyKey}`);
return { status: 'duplicate', key: idempotencyKey };
}
try {
// Process the event
await handleCheckoutCompleted(payload);
// Mark as processed (TTL 48h)
await redis.setex(`webhook:${idempotencyKey}`, 172800, 'processed');
return { status: 'success' };
} catch (error) {
// Will be retried automatically by Bull
throw error;
}
});
// Dead letter handler
webhookQueue.on('failed', (job, error) => {
if (job.attemptsMade >= job.opts.attempts) {
console.error(`Webhook permanently failed after ${job.attemptsMade} attempts:`, {
eventType: job.name,
idempotencyKey: job.data.idempotencyKey,
error: error.message,
});
// Send alert to Slack/PagerDuty
alertService.sendCritical('Webhook permanently failed', {
job: job.id,
event: job.name,
error: error.message,
});
}
});
5. Idempotency Implementation
import hashlib
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def process_webhook_idempotently(event_id, event_type, payload, handler_fn):
"""Ensure a webhook is processed exactly once."""
# Create idempotency key
idem_key = f"webhook:processed:{event_id}"
# Check if already processed (atomic operation)
if redis_client.exists(idem_key):
logger.info(f"Skipping duplicate webhook: {event_id}")
return {'status': 'duplicate'}
# Set a processing lock (prevents concurrent processing)
lock_key = f"webhook:lock:{event_id}"
lock_acquired = redis_client.set(lock_key, '1', nx=True, ex=60)
if not lock_acquired:
logger.warning(f"Webhook already being processed: {event_id}")
return {'status': 'in_progress'}
try:
# Process the event
result = handler_fn(event_type, payload)
# Mark as processed (keep for 48 hours)
redis_client.setex(idem_key, 172800, json.dumps({
'processed_at': datetime.utcnow().isoformat(),
'result': str(result),
}))
return {'status': 'processed', 'result': result}
except Exception as e:
# Release lock so it can be retried
redis_client.delete(lock_key)
raise
finally:
redis_client.delete(lock_key)
6. Monitoring Dashboard
Key Metrics to Track
| Metric | Target | Alert Threshold |
|---|---|---|
| Delivery success rate | > 99.5% | < 98% |
| Average processing time | < 500ms | > 2000ms |
| Queue depth | < 100 | > 500 |
| Duplicate rate | < 5% | > 15% |
| Signature verification failures | 0 | > 3/hour |
| Dead letter queue size | 0 | > 10 |
Prometheus Metrics Example
from prometheus_client import Counter, Histogram, Gauge
# Counters
webhook_received_total = Counter(
'webhook_received_total',
'Total webhooks received',
['source', 'event_type']
)
webhook_processed_total = Counter(
'webhook_processed_total',
'Total webhooks successfully processed',
['source', 'event_type']
)
webhook_failed_total = Counter(
'webhook_failed_total',
'Total webhook processing failures',
['source', 'event_type', 'error_type']
)
webhook_duplicate_total = Counter(
'webhook_duplicate_total',
'Duplicate webhook deliveries skipped',
['source']
)
# Histograms
webhook_processing_duration = Histogram(
'webhook_processing_duration_seconds',
'Time to process a webhook',
['source', 'event_type'],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
# Gauges
webhook_queue_depth = Gauge(
'webhook_queue_depth',
'Current number of webhooks waiting to be processed',
['source']
)
Health Check Endpoint
// NestJS health check example
@Controller('health')
export class HealthController {
@Get('webhooks')
async webhookHealth() {
const stats = await this.webhookService.getStats();
const healthy = stats.failureRate < 0.02
&& stats.queueDepth < 500
&& stats.avgProcessingTimeMs < 2000
&& stats.deadLetterCount < 10;
return {
status: healthy ? 'healthy' : 'degraded',
metrics: {
totalReceived24h: stats.totalReceived,
successRate: `${((1 - stats.failureRate) * 100).toFixed(2)}%`,
avgProcessingTimeMs: stats.avgProcessingTimeMs,
queueDepth: stats.queueDepth,
deadLetterCount: stats.deadLetterCount,
duplicateRate: `${(stats.duplicateRate * 100).toFixed(2)}%`,
lastReceivedAt: stats.lastReceivedAt,
},
};
}
}
7. Security Best Practices
IP Whitelisting
# Nginx configuration for webhook endpoints
location /api/webhooks/stripe {
# Only allow Stripe's IP ranges
# https://stripe.com/docs/ips
allow 3.18.12.63;
allow 3.130.192.0/24;
allow 13.235.14.0/24;
allow 18.211.135.0/24;
allow 35.154.171.0/24;
deny all;
proxy_pass http://localhost:3001;
}
Request Validation Checklist
- Verify the cryptographic signature (HMAC-SHA256)
- Check the timestamp is within tolerance (5 minutes)
- Validate the Content-Type header
- Check the payload size is within bounds (reject > 1MB)
- Validate the event type is expected
- Verify IP address if the sender publishes ranges
- Rate limit the endpoint (prevent abuse)
def validate_webhook_request(request):
"""Comprehensive webhook request validation."""
errors = []
# 1. Content-Type
if request.content_type != 'application/json':
errors.append(f'Invalid Content-Type: {request.content_type}')
# 2. Payload size (max 1MB)
if request.content_length and request.content_length > 1_048_576:
errors.append(f'Payload too large: {request.content_length} bytes')
# 3. Required headers
if not request.headers.get('X-Webhook-Signature'):
errors.append('Missing signature header')
# 4. Valid JSON
try:
payload = request.json
except Exception:
errors.append('Invalid JSON payload')
return errors
# 5. Required fields
if 'type' not in payload:
errors.append('Missing event type')
if 'id' not in payload:
errors.append('Missing event ID')
return errors
8. Debugging Checklist
When a webhook stops working, follow this systematic approach:
- Check the sender's dashboard — Most platforms (Stripe, Shopify, GitHub) show delivery attempts, response codes, and timestamps
- Check your server logs — Look for the webhook endpoint access logs in Nginx/Apache
- Verify SSL certificate — Expired certs are the number one silent killer
- Test with curl — Manually POST to your endpoint from a different network
- Check DNS resolution — Ensure your domain resolves correctly from external networks
- Verify the webhook secret — Secrets rotate during key changes or environment migrations
- Check for payload format changes — API version updates can change webhook payloads
- Review recent deployments — Code changes may have broken the handler
- Check resource limits — Memory, CPU, database connections
- Test the full processing pipeline — Verify database writes, queue processing, side effects
Frequently Asked Questions
How do I test webhooks in local development?
Use ngrok or a similar tunneling tool to expose your local server to the internet. Run 'ngrok http 3001' to get a public URL that forwards to localhost:3001. Configure this URL as the webhook endpoint in the sender's dashboard. The ngrok web inspector at localhost:4040 lets you inspect every request and replay failed deliveries. For CI/CD, use mock servers or record-replay patterns instead.
What should my webhook endpoint return?
Return a 200 status code as quickly as possible — within 2-5 seconds. The response body is typically ignored by the sender, but a simple JSON like {"status":"received"} is good practice. Return 200 even if you plan to process the event asynchronously. Only return 4xx if the signature is invalid or the payload is malformed. Never return 5xx for business logic errors — log them and process in a retry queue instead.
How do I handle webhook events that arrive out of order?
Events may arrive out of order because of retries and network conditions. Include a timestamp or sequence number check in your handler. For Stripe, use the event's created timestamp. For Shopify, check the updated_at field. If an event arrives that refers to a state your system has not seen yet, either queue it for later processing or fetch the current state from the sender's API to reconcile. Idempotency keys prevent duplicate processing regardless of order.
What is the best way to monitor webhook reliability?
Track four key metrics: delivery success rate (target above 99.5%), average processing time (target below 500 milliseconds), queue depth (target below 100), and dead letter queue size (target zero). Set up alerts for when any metric crosses its threshold. Use Prometheus with Grafana for dashboards, or a managed service like Datadog or New Relic. Also monitor the sender's webhook dashboard for delivery failures you may not see in your own logs.
How do I prevent webhook replay attacks?
Implement three defenses: First, verify the cryptographic signature on every request using HMAC-SHA256 with a shared secret. Second, check the timestamp in the signed payload and reject events older than 5 minutes (Stripe includes this in their signature scheme). Third, use idempotency keys to ensure each event is processed exactly once, even if an attacker replays a valid signed request within the timestamp window.
Next Steps
Webhook reliability is the foundation of event-driven integrations. The patterns in this guide — signature verification, idempotent processing, async queues, structured monitoring — apply regardless of which platforms you are integrating.
Related resources:
- Odoo REST API Tutorial — API integration with Odoo
- ECOSIRE Marketplace Connectors — Pre-built Odoo integrations
- Best ERP for E-commerce 2026 — Integration-ready ERP comparison
ECOSIRE builds production-grade webhook integrations that connect Odoo, Shopify, Stripe, and dozens of other platforms. Our integration services include monitoring dashboards, dead letter queue handling, and 99.9% delivery guarantees. Talk to our integration engineers.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
eMAG Odoo Integration: Connect Romania's Largest Marketplace to Your ERP (Orders, Stock, e-Factura)
Connect eMAG Marketplace to Odoo ERP: offer and order sync, AWB shipping, returns, stock and price updates, plus Romanian e-Factura compliance for sellers.
Shopify-Odoo Deep Integration 2026: Inventory, Orders, Accounting Sync
Architect a production Shopify-Odoo connector: bi-directional inventory, order sync, accounting integration, multi-warehouse, returns, idempotent processing.
Shopify Webhooks 2026: HMAC, Retries, Idempotency in Production
Build reliable Shopify webhook receivers: HMAC verification, retry strategies, idempotency, dead-letter queues, and at-least-once processing patterns.
More from Performance & Scalability
Shopify Speed Optimization: A Technical Checklist That Actually Moves Core Web Vitals (2026)
A field-tested Shopify speed checklist for 2026 — what actually improves LCP, INP, and CLS on real stores, what wastes time, and how to audit apps and themes.
Technical SEO Audit Checklist 2026: 47 Checks We Run on Every Client Site
The 47-point technical SEO audit checklist we run on every client site in 2026 — crawlability, indexation, canonicals, hreflang, Core Web Vitals, and logs.
Odoo 19 HR: Skills Matrix, Career Plans, Performance Cycles
Odoo 19 HR upgrade: native skills matrix, career path planning, performance review cycles, 9-box grid, succession planning, HRIS integration.
Odoo 19 Performance Benchmarks: PostgreSQL 17 Tuning Numbers
Real-world Odoo 19 performance benchmarks: web client speed, ORM throughput, PG17 tuning settings, connection pooling, worker counts, scaling thresholds.
OpenClaw Cost Optimization and Token Efficiency at Scale
OpenClaw token cost optimization: prompt caching, model routing, response caching, batch APIs, and per-tenant cost guardrails for production agents.
Power BI Incremental Refresh for Tables Over 10 Million Rows
Power BI Incremental Refresh playbook for 10M+ row tables: partition design, RangeStart/RangeEnd, refresh policies, query folding, and DirectQuery hybrids.