Best Practices 9 min read

Webhook Testing Strategies: From Development to Production

Testing webhooks is notoriously difficult. Learn practical strategies for testing webhook handlers locally, in CI, and in production without breaking anything.

H

HookWatch Team

February 5, 2026

Webhooks are hard to test. You can't easily simulate an incoming webhook from Stripe or GitHub during development, and testing in production risks processing fake events. Here's how to build a comprehensive testing strategy.

The Testing Pyramid for Webhooks

Code
         ╱╲
        ╱  ╲       Production monitoring
       ╱────╲      (Real webhooks, alerts)
      ╱      ╲
     ╱────────╲    Integration tests
    ╱          ╲   (End-to-end with mock sender)
   ╱────────────╲
  ╱              ╲  Unit tests
 ╱────────────────╲ (Handler logic, signature verification)

Level 1: Unit Testing Webhook Handlers

Start by testing your handler logic in isolation:

Javascript
const { processOrderWebhook } = require('./handlers');

describe('Order Webhook Handler', () => {
  it('should create an order from webhook payload', async () => {
    const payload = {
      id: 'evt_123',
      type: 'order.created',
      data: {
        id: 'order_456',
        customer_email: 'test@example.com',
        total_price: '49.99',
        line_items: [
          { title: 'Widget', quantity: 2, price: '24.99' }
        ]
      }
    };

    const result = await processOrderWebhook(payload);

    expect(result.orderId).toBe('order_456');
    expect(result.total).toBe(49.99);
    expect(result.itemCount).toBe(1);
  });

  it('should reject invalid payloads', async () => {
    const payload = { id: 'evt_123', data: null };

    await expect(processOrderWebhook(payload))
      .rejects.toThrow('Invalid payload');
  });

  it('should handle duplicate events', async () => {
    const payload = {
      id: 'evt_123',
      type: 'order.created',
      data: { id: 'order_456' }
    };

    // Process first time
    await processOrderWebhook(payload);

    // Process again - should be idempotent
    const result = await processOrderWebhook(payload);
    expect(result.duplicate).toBe(true);
  });
});

Testing Signature Verification

Javascript
const crypto = require('crypto');
const { verifySignature } = require('./security');

describe('Webhook Signature Verification', () => {
  const secret = 'test-secret-key';

  it('should accept valid signatures', () => {
    const payload = '{"event":"test"}';
    const signature = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    expect(verifySignature(payload, signature, secret)).toBe(true);
  });

  it('should reject tampered payloads', () => {
    const payload = '{"event":"test"}';
    const tamperedPayload = '{"event":"malicious"}';
    const signature = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    expect(verifySignature(tamperedPayload, signature, secret))
      .toBe(false);
  });

  it('should reject invalid signatures', () => {
    const payload = '{"event":"test"}';
    expect(verifySignature(payload, 'invalid', secret)).toBe(false);
  });
});

Level 2: Local Development Testing

Using CLI Tools

Most providers offer CLI tools for local testing:

Bash
# Stripe CLI - forward webhooks to localhost
stripe listen --forward-to localhost:3000/webhooks/stripe

# Trigger a test event
stripe trigger payment_intent.succeeded

# GitHub CLI - create a test webhook
gh api repos/owner/repo/hooks -X POST -f url=http://localhost:3000/webhooks/github

Building a Local Webhook Sender

Create a simple script to simulate webhooks:

Javascript
// test/send-webhook.js
const crypto = require('crypto');

async function sendTestWebhook(url, payload, secret) {
  const body = JSON.stringify(payload);
  const signature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': signature,
      'X-Webhook-ID': crypto.randomUUID()
    },
    body
  });

  console.log(`Status: ${response.status}`);
  console.log(`Response: ${await response.text()}`);
}

// Usage
sendTestWebhook('http://localhost:3000/webhook', {
  id: 'evt_test_001',
  type: 'order.created',
  data: {
    id: 'order_789',
    customer_email: 'test@example.com',
    total_price: '99.99'
  }
}, 'your-webhook-secret');

Using Tunnel Services

Expose your local server for real webhook testing:

Bash
# Using ngrok
ngrok http 3000

