Local Development

Test webhooks on your local machine using tunneling solutions to expose localhost to the internet.

The Challenge

During local development, your application runs on localhost, which is not accessible from the internet. This creates a problem: Hook Mesh (and other webhook providers) can't send webhooks to your local machine.

The solution is to use a tunneling service that creates a public URL forwarding requests to your localhost. This allows Hook Mesh to deliver webhooks to your development environment.

ngrok (Recommended)

ngrok is the most popular tunneling solution. It's reliable, fast, and includes a web interface for inspecting HTTP traffic in real-time.

Installation

# macOS
brew install ngrok

# Linux (Debian/Ubuntu)
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

# Windows (Chocolatey)
choco install ngrok

# Or download from https://ngrok.com/download

Basic Usage

# Start your local server first
npm run dev  # Or your dev command (runs on port 3000)

# In another terminal, start ngrok
ngrok http 3000

# ngrok will display output like:
# Session Status    online
# Forwarding        https://abc123def456.ngrok.io -> http://localhost:3000

Custom Domains (Paid Feature)

With ngrok Pro or higher, you can use a custom subdomain that persists across restarts:

# Sign up and authenticate first
ngrok config add-authtoken YOUR_AUTH_TOKEN

# Use a custom subdomain (requires paid plan)
ngrok http 3000 --domain=my-app.ngrok.io

# Your URL will always be: https://my-app.ngrok.io

Configuration File

Create a config file for persistent settings. The location depends on your ngrok version:

# ~/.config/ngrok/ngrok.yml (ngrok v3+)
version: "2"
authtoken: YOUR_AUTH_TOKEN
tunnels:
  webhook-dev:
    proto: http
    addr: 3000
    domain: my-app.ngrok.io  # Requires paid plan
    inspect: true

# Start with: ngrok start webhook-dev

ngrok Version Note

If you're using ngrok v3 or later (released 2022+), the config file location is ~/.config/ngrok/ngrok.yml. For ngrok v2, use ~/.ngrok2/ngrok.yml. Check your version with ngrok version.

💡

ngrok Web Interface

Visit http://localhost:4040 to see ngrok's web inspector. It shows all HTTP requests in real-time, including webhook deliveries, headers, and response bodies. You can even replay requests!

Free URLs Change on Restart

Free ngrok URLs change every time you restart ngrok. You'll need to update your endpoint URL in the Hook Mesh dashboard after each restart. Consider upgrading to ngrok Pro for a permanent subdomain.

localtunnel (Free Alternative)

localtunnel is a free, npm-based tunneling solution. It's simpler than ngrok but less reliable and lacks advanced features.

# Install globally
npm install -g localtunnel

# Start your local server
npm run dev  # Running on port 3000

# Create a tunnel with a custom subdomain
lt --port 3000 --subdomain my-webhook-test

# Your URL will be:
# https://my-webhook-test.loca.lt

# Note: First request may show a landing page with a button to continue.
# This can interfere with webhook delivery. Use --print-requests to debug.

Benefits

  • No account or authentication required
  • npm-based (easy for Node.js developers)
  • Custom subdomains available (free)
  • Open source

Limitations

  • Less stable than ngrok
  • No web interface for inspecting requests
  • May have latency issues
  • Landing page on first visit can interfere with automated webhooks

cloudflared (Cloudflare Tunnel)

Cloudflare Tunnel (via cloudflared) is a free, production-grade tunneling solution from Cloudflare. It's more complex to set up but offers enterprise-level reliability.

# macOS
brew install cloudflared

# Linux
wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared-linux-amd64.deb

# Quick tunnel (no account required, temporary URL)
cloudflared tunnel --url http://localhost:3000

# Output:
# Your quick Tunnel has been created! Visit it at:
# https://random-words-123.trycloudflare.com

Named Tunnels (Advanced)

With a free Cloudflare account, you can create named tunnels with custom domains. This requires more setup but provides permanent URLs. See Cloudflare's documentation for details.

serveo (SSH-Based)

