How to Test Webhooks: A Complete Guide

Published Feb 21 202612 min read
Webhook testing workflow showing local development, staging, and production testing strategies

Testing webhooks presents unique challenges. Unlike API calls where you control both the request and the response, webhooks arrive from external systems at unpredictable times with payloads you do not fully control. You cannot simply call a webhook to test it — you need the source system to send one, or you need to simulate it convincingly. This guide covers every testing strategy from local development to production monitoring, giving you the tools and techniques to test webhooks thoroughly at every stage.

The Webhook Testing Challenge

Webhook testing is difficult because of several fundamental constraints:

  • External dependency: The webhook source is a third-party system you do not control
  • Public URL required: Webhook providers cannot send requests to localhost
  • Asynchronous flow: Events are triggered by actions in the source system, not by your code
  • Payload variety: Different event types produce different payload structures
  • Security requirements: Testing must include signature verification to be meaningful
  • Idempotency concerns: Tests must verify that duplicate deliveries are handled correctly

A comprehensive testing strategy addresses all of these challenges across multiple environments.

Level 1: Local Development Testing

Using ngrok for Local Webhook Testing

ngrok creates a secure tunnel from a public URL to your local machine, making localhost accessible to webhook providers:

1

Start your local webhook server

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

app.use(express.json());

app.post('/webhook', (req, res) => {
  console.log('Webhook received!');
  console.log('Event type:', req.body.type || req.body.event);
  console.log('Payload:', JSON.stringify(req.body, null, 2));
  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log('Server running on port 3000'));
node server.js
2

Start ngrok tunnel

ngrok http 3000
# Output:
# Session Status: online
# Forwarding: https://a1b2c3d4.ngrok.io -> http://localhost:3000
3

Configure the webhook provider

Copy the ngrok HTTPS URL and paste it into the provider's webhook settings:

https://a1b2c3d4.ngrok.io/webhook
4

Trigger an event and inspect

Perform an action in the provider's system (make a test payment, push a commit) and watch the webhook arrive in your terminal. ngrok also provides a web inspector at http://127.0.0.1:4040 where you can see all requests.

ngrok URLs change every time you restart the tunnel (unless you have a paid plan). This means you need to update the webhook URL in the provider's dashboard every time. For a more stable development experience, use Webhookify endpoints that persist across sessions.

Using Webhookify for Development

Webhookify provides persistent webhook endpoints that you can use throughout development:

1

Create a Webhookify endpoint

Sign up at webhookify.app and create a new endpoint. You get a permanent URL like https://webhookify.app/wh/your-endpoint-id.

2

Point your webhook provider at the Webhookify endpoint

Configure the source system to send webhooks to your Webhookify URL.

3

Inspect incoming webhooks

Every webhook is logged with full headers, body, and timing. Use this to understand the exact payload structure before writing your handler code.

4

Build your handler based on real payloads

Now that you know exactly what the payload looks like, build your handler locally and test it with curl using the captured payloads.

Simulating Webhooks with curl

Once you know the payload structure, simulate webhook deliveries locally:

# Basic webhook simulation
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "id": "evt_test_123",
    "type": "payment_intent.succeeded",
    "data": {
      "object": {
        "id": "pi_test_456",
        "amount": 2000,
        "currency": "usd",
        "status": "succeeded"
      }
    }
  }'
# With signature verification (compute HMAC)
SECRET="whsec_test_secret"
TIMESTAMP=$(date +%s)
PAYLOAD='{"id":"evt_test_123","type":"payment_intent.succeeded","data":{"object":{"amount":2000}}}'
SIGNATURE=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "${SECRET}" | awk '{print $2}')

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: ${TIMESTAMP}" \
  -H "X-Webhook-Signature: v1=${SIGNATURE}" \
  -d "${PAYLOAD}"

Using Provider CLI Tools

Many providers offer CLI tools that simplify local testing:

# Stripe CLI — forward live webhook events to your local server
stripe listen --forward-to localhost:3000/webhook
# The CLI creates a temporary webhook endpoint and forwards events

# Trigger specific test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

# GitHub CLI — redeliver a webhook
gh api repos/owner/repo/hooks/HOOK_ID/deliveries/DELIVERY_ID/attempts -X POST

Level 2: Unit Testing Webhook Handlers

Unit tests verify that your webhook processing logic works correctly with known inputs. Mock the HTTP layer and test the business logic directly.

Testing with Jest

const { handleWebhookEvent } = require('../src/webhookHandler');
const db = require('../src/database');

// Mock database
jest.mock('../src/database');