# Your webhook URL becomes something like:
# https://abc123.ngrok.io/webhooks/stripe

Level 3: Integration Testing

Mock Webhook Server

Build a test harness that simulates webhook senders:

Javascript
const express = require('express');
const crypto = require('crypto');

class MockWebhookSender {
  constructor(targetUrl, secret) {
    this.targetUrl = targetUrl;
    this.secret = secret;
    this.deliveryLog = [];
  }

  async send(eventType, data, options = {}) {
    const payload = {
      id: `evt_${crypto.randomUUID()}`,
      type: eventType,
      created: Date.now(),
      data
    };

    const body = JSON.stringify(payload);
    const signature = crypto
      .createHmac('sha256', this.secret)
      .update(body)
      .digest('hex');

    const response = await fetch(this.targetUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': signature
      },
      body
    });

    const result = {
      payload,
      status: response.status,
      response: await response.text(),
      timestamp: new Date()
    };

    this.deliveryLog.push(result);
    return result;
  }

  // Simulate retries
  async sendWithRetry(eventType, data, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      const result = await this.send(eventType, data);

      if (result.status >= 200 && result.status < 300) {
        return result;
      }

      // Exponential backoff
      await new Promise(r =>
        setTimeout(r, Math.pow(2, attempt) * 100)
      );
    }

    throw new Error('All retry attempts failed');
  }
}

// In your test suite
describe('Webhook Integration', () => {
  let sender;

  beforeAll(() => {
    sender = new MockWebhookSender(
      'http://localhost:3000/webhook',
      'test-secret'
    );
  });

  it('should process order webhooks end-to-end', async () => {
    const result = await sender.send('order.created', {
      id: 'order_integration_test',
      total: 149.99
    });

    expect(result.status).toBe(200);

    // Verify side effects
    const order = await db.orders.findOne({
      externalId: 'order_integration_test'
    });
    expect(order).toBeDefined();
    expect(order.total).toBe(149.99);
  });
});

Level 4: Production Testing

Canary Webhooks

Send periodic test webhooks to verify your production handler:

Javascript
// canary.js - run on a schedule
async function sendCanaryWebhook() {
  const result = await fetch(WEBHOOK_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Canary': 'true'
    },
    body: JSON.stringify({
      id: `canary_${Date.now()}`,
      type: 'canary.ping',
      data: { timestamp: new Date().toISOString() }
    })
  });

  if (result.status !== 200) {
    await alertTeam('Canary webhook failed!');
  }
}

// Run every 5 minutes
setInterval(sendCanaryWebhook, 5 * 60 * 1000);

Shadow Mode Testing

Process webhooks in both old and new handlers, compare results:

Javascript
app.post('/webhook', async (req, res) => {
  // Primary handler (existing)
  const primaryResult = await primaryHandler(req.body);

  // Shadow handler (new version) - don't affect response
  try {
    const shadowResult = await shadowHandler(req.body);

    if (JSON.stringify(primaryResult) !== JSON.stringify(shadowResult)) {
      logger.warn('Shadow handler divergence', {
        eventId: req.body.id,
        primary: primaryResult,
        shadow: shadowResult
      });
    }
  } catch (error) {
    logger.error('Shadow handler failed', { error });
  }

  res.status(200).json(primaryResult);
});

Testing Checklist

Before deploying webhook handlers to production, verify:

  • Signature verification works with valid and invalid signatures
  • Duplicate events are handled idempotently
  • Invalid payloads return appropriate errors
  • Handler responds within the provider's timeout
  • All expected event types are handled
  • Unknown event types don't cause errors
  • Database operations are transactional
  • Error cases are logged and alerted

How HookWatch Helps with Testing

HookWatch makes webhook testing easier:

  • Request logging: See exactly what was sent and how your server responded
  • One-click replay: Resend any webhook for debugging
  • Payload inspection: Examine headers and body of every delivery
  • Test endpoints: Create isolated endpoints for staging and testing

Build confidence in your webhook handlers by testing at every level.

Tags: testingwebhooksci-cddevelopmentbest-practices

Share this article

Ready to try HookWatch?

Start monitoring your webhooks in minutes. No credit card required.

Start Free Today