Skip to main content

Webhook Debugging and Monitoring: The Complete Troubleshooting Guide

Master webhook debugging with this complete guide covering failure patterns, debugging tools, retry strategies, monitoring dashboards, and security best practices.

E
ECOSIRE Research and Development Team
|March 23, 202614 min read2.7k Words|

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 guide

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. 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

PlatformMax RetriesTimeout WindowBackoff Pattern
Stripe324 hoursExponential
Shopify1948 hoursExponential
GitHub31 hourFixed (10 min)
PayPal153 daysExponential
Twilio1ImmediateNone

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

MetricTargetAlert Threshold
Delivery success rate> 99.5%< 98%
Average processing time< 500ms> 2000ms
Queue depth< 100> 500
Duplicate rate< 5%> 15%
Signature verification failures0> 3/hour
Dead letter queue size0> 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

  1. Verify the cryptographic signature (HMAC-SHA256)
  2. Check the timestamp is within tolerance (5 minutes)
  3. Validate the Content-Type header
  4. Check the payload size is within bounds (reject > 1MB)
  5. Validate the event type is expected
  6. Verify IP address if the sender publishes ranges
  7. 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:

  1. Check the sender's dashboard — Most platforms (Stripe, Shopify, GitHub) show delivery attempts, response codes, and timestamps
  2. Check your server logs — Look for the webhook endpoint access logs in Nginx/Apache
  3. Verify SSL certificate — Expired certs are the number one silent killer
  4. Test with curl — Manually POST to your endpoint from a different network
  5. Check DNS resolution — Ensure your domain resolves correctly from external networks
  6. Verify the webhook secret — Secrets rotate during key changes or environment migrations
  7. Check for payload format changes — API version updates can change webhook payloads
  8. Review recent deployments — Code changes may have broken the handler
  9. Check resource limits — Memory, CPU, database connections
  10. 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:

ECOSIRE builds production-grade webhook integrations that connect Odoo, Shopify, Stripe, and other platforms. Our integration services include monitoring dashboards, dead letter queue handling, and 99.9% delivery guarantees. Talk to our integration engineers.

E

Written by

ECOSIRE Research and Development Team

Technical 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.