Testing Webhooks with Mock Servers: A Practical Guide

Testing Webhooks with Mock Servers: A Practical Guide

Webhooks are notoriously hard to test. They arrive asynchronously, carry cryptographic signatures, and the service sending them is usually not your code. Mock servers solve this by letting you simulate incoming webhook events locally — without waiting for real events from Stripe, GitHub, or Shopify.

This guide shows how to set up mock servers for webhook testing, what tools to use, and how to structure tests that actually catch bugs.

Why Mock Servers for Webhooks?

When testing webhook handlers you face two problems:

  1. The sender is external — you can't trigger real Stripe charges or GitHub pushes on demand
  2. The payload is async — your handler receives an HTTP POST; you need something to send it

Mock servers solve both: they let you POST any payload to your handler endpoint on demand, synchronously, in your test suite.

Option 1: Express Mock in Node.js Tests

The simplest approach is spinning up a minimal HTTP server in your test file:

const express = require('express');
const request = require('supertest');

const webhookHandler = require('../src/webhooks/stripe');

describe('Stripe webhook handler', () => {
  const app = express();
  app.use('/webhook', express.raw({ type: 'application/json' }), webhookHandler);

  it('processes payment_intent.succeeded', async () => {
    const payload = {
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_test_123', amount: 2000 } }
    };

    const res = await request(app)
      .post('/webhook')
      .set('Content-Type', 'application/json')
      .set('stripe-signature', buildTestSignature(payload))
      .send(JSON.stringify(payload));

    expect(res.status).toBe(200);
  });
});

This runs entirely in-process — no network, no real Stripe. Fast and deterministic.

Option 2: WireMock for Outbound Webhook Calls

If your service sends webhooks to customer endpoints, you need to mock the receiving side. WireMock is ideal:

const WireMock = require('wiremock-docker');

beforeAll(async () => {
  await WireMock.start();
  await WireMock.stub({
    request: { method: 'POST', urlPath: '/customer-webhook' },
    response: { status: 200, body: 'OK' }
  });
});

it('delivers webhook on order completion', async () => {
  await orderService.complete('order-123');

  const calls = await WireMock.verify('/customer-webhook');
  expect(calls).toHaveLength(1);
  expect(calls[0].body.event).toBe('order.completed');
});

WireMock records all requests so you can assert exactly what your code sent.

Option 3: msw (Mock Service Worker) for Browser/Node

msw intercepts HTTP at the network level, making it easy to test webhook-receiving front-ends:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.post('/api/webhooks/github', async ({ request }) => {
    const body = await request.json();
    // Simulate processing delay
    return HttpResponse.json({ received: true }, { status: 200 });
  })
);

beforeAll(() => server.listen());
afterAll(() => server.close());

msw works in both Jest/Vitest (Node) and browsers, making it versatile for full-stack testing.

Option 4: RequestBin / Webhook.site for Manual Testing

For exploring third-party webhook shapes before you write handlers:

  1. Go to webhook.site and get a unique URL
  2. Configure the webhook in your provider (GitHub, Stripe, etc.) to POST there
  3. Trigger the event
  4. Inspect the full payload, headers, and signature

Once you understand the payload shape, copy it into your automated test fixtures.

Structuring Webhook Test Fixtures

Don't hardcode payloads inline — keep them as JSON fixtures:

tests/
  fixtures/
    webhooks/
      stripe/
        payment_intent.succeeded.json
        payment_intent.payment_failed.json
        customer.subscription.deleted.json
      github/
        push.json
        pull_request.opened.json

Load them in tests:

const fixture = require('./fixtures/webhooks/stripe/payment_intent.succeeded.json');

it('handles payment success', async () => {
  const res = await request(app)
    .post('/webhooks/stripe')
    .send(fixture);

  expect(res.status).toBe(200);
});

This makes it easy to add edge cases (unusual payloads, missing fields) without cluttering test code.

Testing Idempotency

Webhooks often arrive more than once. Your handler must be idempotent — processing the same event twice should have no side effects:

it('is idempotent on duplicate delivery', async () => {
  const payload = require('./fixtures/webhooks/stripe/payment_intent.succeeded.json');

  // Send the same webhook twice
  const res1 = await request(app).post('/webhooks/stripe').send(payload);
  const res2 = await request(app).post('/webhooks/stripe').send(payload);

  expect(res1.status).toBe(200);
  expect(res2.status).toBe(200);

  // Order should only be fulfilled once
  const orders = await db.query('SELECT * FROM orders WHERE payment_id = $1', [payload.data.object.id]);
  expect(orders.rows).toHaveLength(1);
});

Running Webhook Tests End-to-End

For full integration tests, use HelpMeTest to write tests that hit your staging server with real-shaped payloads:

*** Test Cases ***
Webhook Endpoint Accepts Payment Success Event
    ${payload}=    Get File    fixtures/stripe_payment_succeeded.json
    ${headers}=    Create Dictionary    Content-Type=application/json    stripe-signature=test_sig_123
    ${response}=   POST    ${BASE_URL}/webhooks/stripe    headers=${headers}    data=${payload}
    Should Be Equal As Integers    ${response.status_code}    200
    ${body}=       Set Variable    ${response.json()}
    Should Be Equal    ${body}[status]    received

Running these against a deployed staging environment catches infrastructure issues (wrong routing, missing env vars) that unit tests miss.

Common Mistakes

Forgetting to parse raw body — Stripe and many providers require the raw (unparsed) request body for signature verification. If you use express.json() before your webhook route, the body is already parsed and signature verification fails.

// Wrong — body is already parsed, signature fails
app.use(express.json());
app.post('/webhooks/stripe', stripeHandler);

// Right — raw body for webhook route
app.post('/webhooks/stripe', express.raw({ type: '*/*' }), stripeHandler);
app.use(express.json()); // for other routes

Not testing the error path — What happens when your database is down mid-webhook? Return 500 so the provider retries. Test this explicitly.

Ignoring unknown event types — Always return 200 for unknown events (not 400). Returning an error causes the provider to retry indefinitely. Test that unknown types are gracefully ignored.

Summary

Mock servers let you test webhook handlers without real external events. The right tool depends on your scenario:

Scenario Tool
Testing your handler in unit tests Express/supertest or Fastify inject
Mocking outbound webhook delivery WireMock
Browser or isomorphic testing msw
Exploring unknown payload shapes RequestBin / webhook.site
Full E2E against staging HelpMeTest

Start with in-process tests for speed, add WireMock for outbound delivery verification, and use end-to-end tests for integration confidence.

Read more