Automating Webhook Tests in CI/CD: From Unit to Integration Tests

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 == 200

Full 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:resilience

Production 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.

Read more