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/sdkStore 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 testThe 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 differdiffRequests 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.