serveo uses SSH port forwarding to create tunnels. It requires no installation beyond SSH (built into most systems).

# Start your local server
npm run dev  # Running on port 3000

# Create tunnel via SSH
ssh -R 80:localhost:3000 serveo.net

# Output will show your public URL:
# Forwarding HTTP traffic from https://random123.serveo.net

# For a custom subdomain (may not always be available):
ssh -R myapp:80:localhost:3000 serveo.net

Reliability Concerns

serveo has experienced downtime in the past. It's best as a backup option or for quick testing. For serious development work, use ngrok or cloudflared.

Development Workflow

Follow this workflow to test webhooks during local development:

1️⃣

Start Your Local Server

# Start your app on port 3000 (or any port)
npm run dev

# Verify your webhook endpoint is accessible:
curl http://localhost:3000/webhooks/hookmesh
2️⃣

Start Tunnel

# Using ngrok
ngrok http 3000

# Or using localtunnel
lt --port 3000 --subdomain my-test

# Or using cloudflared
cloudflared tunnel --url http://localhost:3000
3️⃣

Create Endpoint in Hook Mesh

# Use your tunnel URL to create an endpoint
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"],
    "description": "Local development endpoint"
  }'
4️⃣

Send Test Webhooks

# Use the Test Endpoint API to send webhooks
curl -X POST https://api.hookmesh.com/v1/endpoints/ep_abc123/test \
  -H "Authorization: Bearer ${HOOKMESH_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "user.created",
    "payload": {
      "user_id": "usr_test_123",
      "email": "test@example.com",
      "name": "Test User"
    }
  }'
5️⃣

Monitor Logs

Watch your local server logs and the ngrok web interface (http://localhost:4040) to see the webhook arrive.

# Example server log output:
# [2026-01-20 14:30:15] POST /webhooks/hookmesh
# [2026-01-20 14:30:15] Headers: {
#   "webhook-id": "job_5nM8pQ1rK3vL9xB",
#   "webhook-timestamp": "1737380400",
#   "webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
#   "Content-Type": "application/json"
# }
# [2026-01-20 14:30:15] Body: {"event_type":"user.created",...}
# [2026-01-20 14:30:15] Webhook processed successfully

Complete Example: Express.js Webhook Handler

Here's a complete example of an Express.js server with a webhook handler for local testing:

// server.js
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.HOOKMESH_WEBHOOK_SECRET || 'whsec_dev_secret';

// Middleware: Verify webhook signature
function verifySignature(req, res, next) {
  const signature = req.headers['webhook-signature'];

  if (!signature) {
    console.error('[Webhook] Missing signature');
    return res.status(401).json({ error: 'Missing signature' });
  }

  // Extract webhook ID, timestamp, and signature from headers
  const webhookId = req.headers['webhook-id'];
  const timestamp = req.headers['webhook-timestamp'];

  if (!webhookId || !timestamp || !signature) {
    console.error('[Webhook] Missing required headers');
    return res.status(401).json({ error: 'Missing required headers' });
  }

  // Extract signature (format: "v1,<signature>")
  const parts = signature.split(',');
  if (parts[0] !== 'v1') {
    console.error('[Webhook] Invalid signature version');
    return res.status(401).json({ error: 'Invalid signature version' });
  }
  const expectedSignature = parts[1];

  // Construct signed content: {webhookId}.{timestamp}.{jsonPayload}
  const body = JSON.stringify(req.body);
  const signedContent = `${webhookId}.${timestamp}.${body}`;

  // Compute HMAC-SHA256 and encode as base64
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  hmac.update(signedContent);
  const computedSignature = hmac.digest('base64');

  // Compare signatures (timing-safe)
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(computedSignature)
  )) {
    console.error('[Webhook] Invalid signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  console.log('[Webhook] Signature verified ✓');
  next();
}

// Webhook endpoint
app.post('/webhooks/hookmesh', verifySignature, async (req, res) => {
  const { event_id, event_type, payload, timestamp } = req.body;

  console.log(`[Webhook] Received: ${event_type}`, {
    event_id,
    timestamp,
    payload,
  });

  try {
    // Handle different event types
    switch (event_type) {
      case 'user.created':
        console.log(`[Webhook] New user: ${payload.email}`);
        // Process user creation
        await createUser(payload);
        break;

      case 'user.updated':
        console.log(`[Webhook] Updated user: ${payload.user_id}`);
        // Process user update
        await updateUser(payload);
        break;

      case 'payment.succeeded':
        console.log(`[Webhook] Payment succeeded: ${payload.payment_id}`);
        // Process payment
        await recordPayment(payload);
        break;

      default:
        console.warn(`[Webhook] Unknown event type: ${event_type}`);
    }

    // Return 200 to acknowledge receipt
    res.status(200).json({
      status: 'processed',
      event_id,
    });
  } catch (error) {
    console.error('[Webhook] Processing error:', error);
    // Return 500 to trigger retry
    res.status(500).json({
      error: 'Processing failed',
    });
  }
});

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Dummy processing functions (replace with your logic)
async function createUser(payload) {
  // Simulate async database operation
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(`Created user in database: ${payload.user_id}`);
}

async function updateUser(payload) {
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(`Updated user in database: ${payload.user_id}`);
}

async function recordPayment(payload) {
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(`Recorded payment in database: ${payload.payment_id}`);
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`\nServer running on http://localhost:${PORT}`);
  console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/hookmesh`);
  console.log(`\nTo expose this server:`);
  console.log(`  ngrok http ${PORT}`);
  console.log(`  lt --port ${PORT}`);
  console.log(`  cloudflared tunnel --url http://localhost:${PORT}\n`);
});

