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:

LimitValue
Test deliveries per hour10
Test deliveries per day100
Max payload size256 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/download

Step 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/hookmesh

Step 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:3000

Step 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 endpoints

Benefits

  • 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) == 1

Testing Best Practices

1️⃣

Test All Event Types

Create test cases for every event type your application handles. Don't forget edge cases and malformed payloads.

2️⃣

Always Verify Signatures

Include signature verification tests to ensure your handler rejects unauthorized requests. Test with valid, invalid, and missing signatures.

3️⃣

Test Idempotency

Send the same webhook multiple times (sequentially and concurrently) to verify idempotent processing. Ensure only one operation completes.

4️⃣

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

5️⃣

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.

6️⃣

Test Response Times

Ensure your webhook handlers respond within 30 seconds. Add assertions to verify processing completes quickly enough.

Related Documentation