describe('Webhook Handler', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('processes payment_intent.succeeded correctly', async () => {
    const event = {
      id: 'evt_test_123',
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: 'pi_test_456',
          amount: 2000,
          currency: 'usd',
          customer: 'cus_test_789'
        }
      }
    };

    db.query.mockResolvedValue({ rows: [{ id: 'order_123' }] });

    const result = await handleWebhookEvent(event);

    expect(result.status).toBe('processed');
    expect(db.query).toHaveBeenCalledWith(
      expect.stringContaining('UPDATE orders'),
      expect.arrayContaining(['paid'])
    );
  });

  test('handles duplicate events idempotently', async () => {
    const event = {
      id: 'evt_already_processed',
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_test', amount: 1000 } }
    };

    // Simulate event already in processed_events table
    db.query.mockResolvedValueOnce({ rows: [{ event_id: event.id }] });

    const result = await handleWebhookEvent(event);

    expect(result.status).toBe('duplicate');
    expect(result.skipped).toBe(true);
  });

  test('handles unknown event types gracefully', async () => {
    const event = {
      id: 'evt_test_unknown',
      type: 'some.unknown.event',
      data: { object: {} }
    };

    const result = await handleWebhookEvent(event);

    expect(result.status).toBe('ignored');
  });

  test('throws on processing failure for retry', async () => {
    const event = {
      id: 'evt_test_fail',
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_fail', amount: 5000 } }
    };

    db.query.mockRejectedValue(new Error('Database connection lost'));

    await expect(handleWebhookEvent(event)).rejects.toThrow('Database connection lost');
  });
});

Testing Signature Verification

const crypto = require('crypto');
const { verifyWebhookSignature } = require('../src/auth');

describe('Webhook Signature Verification', () => {
  const secret = 'whsec_test_secret_key';

  test('accepts valid signature', () => {
    const payload = '{"event":"test"}';
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signedPayload = `${timestamp}.${payload}`;
    const signature = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');

    const result = verifyWebhookSignature(payload, `v1=${signature}`, timestamp, secret);
    expect(result).toBe(true);
  });

  test('rejects invalid signature', () => {
    const payload = '{"event":"test"}';
    const timestamp = Math.floor(Date.now() / 1000).toString();

    const result = verifyWebhookSignature(payload, 'v1=invalid_signature', timestamp, secret);
    expect(result).toBe(false);
  });

  test('rejects tampered payload', () => {
    const originalPayload = '{"event":"test","amount":100}';
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${originalPayload}`)
      .digest('hex');

    // Tampered payload
    const tamperedPayload = '{"event":"test","amount":999999}';

    const result = verifyWebhookSignature(tamperedPayload, `v1=${signature}`, timestamp, secret);
    expect(result).toBe(false);
  });

  test('rejects expired timestamp', () => {
    const payload = '{"event":"test"}';
    const oldTimestamp = (Math.floor(Date.now() / 1000) - 600).toString(); // 10 minutes ago
    const signature = crypto
      .createHmac('sha256', secret)
      .update(`${oldTimestamp}.${payload}`)
      .digest('hex');

    const result = verifyWebhookSignature(payload, `v1=${signature}`, oldTimestamp, secret);
    expect(result).toBe(false); // Should reject due to timestamp tolerance
  });
});

Level 3: Integration Testing

Integration tests verify your entire webhook endpoint — from HTTP request to database update — using a real (or realistic) server.

Using Supertest for HTTP Integration Tests

const request = require('supertest');
const crypto = require('crypto');
const app = require('../src/app');

describe('Webhook Endpoint Integration', () => {
  const secret = process.env.WEBHOOK_SECRET || 'test_secret';

  function createSignedRequest(payload) {
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const body = JSON.stringify(payload);
    const signature = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${body}`)
      .digest('hex');

    return {
      body,
      timestamp,
      signature: `v1=${signature}`
    };
  }

  test('POST /webhook returns 200 for valid signed request', async () => {
    const payload = {
      id: 'evt_integration_test',
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_test', amount: 1000 } }
    };

    const { body, timestamp, signature } = createSignedRequest(payload);

    const response = await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('X-Webhook-Timestamp', timestamp)
      .set('X-Webhook-Signature', signature)
      .send(body)
      .expect(200);

    expect(response.body.received).toBe(true);
  });

  test('POST /webhook returns 401 for invalid signature', async () => {
    await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('X-Webhook-Signature', 'v1=invalid')
      .set('X-Webhook-Timestamp', Math.floor(Date.now() / 1000).toString())
      .send('{"id":"evt_fake","type":"test"}')
      .expect(401);
  });

  test('POST /webhook returns 400 for invalid payload', async () => {
    const { body, timestamp, signature } = createSignedRequest({ invalid: true });

    await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('X-Webhook-Timestamp', timestamp)
      .set('X-Webhook-Signature', signature)
      .send(body)
      .expect(400);
  });

  test('POST /webhook handles duplicate events', async () => {
    const payload = {
      id: 'evt_duplicate_test',
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_dup', amount: 500 } }
    };

    const { body, timestamp, signature } = createSignedRequest(payload);

    // First request — should process
    await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('X-Webhook-Timestamp', timestamp)
      .set('X-Webhook-Signature', signature)
      .send(body)
      .expect(200);

    // Second request with same event ID — should detect duplicate
    const second = createSignedRequest(payload);
    const response = await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('X-Webhook-Timestamp', second.timestamp)
      .set('X-Webhook-Signature', second.signature)
      .send(second.body)
      .expect(200);

    expect(response.body.duplicate).toBe(true);
  });
});