Test Endpoint API

Hook Mesh provides a dedicated Test Endpoint API for sending test webhooks without affecting production data. Perfect for local development.

API Details:

PropertyValue
EndpointPOST /v1/endpoints/:endpoint_id/test
Rate limit (hourly)10 requests
Rate limit (daily)100 requests
Max payload size256 KB

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.

Examples in 4 Languages:

Node.js

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

Python

import os
import requests

response = requests.post(
    'https://api.hookmesh.com/v1/endpoints/ep_abc123/test',
    headers={
        'Authorization': f'Bearer {os.getenv("HOOKMESH_API_KEY")}',
        'Content-Type': 'application/json'
    },
    json={
        'event_type': 'user.created',
        'payload': {
            'user_id': 'usr_test_123',
            'email': 'test@example.com',
            'name': 'Test User'
        }
    }
)

result = response.json()
print(f'Test webhook sent: {result["delivery_id"]}')

Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
)

func main() {
    payload := map[string]interface{}{
        "event_type": "user.created",
        "payload": map[string]string{
            "user_id": "usr_test_123",
            "email":   "test@example.com",
            "name":    "Test User",
        },
    }

    body, _ := json.Marshal(payload)
    req, _ := http.NewRequest(
        "POST",
        "https://api.hookmesh.com/v1/endpoints/ep_abc123/test",
        bytes.NewBuffer(body),
    )

    req.Header.Set("Authorization", "Bearer "+os.Getenv("HOOKMESH_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, _ := client.Do(req)
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    fmt.Printf("Test webhook sent: %s\n", result["delivery_id"])
}

PHP

<?php

$payload = [
    'event_type' => 'user.created',
    'payload' => [
        'user_id' => 'usr_test_123',
        'email' => 'test@example.com',
        'name' => 'Test User'
    ]
];

$ch = curl_init('https://api.hookmesh.com/v1/endpoints/ep_abc123/test');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . getenv('HOOKMESH_API_KEY'),
    'Content-Type: application/json'
]);

$response = curl_exec($ch);
curl_close($ch);

$result = json_decode($response, true);
echo 'Test webhook sent: ' . $result['delivery_id'] . "\n";

For more details, see the Testing Webhooks documentation.

Debugging Tips

🔍

Enable Verbose Logging

Log all webhook requests, headers, and payloads during development:

