Testing Webhooks
Test and debug webhooks during development using Hook Mesh test endpoints, local tunnels, and automated tests.
Test Endpoint API
Hook Mesh provides a Test Endpoint API that lets you send test webhooks to your endpoints without affecting production data. This is perfect for verifying your integration before going live.
// Send a test webhook to a specific endpoint
const response = await fetch(
'https://api.hookmesh.com/v1/endpoints/ep_abc123/test',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_type: 'user.created',
payload: {
user_id: 'usr_test_123',
email: 'test@example.com',
name: 'Test User'
}
})
}
);
const result = await response.json();
console.log('Test webhook sent:', result.delivery_id);
// Check the delivery status
const statusResponse = await fetch(
`https://api.hookmesh.com/v1/deliveries/${result.delivery_id}`,
{
headers: {
'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`
}
}
);
const status = await statusResponse.json();
console.log('Status:', status.status);
console.log('Response code:', status.response_code);
console.log('Response body:', status.response_body);Test Webhooks Don't Count
Test webhook deliveries don't count toward your billing limits or appear in production webhook logs. They're clearly marked as test deliveries.
Test Endpoint Rate Limits:
| Limit | Value |
|---|---|
| Test deliveries per hour | 10 |
| Test deliveries per day | 100 |
| Max payload size | 256 KB |
Local Development with ngrok
During development, you can expose your local server to the internet using ngrok, allowing Hook Mesh to send webhooks to your localhost.
Step 1: Install ngrok
# macOS
brew install ngrok
# Linux
curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | \
sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null && \
echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | \
sudo tee /etc/apt/sources.list.d/ngrok.list && \
sudo apt update && sudo apt install ngrok
# Or download from https://ngrok.com/downloadStep 2: Start Your Local Server
# Start your application on port 3000 (or any port)
npm run dev
# Your webhook endpoint should be available at:
# http://localhost:3000/webhooks/hookmeshStep 3: Create an ngrok Tunnel
# Create a tunnel to your local port
ngrok http 3000
# ngrok will display a public URL like:
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000Step 4: Register the ngrok URL in Hook Mesh
# Create an endpoint using the ngrok URL
curl -X POST https://api.hookmesh.com/v1/endpoints \
-H "Authorization: Bearer ${HOOKMESH_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"application_id": "app_xyz789",
"url": "https://abc123.ngrok.io/webhooks/hookmesh",
"event_types": ["user.created", "user.updated"]
}'Step 5: Send Test Webhooks
# Send a test webhook job
curl -X POST https://api.hookmesh.com/v1/webhook-jobs \
-H "Authorization: Bearer ${HOOKMESH_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"application_id": "app_xyz789",
"event_type": "user.created",
"payload": {
"user_id": "usr_123",
"email": "test@example.com",
"name": "Test User"
}
}'
# Watch your local server logs to see the webhook arrive!ngrok URLs Change on Restart
Free ngrok URLs change every time you restart ngrok. Consider upgrading to ngrok Pro for a permanent subdomain, or update your endpoint URL after each restart.
Use ngrok's Web Interface
Visit http://localhost:4040 to see ngrok's web interface. It shows all HTTP requests in real-time, including webhook deliveries, headers, and response bodies. You can even replay requests!
Alternative: localtunnel
If you prefer a simpler, npm-based tunneling solution, try localtunnel:
# Install globally
npm install -g localtunnel
# Start your local server
npm run dev
# Create a tunnel with a custom subdomain
lt --port 3000 --subdomain my-webhook-test
# Your URL will be:
# https://my-webhook-test.loca.lt
# Use this URL when creating Hook Mesh endpointsBenefits
- No account or authentication required
- npm-based (easier for Node.js developers)
- Custom subdomains available
- Open source
Limitations
- Less stable than ngrok
- No web interface for inspecting requests
- May have latency issues
Unit Testing Webhook Handlers
Write automated tests for your webhook handlers to ensure they handle all event types correctly, validate signatures, and process data properly.
// Node.js + Jest + Supertest example
import request from 'supertest';
import crypto from 'crypto';
import { app } from '../app';
// Helper: Generate Hook Mesh HMAC signature
function generateSignature(
webhookId: string,
timestamp: string,
payload: any,
secret: string
): string {
// Construct signed content: {webhookId}.{timestamp}.{jsonPayload}
const signedContent = `${webhookId}.${timestamp}.${JSON.stringify(payload)}`;
// Compute HMAC-SHA256 and encode as base64
const hmac = crypto.createHmac('sha256', secret);
hmac.update(signedContent);
const signature = hmac.digest('base64');
// Return in v1 format: "v1,<signature>"
return `v1,${signature}`;
}
describe('Webhook Handler', () => {
const webhookSecret = 'whsec_test123';
beforeEach(() => {
// Reset database or mocks
});
test('processes user.created event successfully', async () => {
const webhookId = 'job_test_123';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = {
event_id: 'evt_test_123',
event_type: 'user.created',
application_id: 'app_xyz789',
timestamp: new Date().toISOString(),
payload: {
user_id: 'usr_123',
email: 'alice@example.com',
name: 'Alice Johnson',
created_at: new Date().toISOString()
}
};
const signature = generateSignature(webhookId, timestamp, payload, webhookSecret);
const response = await request(app)
.post('/webhooks/hookmesh')
.set('webhook-id', webhookId)
.set('webhook-timestamp', timestamp)
.set('webhook-signature', signature)
.set('Content-Type', 'application/json')
.send(payload);
expect(response.status).toBe(200);
expect(response.body).toEqual({
status: 'processed',
event_id: 'evt_test_123'
});
// Verify the user was created in the database
const user = await db.users.findById('usr_123');
expect(user).toBeDefined();
expect(user.email).toBe('alice@example.com');
});
test('rejects webhook with invalid signature', async () => {
const webhookId = 'job_test_456';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = {
event_id: 'evt_test_456',
event_type: 'user.created',
application_id: 'app_xyz789',
timestamp: new Date().toISOString(),
payload: { user_id: 'usr_456' }
};
const response = await request(app)
.post('/webhooks/hookmesh')
.set('webhook-id', webhookId)
.set('webhook-timestamp', timestamp)
.set('webhook-signature', 'v1,invalid_signature')
.set('Content-Type', 'application/json')
.send(payload);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid signature');
});
test('handles duplicate webhook idempotently', async () => {
const webhookId = 'job_test_789';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = {
event_id: 'evt_test_789',
event_type: 'payment.succeeded',
application_id: 'app_xyz789',
timestamp: new Date().toISOString(),
payload: {
payment_id: 'pay_123',
amount: 99.99,
currency: 'USD'
}
};
const signature = generateSignature(webhookId, timestamp, payload, webhookSecret);
// Send the webhook twice
const response1 = await request(app)
.post('/webhooks/hookmesh')
.set('webhook-id', webhookId)
.set('webhook-timestamp', timestamp)
.set('webhook-signature', signature)
.send(payload);
const response2 = await request(app)
.post('/webhooks/hookmesh')
.set('webhook-id', webhookId)
.set('webhook-timestamp', timestamp)
.set('webhook-signature', signature)
.send(payload);
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
expect(response2.body.status).toBe('already_processed');
// Verify payment was only recorded once
const payments = await db.payments.findAll({
where: { id: 'pay_123' }
});
expect(payments).toHaveLength(1);
});
test('returns 500 for processing errors', async () => {
const webhookId = 'job_test_error';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = {
event_id: 'evt_test_error',
event_type: 'order.shipped',
application_id: 'app_xyz789',
timestamp: new Date().toISOString(),
payload: {
order_id: 'ord_invalid' // This will cause an error
}
};
const signature = generateSignature(webhookId, timestamp, payload, webhookSecret);
const response = await request(app)
.post('/webhooks/hookmesh')
.set('webhook-id', webhookId)
.set('webhook-timestamp', timestamp)
.set('webhook-signature', signature)
.send(payload);
expect(response.status).toBe(500);
expect(response.body.error).toBe('Processing failed');
// Verify the event was NOT marked as processed
const processed = await db.processedWebhooks.findById('evt_test_error');
expect(processed).toBeUndefined();
});
});# Python + pytest example
import pytest
import hmac
import hashlib
import base64
import json
import time
from app import app
from db import db
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def generate_signature(webhook_id, timestamp, payload, secret):
"""Generate Hook Mesh HMAC signature"""
# Construct signed content: {webhookId}.{timestamp}.{jsonPayload}
signed_content = f"{webhook_id}.{timestamp}.{json.dumps(payload)}"
# Compute HMAC-SHA256 and encode as base64
signature = hmac.new(
secret.encode(),
signed_content.encode(),
hashlib.sha256
).digest()
signature_b64 = base64.b64encode(signature).decode()
# Return in v1 format: "v1,<signature>"
return f"v1,{signature_b64}"
def test_user_created_webhook(client):
"""Test processing user.created event"""
webhook_id = 'job_test_123'
timestamp = str(int(time.time()))
payload = {
'event_id': 'evt_test_123',
'event_type': 'user.created',
'application_id': 'app_xyz789',
'timestamp': '2026-01-20T10:30:00Z',
'payload': {
'user_id': 'usr_123',
'email': 'alice@example.com',
'name': 'Alice Johnson'
}
}
secret = 'whsec_test123'
signature = generate_signature(webhook_id, timestamp, payload, secret)
response = client.post(
'/webhooks/hookmesh',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'webhook-id': webhook_id,
'webhook-timestamp': timestamp,
'webhook-signature': signature
}
)
assert response.status_code == 200
assert response.json['status'] == 'processed'
# Verify user was created
user = db.session.query(User).filter_by(id='usr_123').first()
assert user is not None
assert user.email == 'alice@example.com'
def test_invalid_signature_rejected(client):
"""Test that invalid signatures are rejected"""
webhook_id = 'job_test_456'
timestamp = str(int(time.time()))
payload = {
'event_id': 'evt_test_456',
'event_type': 'user.created',
'payload': {'user_id': 'usr_456'}
}
response = client.post(
'/webhooks/hookmesh',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'webhook-id': webhook_id,
'webhook-timestamp': timestamp,
'webhook-signature': 'v1,invalid_signature'
}
)
assert response.status_code == 401
assert 'Invalid signature' in response.json['error']
def test_idempotent_processing(client):
"""Test that duplicate webhooks are handled idempotently"""
webhook_id = 'job_test_789'
timestamp = str(int(time.time()))
payload = {
'event_id': 'evt_test_789',
'event_type': 'payment.succeeded',
'payload': {
'payment_id': 'pay_123',
'amount': 99.99
}
}
secret = 'whsec_test123'
signature = generate_signature(webhook_id, timestamp, payload, secret)
# Send twice
response1 = client.post(
'/webhooks/hookmesh',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'webhook-id': webhook_id,
'webhook-timestamp': timestamp,
'webhook-signature': signature
}
)
response2 = client.post(
'/webhooks/hookmesh',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'webhook-id': webhook_id,
'webhook-timestamp': timestamp,
'webhook-signature': signature
}
)
assert response1.status_code == 200
assert response2.status_code == 200
assert response2.json['status'] == 'already_processed'
# Verify only one payment record
payments = db.session.query(Payment).filter_by(id='pay_123').all()
assert len(payments) == 1Testing Best Practices
Test All Event Types
Create test cases for every event type your application handles. Don't forget edge cases and malformed payloads.
Always Verify Signatures
Include signature verification tests to ensure your handler rejects unauthorized requests. Test with valid, invalid, and missing signatures.
Test Idempotency
Send the same webhook multiple times (sequentially and concurrently) to verify idempotent processing. Ensure only one operation completes.
Test Error Handling
Verify your handler returns appropriate HTTP status codes: 200 for success, 4xx for client errors (don't retry), 5xx for server errors (trigger retries).
Use Realistic Payloads
Base your test payloads on real examples from production or the Hook Mesh documentation. Include all required fields and realistic data types.
Test Response Times
Ensure your webhook handlers respond within 30 seconds. Add assertions to verify processing completes quickly enough.