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:
- The sender is external — you can't trigger real Stripe charges or GitHub pushes on demand
- 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:
- Go to webhook.site and get a unique URL
- Configure the webhook in your provider (GitHub, Stripe, etc.) to POST there
- Trigger the event
- 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.jsonLoad 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] receivedRunning 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 routesNot 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.