Skip to content

Testing Webhooks with ngrok and smee

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Testing Webhooks with ngrok and smee. We cover key concepts, practical examples, and best practices to help you master this topic.

Learn webhook testing: use ngrok and smee.io to test webhooks locally, inspect webhook requests, replay webhooks, set up testing workflows, and write automated webhook tests.

What You Learn

You will learn how to test webhooks locally using ngrok and smee.io, inspect incoming webhook requests with inspectors, replay webhooks for debugging, write automated tests for webhook handlers, and set up a webhook testing workflow.

Why It Matters

Webhook providers need a public URL to send events. During development, your server runs on localhost. Without tools like ngrok, you cannot test webhook integration until deployment. Testing locally catches issues early and saves deployment cycles.

Real-World Use

DodaTech developers use ngrok for daily webhook testing. Each developer has a persistent ngrok endpoint. Stripe and GitHub webhooks are forwarded to local servers. The webhook inspector captures requests for debugging and replay.

Setting Up ngrok

# Install ngrok
npm install -g ngrok

# Start ngrok tunnel to local webhook server
ngrok http 3000

# Output:
# Forwarding https://abc123.ngrok.io -> http://localhost:3000

Expected output: ngrok creates a public HTTPS URL that forwards to your local webhook server. Configure your webhook provider to send to https://abc123.ngrok.io/webhooks.

ngrok Configuration

# ngrok.yml - Persistent configuration
version: "2"
authtoken: your_ngrok_auth_token

tunnels:
  webhook-server:
    proto: http
    addr: 3000
    inspect: true  # Enable request inspection
    domain: my-dev.ngrok.io  # Custom domain (paid)
    basic_auth:
      - "webhook:test123"  # Protect your tunnel

  webhook-secondary:
    proto: http
    addr: 3001
    inspect: true

Expected output: Persistent ngrok configuration with fixed subdomain, basic authentication to prevent unwanted requests, and request inspection enabled for debugging.

Webhook Inspector

// Using ngrok's inspection API to read captured requests
class NgrokWebhookInspector {
    constructor(ngrokApiKey) {
        this.apiKey = ngrokApiKey;
        this.baseUrl = 'https://api.ngrok.com';
    }

    async getRecentRequests(tunnelName, limit = 10) {
        const response = await fetch(
            `${this.baseUrl}/tunnel_sessions/${tunnelName}/requests?limit=${limit}`,
            {
                headers: {
                    'Authorization': `Bearer ${this.apiKey}`,
                    'Ngrok-Version': '2',
                },
            }
        );

        const data = await response.json();
        return data.requests.map(req => ({
            id: req.id,
            method: req.method,
            path: req.path,
            statusCode: req.response.status_code,
            duration: req.response.duration_ms,
            requestHeaders: req.request.headers,
            requestBody: req.request.body,
            responseHeaders: req.response.headers,
            responseBody: req.response.body,
            timestamp: req.started_at,
        }));
    }

    async replayRequest(requestId) {
        const request = await this.getRequestDetail(requestId);

        // Replay the request to your local server
        const response = await fetch('http://localhost:3000' + request.path, {
            method: request.method,
            headers: request.requestHeaders,
            body: request.requestBody,
        });

        return {
            replayed: true,
            originalId: requestId,
            statusCode: response.status,
            replayedAt: new Date().toISOString(),
        };
    }

    async getRequestDetail(requestId) {
        const response = await fetch(
            `${this.baseUrl}/requests/${requestId}`,
            {
                headers: {
                    'Authorization': `Bearer ${this.apiKey}`,
                    'Ngrok-Version': '2',
                },
            }
        );
        return response.json();
    }
}

Expected output: ngrok's inspection API captures every webhook request. Developers can view request details, replay failed webhooks, and debug integration issues without the provider resending.

Using smee.io

// smee.io - lightweight webhook forwarding for development
const SmeeClient = require('smee-client');

const smee = new SmeeClient({
    source: 'https://smee.io/your-channel-id',
    target: 'http://localhost:3000/webhooks',
    headers: {
        'X-Forwarded-For': 'smee.io',
    },
});

const events = smee.start();

// Listen for events
events.on('message', (message) => {
    console.log('Received webhook via smee:', {
        path: message.path,
        method: message.method,
        headers: message.headers,
        body: message.body?.slice(0, 200),
    });
});

events.on('error', (err) => {
    console.error('smee error:', err.message);
});

Expected output: smee.io creates a public URL (https://smee.io/your-channel-id). Providers send webhooks to this URL. smee forwards them to your local server. No installation or auth required.

Automated Webhook Tests

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

// Test helper for webhook handlers
class WebhookTestHelper {
    constructor(secret) {
        this.secret = secret;
    }

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

        return {
            body,
            headers: {
                'Content-Type': 'application/json',
                'X-Webhook-Signature': `sha256=${signature}`,
                'X-Webhook-Timestamp': new Date().toISOString(),
                'X-Webhook-ID': `test_${Date.now()}`,
            },
        };
    }

    async sendTestWebhook(app, payload) {
        const { body, headers } = this.createSignedPayload(payload);

        const response = await fetch('http://localhost:3000/webhooks/test', {
            method: 'POST',
            headers,
            body,
        });

        return {
            status: response.status,
            headers: response.headers,
            body: await response.text(),
        };
    }
}

