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.
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
╱╲
╱ ╲ 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:
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
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:
# 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:
// 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:
# 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:
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:
// 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:
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.