Automating Webhook Tests in CI/CD: From Unit to Integration Tests
Webhook handlers are business-critical but rarely tested systematically. This guide shows how to build a complete webhook testing pyramid in CI/CD: unit tests for handler logic, smee.io integration tests, contract tests with JSON Schema, security tests for signature verification, and Toxiproxy for retry behavior. Full pytest and Jest examples with a working GitHub Actions workflow.
Key Takeaways
Unit test the handler logic, not the HTTP layer. Extract business logic from your route handler and test it directly — no HTTP overhead, fast feedback. Contract test your payload schemas. If a sender changes their payload format, a contract test catches it before production does. Test retry logic with Toxiproxy. Inject network failures in CI to prove your handler is idempotent and your retry behavior is correct.
Webhook handlers process payments, update orders, provision accounts, and trigger critical workflows. Yet most teams test them manually: trigger a Stripe test event, check the logs, call it done.
This approach misses the scenarios that actually cause incidents: duplicate delivery, invalid signatures, malformed payloads, retry storms, and handler exceptions that swallow the error and return 200 anyway.
Here's how to build a proper webhook testing pipeline in CI.
The Webhook Testing Pyramid
[E2E / Production Monitoring]
(HelpMeTest health checks)
[Integration Tests]
(smee.io tunnel, real HTTP, real DB)
[Contract Tests]
(JSON Schema payload validation)
[Unit Tests]
(handler logic, no HTTP, mocked services)Each layer serves a different purpose. Start at the bottom — fast, isolated unit tests — and build up to integration tests that run in CI with real infrastructure.
Layer 1: Unit Testing Webhook Handlers
The most common mistake is testing the webhook route instead of the webhook handler logic. Routes are boilerplate; logic is where bugs live.
Bad approach — testing the HTTP route:
// This test is slow, requires a running server, and tests infrastructure not logic
test("POST /webhooks/stripe returns 200", async () => {
const response = await request(app).post("/webhooks/stripe").send(payload);
expect(response.status).toBe(200);
});Good approach — testing the handler function:
// src/webhooks/handlers/paymentSucceeded.js
async function handlePaymentSucceeded(event, { db, emailService }) {
const { id: eventId, data: { object: payment } } = event;
// Idempotency check
const existing = await db.processedEvents.findOne({ eventId });
if (existing) return { status: "duplicate", eventId };
await db.transaction(async (t) => {
await db.orders.update(
{ status: "paid", paidAt: new Date() },
{ where: { stripePaymentId: payment.id }, transaction: t }
);
await db.processedEvents.create({ eventId }, { transaction: t });
});
await emailService.sendReceipt(payment.customer, payment.amount);
return { status: "processed", eventId };
}
module.exports = handlePaymentSucceeded;// tests/unit/handlers/paymentSucceeded.test.js
const handlePaymentSucceeded = require("../../../src/webhooks/handlers/paymentSucceeded");
describe("handlePaymentSucceeded", () => {
let mockDb, mockEmailService;
beforeEach(() => {
mockDb = {
processedEvents: {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({})
},
orders: {
update: jest.fn().mockResolvedValue([1])
},
transaction: jest.fn().mockImplementation(fn => fn({}))
};
mockEmailService = {
sendReceipt: jest.fn().mockResolvedValue(undefined)
};
});
const makeEvent = (overrides = {}) => ({
id: "evt_test_001",
type: "payment_intent.succeeded",
data: {
object: {
id: "pi_001",
amount: 5000,
currency: "usd",
customer: "cus_001",
...overrides
}
}
});
test("marks order as paid and sends receipt on success", async () => {
const result = await handlePaymentSucceeded(
makeEvent(),
{ db: mockDb, emailService: mockEmailService }
);
expect(result.status).toBe("processed");
expect(mockDb.orders.update).toHaveBeenCalledWith(
{ status: "paid", paidAt: expect.any(Date) },
expect.objectContaining({ where: { stripePaymentId: "pi_001" } })
);
expect(mockEmailService.sendReceipt).toHaveBeenCalledWith("cus_001", 5000);
});
test("skips processing for duplicate event IDs", async () => {
mockDb.processedEvents.findOne.mockResolvedValue({ eventId: "evt_test_001" });
const result = await handlePaymentSucceeded(
makeEvent(),
{ db: mockDb, emailService: mockEmailService }
);
expect(result.status).toBe("duplicate");
expect(mockDb.orders.update).not.toHaveBeenCalled();
expect(mockEmailService.sendReceipt).not.toHaveBeenCalled();
});
test("does not send email if order update fails", async () => {
mockDb.transaction.mockRejectedValue(new Error("DB connection failed"));
await expect(
handlePaymentSucceeded(makeEvent(), { db: mockDb, emailService: mockEmailService })
).rejects.toThrow("DB connection failed");
expect(mockEmailService.sendReceipt).not.toHaveBeenCalled();
});
test("handles missing customer gracefully", async () => {
const result = await handlePaymentSucceeded(
makeEvent({ customer: null }),
{ db: mockDb, emailService: mockEmailService }
);
expect(result.status).toBe("processed");
expect(mockEmailService.sendReceipt).not.toHaveBeenCalled(); // no customer = no email
});
});Layer 2: Signature Verification Unit Tests
Signature verification is security-critical. Test it in isolation:
// tests/unit/middleware/webhookSignature.test.js
const crypto = require("crypto");
const { verifyStripeSignature } = require("../../../src/middleware/webhookSignature");
const SIGNING_SECRET = "whsec_test_secret";
function createValidHeaders(payload, timestamp = Math.floor(Date.now() / 1000)) {
const payloadStr = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payloadStr}`;
const signature = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(signedPayload)
.digest("hex");
return {
"stripe-signature": `t=${timestamp},v1=${signature}`,
rawBody: Buffer.from(payloadStr)
};
}
describe("Stripe webhook signature verification", () => {
test("passes valid signature", () => {
const payload = { type: "payment_intent.succeeded", data: {} };
const { "stripe-signature": sig, rawBody } = createValidHeaders(payload);
expect(() => verifyStripeSignature(rawBody, sig, SIGNING_SECRET)).not.toThrow();
});
test("rejects invalid signature", () => {
const payload = { type: "payment_intent.succeeded" };
const rawBody = Buffer.from(JSON.stringify(payload));
expect(() =>
verifyStripeSignature(rawBody, "t=12345,v1=invalidsig", SIGNING_SECRET)
).toThrow();
});
test("rejects timestamp older than 5 minutes", () => {
const oldTimestamp = Math.floor(Date.now() / 1000) - 400;
const { "stripe-signature": sig, rawBody } = createValidHeaders({}, oldTimestamp);
expect(() => verifyStripeSignature(rawBody, sig, SIGNING_SECRET, 300)).toThrow(/timestamp/i);
});
test("rejects empty signature header", () => {
expect(() => verifyStripeSignature(Buffer.from("{}"), "", SIGNING_SECRET)).toThrow();
});
});Layer 3: Contract Tests with JSON Schema
Contract testing verifies that the payloads you receive match the documented schema from the webhook sender. When Stripe or GitHub changes their payload format, you want to know in CI before it breaks production.
// tests/contract/stripePayloads.test.js
const Ajv = require("ajv");
const ajv = new Ajv({ allErrors: true });
// Load schemas — maintain these from the provider's documentation
const schemas = {
"payment_intent.succeeded": require("./schemas/stripe/payment_intent.succeeded.json"),
"customer.subscription.deleted": require("./schemas/stripe/customer.subscription.deleted.json"),
"invoice.payment_failed": require("./schemas/stripe/invoice.payment_failed.json")
};
// Load fixture payloads — real payloads captured from the provider
const fixtures = {
"payment_intent.succeeded": require("./fixtures/stripe/payment_intent.succeeded.json"),
"customer.subscription.deleted": require("./fixtures/stripe/customer.subscription.deleted.json"),
"invoice.payment_failed": require("./fixtures/stripe/invoice.payment_failed.json")
};
describe("Stripe webhook payload contract tests", () => {
for (const [eventType, schema] of Object.entries(schemas)) {
test(`${eventType} fixture matches schema`, () => {
const validate = ajv.compile(schema);
const fixture = fixtures[eventType];
const valid = validate(fixture);
if (!valid) {
console.error(`Schema violations for ${eventType}:`, validate.errors);
}
expect(valid).toBe(true);
});
}
});The schemas live in version control alongside your handler code. When the provider announces a payload change, you update the schema file and the contract test fails on the new fixture — before you ship code that expects the new format.
Layer 4: Integration Tests with smee.io in CI
For integration tests, you need to trigger a real HTTP request to a running handler. smee.io provides a public URL for CI environments:
# .github/workflows/webhook-tests.yml
name: Webhook Integration Tests
on: [push, pull_request]
jobs:
webhook-integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: webhooktest
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Start webhook server
run: node src/server.js &
env:
PORT: 3000
DATABASE_URL: postgresql://postgres:test@localhost/webhooktest
WEBHOOK_SIGNING_SECRET: ${{ secrets.WEBHOOK_SIGNING_SECRET }}
- name: Wait for server
run: npx wait-on http://localhost:3000/health
- name: Run webhook integration tests
run: npm run test:integration
env:
WEBHOOK_ENDPOINT: http://localhost:3000/webhooks
WEBHOOK_SIGNING_SECRET: ${{ secrets.WEBHOOK_SIGNING_SECRET }}// tests/integration/webhookEndpoint.test.js
const axios = require("axios");
const crypto = require("crypto");
const ENDPOINT = process.env.WEBHOOK_ENDPOINT || "http://localhost:3000/webhooks";
const SECRET = process.env.WEBHOOK_SIGNING_SECRET || "test_secret";
function signPayload(payload, timestamp) {
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
return crypto.createHmac("sha256", SECRET).update(signedPayload).digest("hex");
}
async function sendWebhook(eventType, data, options = {}) {
const payload = {
id: options.eventId || `evt_${crypto.randomBytes(6).toString("hex")}`,
type: eventType,
created: Math.floor(Date.now() / 1000),
data: { object: data }
};
const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
const signature = signPayload(payload, timestamp);
return axios.post(`${ENDPOINT}/stripe`, payload, {
headers: {
"Content-Type": "application/json",
"stripe-signature": `t=${timestamp},v1=${signature}`
},
validateStatus: () => true // Don't throw on non-2xx
});
}
describe("Stripe webhook endpoint integration", () => {
test("processes payment_intent.succeeded correctly", async () => {
const response = await sendWebhook("payment_intent.succeeded", {
id: "pi_integration_001",
amount: 9900,
currency: "usd",
customer: "cus_integration_test",
status: "succeeded"
});
expect(response.status).toBe(200);
// Verify database state
const { rows } = await db.query(
"SELECT * FROM processed_events WHERE event_id = $1",
["evt_*"] // Would match the generated event ID
);
expect(rows.length).toBe(1);
});
test("returns 400 for invalid signature", async () => {
const response = await axios.post(
`${ENDPOINT}/stripe`,
{ type: "payment_intent.succeeded" },
{
headers: {
"Content-Type": "application/json",
"stripe-signature": "t=invalid,v1=invalidsig"
},
validateStatus: () => true
}
);
expect(response.status).toBe(400);
});
});Layer 5: Testing Retry Logic with Toxiproxy
Toxiproxy lets you inject network failures in CI to test how your webhook handler behaves under adverse conditions:
# Start Toxiproxy
docker run -d -p 8474:8474 -p 3001:3001 shopify/toxiproxy
<span class="hljs-comment"># Configure a proxy
curl -X POST localhost:8474/proxies -d <span class="hljs-string">'{
"name": "webhook_backend",
"listen": "0.0.0.0:3001",
"upstream": "localhost:3000"
}'# tests/integration/test_webhook_retry_toxiproxy.py
import pytest
import requests
import time
TOXIPROXY_API = "http://localhost:8474"
WEBHOOK_PROXY_URL = "http://localhost:3001/webhooks/stripe"
def add_toxic(proxy_name, toxic_type, attributes):
"""Inject a network failure via Toxiproxy."""
return requests.post(
f"{TOXIPROXY_API}/proxies/{proxy_name}/toxics",
json={"type": toxic_type, "attributes": attributes, "toxicity": 1.0}
).json()
def remove_all_toxics(proxy_name):
"""Remove all network failures."""
toxics = requests.get(f"{TOXIPROXY_API}/proxies/{proxy_name}/toxics").json()
for toxic in toxics:
requests.delete(f"{TOXIPROXY_API}/proxies/{proxy_name}/toxics/{toxic['name']}")
@pytest.fixture(autouse=True)
def cleanup_toxics():
yield
remove_all_toxics("webhook_backend")
def test_handler_is_idempotent_under_connection_reset(mock_sender):
"""
Simulate connection reset after partial processing.
Second delivery of same event should be a no-op.
"""
event_id = "evt_toxiproxy_idem_001"
# First delivery succeeds
response = mock_sender.send(
"payment_intent.succeeded",
{"amount": 5000},
endpoint=WEBHOOK_PROXY_URL,
event_id=event_id
)
assert response.status_code == 200
# Verify processing happened
payment = get_payment_from_db(event_id)
assert payment is not None
# Second delivery (retry) of same event
response = mock_sender.send(
"payment_intent.succeeded",
{"amount": 5000},
endpoint=WEBHOOK_PROXY_URL,
event_id=event_id
)
assert response.status_code in (200, 409) # Either OK or Conflict is acceptable
# Still only one payment record
payments = get_all_payments_for_event(event_id)
assert len(payments) == 1
def test_handler_recovers_after_latency_injection(mock_sender):
"""Handler should process correctly when network is slow."""
# Add 500ms latency
add_toxic("webhook_backend", "latency", {"latency": 500, "jitter": 100})
response = mock_sender.send(
"payment_intent.succeeded",
{"amount": 3000},
endpoint=WEBHOOK_PROXY_URL
)
# Should still succeed despite latency
assert response.status_code == 200Full GitHub Actions Workflow
# .github/workflows/webhook-full-test-suite.yml
name: Webhook Test Suite
on:
push:
paths:
- 'src/webhooks/**'
- 'tests/webhook*/**'
- 'tests/contract/**'
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 -- --testPathPattern="webhook"
contract-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:contract
integration-tests:
runs-on: ubuntu-latest
needs: [unit-tests, contract-tests]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: webhooktest
options: >-
--health-cmd pg_isready
--health-interval 10s
toxiproxy:
image: shopify/toxiproxy
ports:
- 8474:8474
- 3001:3001
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Setup database
run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:test@localhost/webhooktest
- name: Start application
run: node src/server.js &
env:
PORT: 3000
DATABASE_URL: postgresql://postgres:test@localhost/webhooktest
WEBHOOK_SIGNING_SECRET: test_signing_secret_for_ci
- run: npx wait-on http://localhost:3000/health
- name: Configure Toxiproxy
run: |
curl -X POST localhost:8474/proxies \
-d '{"name":"webhook_backend","listen":"0.0.0.0:3001","upstream":"localhost:3000"}'
- name: Run integration tests
run: npm run test:integration
env:
WEBHOOK_ENDPOINT: http://localhost:3000/webhooks
WEBHOOK_PROXY_ENDPOINT: http://localhost:3001/webhooks
WEBHOOK_SIGNING_SECRET: test_signing_secret_for_ci
- name: Run retry/resilience tests
run: npm run test:resilienceProduction Webhook Health Monitoring
CI tests verify your code at deploy time. But webhook endpoint health can degrade in production due to deployment issues, dependency failures, or upstream changes. HelpMeTest health checks continuously send synthetic webhook events to your production endpoints and verify the expected outcomes — catching silent failures between deployments.
Try HelpMeTest
Your webhook handlers are business-critical but typically invisible in monitoring dashboards. Set up HelpMeTest health checks to continuously ping your webhook endpoints with signed synthetic events and verify the side effects. Get alerts before customers report missing notifications. Visit https://helpmetest.com — $100/month flat, unlimited health checks and test runs.