Level 4: Contract Testing

Contract tests verify that your webhook handler can parse the payload format specified by the provider's documentation. This catches breaking changes when providers update their webhook schemas.

const Ajv = require('ajv');
const ajv = new Ajv();

describe('Webhook Payload Contract Tests', () => {
  // Define the expected schema based on provider documentation
  const stripePaymentSchema = {
    type: 'object',
    required: ['id', 'type', 'data'],
    properties: {
      id: { type: 'string', pattern: '^evt_' },
      type: { type: 'string' },
      created: { type: 'integer' },
      data: {
        type: 'object',
        required: ['object'],
        properties: {
          object: {
            type: 'object',
            required: ['id', 'amount', 'currency', 'status'],
            properties: {
              id: { type: 'string' },
              amount: { type: 'integer', minimum: 0 },
              currency: { type: 'string', minLength: 3, maxLength: 3 },
              status: { type: 'string' }
            }
          }
        }
      }
    }
  };

  test('handler accepts valid Stripe payment_intent.succeeded payload', () => {
    const payload = {
      id: 'evt_contract_test',
      type: 'payment_intent.succeeded',
      created: 1708523400,
      data: {
        object: {
          id: 'pi_contract_test',
          amount: 2000,
          currency: 'usd',
          status: 'succeeded'
        }
      }
    };

    const validate = ajv.compile(stripePaymentSchema);
    expect(validate(payload)).toBe(true);
  });

  test('handler rejects payload missing required fields', () => {
    const incompletePayload = {
      id: 'evt_incomplete',
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: 'pi_incomplete'
          // Missing amount, currency, status
        }
      }
    };

    const validate = ajv.compile(stripePaymentSchema);
    expect(validate(incompletePayload)).toBe(false);
  });
});

Save real webhook payloads captured via Webhookify and use them as test fixtures. This gives you realistic test data that matches actual production payloads, catching edge cases that fabricated test data might miss. Store these fixtures in your test suite and update them periodically.

Level 5: Load Testing

Load testing ensures your webhook endpoint can handle peak traffic without degradation.

Using k6 for Webhook Load Testing

// load-test-webhooks.js (k6 script)
import http from 'k6/http';
import { check, sleep } from 'k6';
import crypto from 'k6/crypto';

