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:

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:

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:

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:

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:

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:

// 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 Free

Summary

Webhook debugging doesn't have to be painful. Here's the checklist:

  1. Capture the raw payload — don't rely on documentation alone
  2. Verify signatures correctly — use the raw body, not parsed JSON
  3. Make handlers idempotent — always check for duplicate event IDs
  4. Respond quickly — do heavy processing asynchronously
  5. Use proper tooling — a webhook inspection tool saves hours of guesswork
  6. 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.