app.post('/webhooks/hookmesh', (req, res) => {
  console.log('=== Webhook Received ===');
  console.log('Headers:', JSON.stringify(req.headers, null, 2));
  console.log('Body:', JSON.stringify(req.body, null, 2));
  console.log('=======================');

  // Process webhook...
});
🔎

Use Webhook Inspection Tools

Hook Mesh Portal: View delivery history, response codes, and retry attempts for each webhook.

ngrok Inspector: Visit http://localhost:4040 to see all HTTP traffic, replay requests, and inspect headers/bodies.

🔐

Check Signature Verification

If webhooks are being rejected, verify your signature calculation:

// Extract headers
const webhookId = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
const receivedSignature = req.headers['webhook-signature'];

// Extract signature (format: "v1,<signature>")
const expectedSig = receivedSignature.split(',')[1];

// Construct signed content
const body = JSON.stringify(req.body);
const signedContent = `${webhookId}.${timestamp}.${body}`;

// Compute HMAC-SHA256 as base64
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(signedContent);
const computedSig = hmac.digest('base64');

console.log('Webhook ID:', webhookId);
console.log('Timestamp:', timestamp);
console.log('Expected signature:', expectedSig);
console.log('Computed signature:', computedSig);
console.log('Match:', expectedSig === computedSig);
🔄

Monitor Retry Behavior

Return a 5xx status code to test Hook Mesh's retry mechanism. Check the Hook Mesh portal to see exponential backoff in action. Return 200 to stop retries.

Best Practices

1️⃣

Use Separate API Keys for Development

Create a separate Hook Mesh project or application for development. This keeps test data isolated from production and makes it easy to reset during testing.

2️⃣

Don't Commit Tunnel URLs to Git

Tunnel URLs change frequently. Use environment variables for endpoint URLs:

# .env (not committed to git)
WEBHOOK_URL=https://abc123.ngrok.io/webhooks/hookmesh

# .env.example (committed to git)
WEBHOOK_URL=https://your-tunnel-url.ngrok.io/webhooks/hookmesh
3️⃣

Use Environment Variables

Store all secrets in environment variables, never in code:

# .env
HOOKMESH_API_KEY=hm_dev_abc123
HOOKMESH_WEBHOOK_SECRET=whsec_dev_xyz789
PORT=3000
4️⃣

Test with Different Event Types

Send test webhooks for all event types your application handles. This helps catch edge cases and ensures your event routing logic works correctly.

5️⃣

Test Retry Scenarios

Intentionally return 5xx errors to test how your application handles retried webhooks. Ensure idempotency works as expected.

6️⃣

Test Idempotency

Send the same webhook multiple times (using the same event_id) to verify your handler processes it idempotently. Only one operation should complete.

Common Issues

Tunnel Disconnected

Problem: ngrok or other tunnel disconnects unexpectedly.

Solution: Use --log=stdout to see connection logs:

ngrok http 3000 --log=stdout

# Or check ngrok config for connection issues:
ngrok diagnose

Port Conflicts

Problem: Port already in use.

Solution: Use a different port or kill the conflicting process:

# Find process using port 3000
lsof -ti:3000

# Kill the process
kill -9 $(lsof -ti:3000)

# Or start your server on a different port
PORT=3001 npm run dev
ngrok http 3001

Signature Verification Fails

Problem: Webhooks are rejected with "Invalid signature".

Solution: Verify you're using the correct webhook secret and computing the HMAC correctly:

// Make sure you're using the EXACT body sent by Hook Mesh
// Don't parse then re-stringify - this can change the JSON
const rawBody = req.body; // Use raw body middleware

// Correct secret format
const secret = 'whsec_abc123'; // NOT 'Bearer whsec_abc123'

// Correct signature format
const signature = `sha256=${hmac.digest('hex')}`;
// NOT just hmac.digest('hex')

HTTPS Required

Problem: Hook Mesh requires HTTPS endpoints.

Solution: All tunneling services (ngrok, localtunnel, cloudflared) automatically provide HTTPS. Make sure you're using the HTTPS URL when creating endpoints, not HTTP.

Related Documentation