export const options = {
  stages: [
    { duration: '30s', target: 10 },   // Ramp up to 10 concurrent users
    { duration: '1m', target: 50 },    // Ramp up to 50
    { duration: '2m', target: 100 },   // Hold at 100
    { duration: '30s', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% of requests under 500ms
    http_req_failed: ['rate<0.01'],    // Less than 1% failure rate
  },
};

export default function () {
  const payload = JSON.stringify({
    id: `evt_load_${Date.now()}_${Math.random().toString(36).slice(2)}`,
    type: 'payment_intent.succeeded',
    data: {
      object: {
        id: `pi_load_${Date.now()}`,
        amount: Math.floor(Math.random() * 10000),
        currency: 'usd',
        status: 'succeeded',
      },
    },
  });

  const res = http.post('https://your-app.com/webhook', payload, {
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': 'v1=load_test_signature',
    },
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(0.1); // Small delay between requests
}
# Run the load test
k6 run load-test-webhooks.js

# Output includes:
# - Request rate (requests/second)
# - Response time percentiles (p50, p95, p99)
# - Error rate
# - Data transfer metrics

What to Monitor During Load Tests

  • Response time: Should stay under 500ms even at peak load
  • Error rate: Should remain below 1%
  • CPU and memory: Should not spike to 100%
  • Database connections: Should not exhaust the connection pool
  • Queue depth: If using async processing, the queue should not grow unbounded
  • Downstream services: Check that your handler's dependencies can handle the load

Level 6: End-to-End Testing

End-to-end tests verify the complete webhook flow from event trigger to final outcome.

describe('End-to-End Webhook Flow', () => {
  test('Stripe payment webhook triggers order fulfillment', async () => {
    // Step 1: Create a test order in pending state
    const order = await createTestOrder({
      customerId: 'cus_test_e2e',
      amount: 2999,
      status: 'pending'
    });

    // Step 2: Simulate the Stripe webhook
    const webhookPayload = {
      id: `evt_e2e_${Date.now()}`,
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: `pi_e2e_${Date.now()}`,
          amount: 2999,
          currency: 'usd',
          metadata: { order_id: order.id }
        }
      }
    };

    const { body, timestamp, signature } = signPayload(webhookPayload);

    // Step 3: Send the webhook
    const response = await request(app)
      .post('/webhook/stripe')
      .set('Content-Type', 'application/json')
      .set('Stripe-Signature', `t=${timestamp},v1=${signature}`)
      .send(body)
      .expect(200);

    // Step 4: Wait for async processing
    await waitForProcessing(2000);

    // Step 5: Verify the outcome
    const updatedOrder = await getOrder(order.id);
    expect(updatedOrder.status).toBe('paid');
    expect(updatedOrder.paid_at).toBeDefined();

    // Verify email was sent
    const emails = await getTestEmails(order.customer_email);
    expect(emails).toContainEqual(
      expect.objectContaining({ subject: expect.stringContaining('Order Confirmed') })
    );
  });
});

End-to-end webhook tests are slow and can be flaky due to async processing, external service dependencies, and timing issues. Run them in a dedicated test environment, not in your fast unit test suite. Use waitForProcessing() with a reasonable timeout instead of fixed delays.

CI/CD Pipeline Integration

Include webhook tests in your CI/CD pipeline to catch regressions automatically:

# .github/workflows/test.yml
name: Webhook Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run test:unit -- --filter webhook

  integration-tests:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7
        ports:
          - 6379:6379
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run db:migrate:test
      - run: npm run test:integration -- --filter webhook
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
          REDIS_URL: redis://localhost:6379
          WEBHOOK_SECRET: test_secret_for_ci

  load-tests:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4
      - uses: grafana/setup-k6-action@v1
      - run: k6 run tests/load/webhook-load-test.js

Webhook Testing Utilities

Build reusable testing utilities to simplify webhook test writing:

// test/helpers/webhookTestUtils.js
const crypto = require('crypto');

class WebhookTestHelper {
  constructor(secret) {
    this.secret = secret;
  }

  createEvent(type, data, overrides = {}) {
    return {
      id: `evt_test_${crypto.randomUUID()}`,
      type,
      created: Math.floor(Date.now() / 1000),
      data: { object: data },
      ...overrides
    };
  }

  sign(payload) {
    const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signedPayload = `${timestamp}.${body}`;
    const signature = crypto
      .createHmac('sha256', this.secret)
      .update(signedPayload)
      .digest('hex');

    return {
      body,
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Timestamp': timestamp,
        'X-Webhook-Signature': `v1=${signature}`
      }
    };
  }

  createSignedEvent(type, data) {
    const event = this.createEvent(type, data);
    return { event, ...this.sign(event) };
  }
}

// Usage in tests
const helper = new WebhookTestHelper('test_secret');
const { event, body, headers } = helper.createSignedEvent(
  'payment_intent.succeeded',
  { id: 'pi_test', amount: 2000, currency: 'usd' }
);

Testing Checklist

A comprehensive webhook testing strategy covers:

  • [ ] Unit tests for each event type handler
  • [ ] Unit tests for signature verification (valid, invalid, expired)
  • [ ] Unit tests for idempotency (duplicate detection)
  • [ ] Integration tests for the full endpoint (HTTP to database)
  • [ ] Contract tests against provider payload schemas
  • [ ] Error handling tests (malformed payloads, missing fields, database failures)
  • [ ] Load tests to verify performance under peak traffic
  • [ ] End-to-end tests for critical webhook flows
  • [ ] CI/CD integration for automated regression testing
  • [ ] Manual testing with real provider events via ngrok or Webhookify

Test and Monitor Your Webhooks with Webhookify

Create instant test endpoints, capture real webhook payloads for test fixtures, and monitor production deliveries — all from one dashboard. Get real-time alerts via Telegram, Discord, Slack, email, or push notifications.

Start Testing Free

Further Reading

Related Articles

Frequently Asked Questions

How to Test Webhooks: A Complete Guide - Webhookify | Webhookify