Skip to content
Back to blog
TestingTypeScriptCI/CDIntegration tests

Webhook Testing in CI/CD with TypeScript

Write end-to-end webhook integration tests that run in GitHub Actions. Create ephemeral endpoints, capture requests, and assert on payloads with the webhooks.cc SDK.

Mar 10, 20267 min read

Webhook integrations break silently. Your payment processor changes a field name, your CI pipeline doesn't catch it, and you find out when customers report missing receipts.

The fix is to test webhooks in CI the same way you test API calls: create an endpoint, trigger the event, capture the request, assert on its contents, and clean up. The webhooks.cc SDK makes this straightforward in TypeScript.

Setup

Install the SDK:

npm install @webhooks-cc/sdk

Store your API key as a repository secret in GitHub Actions:

# .github/workflows/test.yml
name: Webhook Tests
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

The SDK reads WHK_API_KEY from the environment by default — no extra config needed.

The basic test pattern

Every webhook test follows the same shape: create an ephemeral endpoint, trigger the webhook, wait for it to arrive, assert on the payload, delete the endpoint.

import { WebhooksCC, matchAll, matchMethod, matchHeader } from "@webhooks-cc/sdk";
import { describe, it, expect, afterEach } from "vitest";
 
describe("Stripe webhooks", () => {
  const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
  let slug: string | undefined;
 
  afterEach(async () => {
    if (slug) {
      await client.endpoints.delete(slug);
      slug = undefined;
    }
  });
 
  it("receives checkout.session.completed", async () => {
    const endpoint = await client.endpoints.create({
      name: "stripe-checkout-test",
      expiresIn: "10m",
    });
    slug = endpoint.slug;
 
    // Point your app at the endpoint URL and trigger the event
    await yourApp.registerWebhook(endpoint.url!);
    await yourApp.triggerCheckout();
 
    const request = await client.requests.waitFor(slug, {
      timeout: "30s",
      match: matchAll(
        matchMethod("POST"),
        matchHeader("stripe-signature")
      ),
    });
 
    const body = JSON.parse(request.body!);
    expect(body.type).toBe("checkout.session.completed");
    expect(body.data.object.payment_status).toBe("paid");
  });
});

expiresIn: "10m" ensures the endpoint cleans itself up even if the test crashes before afterEach runs.

Testing helpers

The SDK ships a /testing entrypoint with helpers that eliminate the create-wait-cleanup boilerplate:

import { matchHeader, WebhooksCC } from "@webhooks-cc/sdk";
import { captureDuring, assertRequest } from "@webhooks-cc/sdk/testing";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
const [request] = await captureDuring(
  client,
  async (endpoint) => {
    await yourApp.registerWebhook(endpoint.url!);
    await yourApp.triggerCheckout();
  },
  {
    expiresIn: "1h",
    timeout: "20s",
    match: matchHeader("stripe-signature"),
  }
);
 
assertRequest(
  request,
  {
    method: "POST",
    bodyJson: { type: "checkout.session.completed" },
  },
  { throwOnFailure: true }
);

captureDuring creates an ephemeral endpoint, runs your callback, waits for matching requests, and deletes the endpoint when done. assertRequest checks method, headers, and body fields in one call.

The testing helpers also export withEndpoint and withEphemeralEndpoint for cases where you need more control over the endpoint lifecycle.

Matchers

Matchers filter which captured requests waitFor returns. Compose them with matchAll (every condition must match) or matchAny (at least one must match):

import {
  matchAll,
  matchAny,
  matchMethod,
  matchHeader,
  matchPath,
  matchBodyPath,
  matchBodySubset,
  matchContentType,
  matchQueryParam,
} from "@webhooks-cc/sdk";
 
// Match POST requests with a Stripe signature and a specific event type
const stripeMatcher = matchAll(
  matchMethod("POST"),
  matchHeader("stripe-signature"),
  matchBodyPath("type", "invoice.paid")
);
 
// Match requests to a specific path with JSON content
const pathMatcher = matchAll(
  matchPath("/webhooks/billing"),
  matchContentType("application/json")
);
 
// Match requests that carry a specific body shape
const subsetMatcher = matchBodySubset({
  type: "checkout.session.completed",
  data: { object: { payment_status: "paid" } },
});

The SDK also exports provider detection helpers — isStripeWebhook, isGitHubWebhook, isShopifyWebhook, isSlackWebhook, isTwilioWebhook, isPaddleWebhook, isLinearWebhook — for branching logic in handlers that receive webhooks from multiple providers.

Parse and diff captured requests

Extract fields from captured request bodies without manual JSON parsing:

import { parseBody, extractJsonField, diffRequests } from "@webhooks-cc/sdk";
 
const body = parseBody(request);
const eventType = extractJsonField<string>(request, "type");
 
// Compare two captured requests
const diff = diffRequests(previousRequest, latestRequest, {
  ignoreHeaders: ["date", "x-request-id"],
});
 
console.log(diff.matches); // false if they differ

diffRequests is useful for regression testing: capture a known-good webhook, then compare future captures against it.

Test isolation

Each test should use its own ephemeral endpoint. This prevents cross-talk between parallel test runs in CI.

// Good: each test gets a unique endpoint
it("test A", async () => {
  const ep = await client.endpoints.create({ expiresIn: "5m" });
  // ...
  await client.endpoints.delete(ep.slug);
});
 
it("test B", async () => {
  const ep = await client.endpoints.create({ expiresIn: "5m" });
  // ...
  await client.endpoints.delete(ep.slug);
});

Set expiresIn short enough that leaked endpoints don't accumulate but long enough that slow CI runs don't time out. Five to ten minutes works for most test suites.

Full flow in one call

For integration tests that don't need fine-grained control, the flow builder runs the entire cycle:

const result = await client
  .flow()
  .createEndpoint({ expiresIn: "1h" })
  .sendTemplate({
    provider: "github",
    template: "push",
    secret: "github_secret",
  })
  .waitForCapture({ timeout: "15s" })
  .verifySignature({
    provider: "github",
    secret: "github_secret",
  })
  .cleanup()
  .run();
 
expect(result.verification?.valid).toBe(true);
expect(result.cleanedUp).toBe(true);

FAQ

SDK Reference

Full API reference for endpoints, requests, matchers, and testing helpers.

Testing Helpers

captureDuring, assertRequest, withEndpoint, and withEphemeralEndpoint.