// Automated test example
async function testWebhookHandler() {
    const helper = new WebhookTestHelper('test_secret_123');

    // Test valid webhook
    const validResult = await helper.sendTestWebhook({
        event: 'payment.succeeded',
        data: { id: 'pi_test_123', amount: 5000 },
    });
    console.assert(validResult.status === 200, 'Valid webhook should return 200');

    // Test invalid signature
    const invalidPayload = helper.createSignedPayload({ test: true });
    invalidPayload.headers['X-Webhook-Signature'] = 'sha256=invalid';

    const invalidResult = await fetch('http://localhost:3000/webhooks/test', {
        method: 'POST',
        headers: invalidPayload.headers,
        body: invalidPayload.body,
    });
    console.assert(invalidResult.status === 401, 'Invalid signature should return 401');

    // Test replay (stale timestamp)
    const stalePayload = helper.createSignedPayload({ test: true });
    stalePayload.headers['X-Webhook-Timestamp'] =
        new Date(Date.now() - 600000).toISOString();

    const staleResult = await fetch('http://localhost:3000/webhooks/test', {
        method: 'POST',
        headers: stalePayload.headers,
        body: stalePayload.body,
    });
    console.assert(staleResult.status === 400, 'Stale webhook should return 400');

    console.log('All tests passed!');
}

Expected output: Automated tests verify webhook handler behavior: valid webhooks return 200, invalid signatures return 401, stale webhooks return 400. Tests run in CI/CD pipeline.

Integration Testing with Providers

// Provider-specific test helpers
class ProviderTestClient {
    // Stripe test webhook
    static async sendStripeTestWebhook(eventType, data) {
        const stripe = require('stripe')(process.env.STRIPE_TEST_KEY);

        const event = await stripe.testHelpers.testHelpers
            .testClock.advance('clock_id');

        // Use Stripe's test webhook endpoint
        const response = await fetch(
            'https://api.stripe.com/v1/webhook_endpoints/test_helpers/send',
            {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${process.env.STRIPE_TEST_KEY}`,
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    'event_type': eventType,
                }),
            }
        );

        return response;
    }

    // GitHub test webhook
    static async sendGitHubTestWebhook(eventType, payload) {
        const response = await fetch(
            'https://api.github.com/repos/owner/repo/dispatches',
            {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
                    'Content-Type': 'application/json',
                    'Accept': 'application/vnd.github.v3+json',
                },
                body: JSON.stringify({
                    event_type: eventType,
                    client_payload: payload,
                }),
            }
        );

        return response;
    }
}

Expected output: Provider test helpers send real test webhooks using provider sandbox environments. Stripe test mode and GitHub Repository dispatches generate genuine webhook payloads for integration testing.

Common Mistakes

1. Testing Only with Fake Payloads

Fake payloads may not match real provider payload structure. Always test with real payloads from provider documentation or sandbox. Capture and replay real webhooks for realistic testing.

2. Exposing Localhost Without Auth

ngrok tunnels are public. Anyone with the URL can send requests. Use basic auth in ngrok config or add authentication to your webhook endpoint during development.

3. Not Testing Error Scenarios

Testing only the happy path misses signature failures, timeouts, malformed JSON, and provider downtime. Write tests for: invalid signatures, expired timestamps, missing fields, oversized payloads.

4. Manual Testing Only

Relying on manual ngrok testing does not scale. Write automated tests that verify webhook handling. Run them in CI/CD. Use replay for Regression Testing.

5. No Webhook Playback Tests

After fixing a bug, replay the failed webhook to verify the fix. Capture the original request from ngrok inspector or your delivery log. Replay it against the fixed code.

Practice Questions

1. How does ngrok enable local webhook testing?

ngrok creates a public HTTPS tunnel to your local server. Providers send webhooks to the ngrok URL, which forwards them to localhost. The ngrok dashboard captures and displays all requests.

2. What is the difference between ngrok and smee.io?

ngrok is a full tunneling solution with request inspection, replay, and paid features (custom domains, reserved tunnels). smee.io is a simple, free webhook forwarding service without inspection or replay.

3. How do you replay a webhook for debugging?

Use ngrok's inspection API to get the raw request. Send the same request to your local server using curl or a test script. This reproduces the exact scenario without the provider resending.

4. What should automated webhook tests verify?

Signature verification (valid and invalid), timestamp validation (current and stale), payload validation (valid and malformed), idempotency (duplicate detection), and event type routing (known and unknown events).

Challenge

Build a webhook testing framework with: ngrok tunnel for local testing, automated test suite with signed payload generation, provider-specific test helpers (Stripe, GitHub), webhook replay capability from captured requests, CI/CD integration that runs tests against a test provider endpoint, and coverage reporting for webhook handler code.

FAQ

Is ngrok free for webhook testing?

ngrok has a free tier with random subdomains and 40 connections/minute. Paid plans ($5/month) add custom domains, reserved tunnels, and higher rate limits. Free tier is sufficient for development.

Can I use smee.io for production webhooks?

No. smee.io is for development only. It has no reliability guarantees. Use it for local testing. For production, configure your actual server URL with the webhook provider.

How do I test webhooks without exposing my local server?

Use smee.io or a webhook forwarding service. Some providers offer webhook logging that shows what they would send. You can also download sample payloads from provider documentation.

What if my provider does not support test mode?

Capture real webhook payloads in production using your delivery log. Redact sensitive data. Use the captured payloads for testing. Replay them against your development server.

How do I test webhook idempotency?

Send the same webhook twice. The first should return 200 (processed). The second should return 200 (duplicate, not processed). Verify the side effect happened only once.

Mini Project: Webhook Testing CLI

Build a CLI tool for webhook testing: send test webhooks with configurable payloads and signatures, replay captured webhooks from ngrok or log files, verify webhook endpoint responses, run test suites against webhook handlers, and generate test coverage reports.

What's Next

Now that you can test webhooks, explore Svix and Standard Webhooks for managed webhook delivery platforms.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro