Playwright E2E
Drive your app in a real browser with Playwright and assert on the webhooks that flow in and out: what your app sends after a click, and how the UI reacts when a provider's webhook arrives.
Updated Oct 2026
Unit tests check a webhook handler on its own. A Playwright test checks the whole path: a person clicks through your app, a webhook goes out or comes in, and the page shows the result. The SDK covers both directions:
- Webhooks your app sends. Point your app at a temporary webhooks.cc endpoint, click through the flow, and wait for the webhook to be captured.
- Webhooks your app receives. Play the provider: send a correctly signed event to your handler with
sendTo, then check that the page updates, without a real Stripe checkout in the test.
Setup
Install the SDK and set the API key
npm install --save-dev @webhooks-cc/sdk @playwright/testCreate an API key on your API keys page and expose it to the tests as WHK_API_KEY.
Add an endpoint fixture
A fixture gives every test its own endpoint and deletes it afterwards, so tests running in parallel never read each other's webhooks. expiresIn removes the endpoint even if a worker crashes before the cleanup runs.
// tests/fixtures.ts
import { test as base, expect } from "@playwright/test";
import { WebhooksCC, type Endpoint } from "@webhooks-cc/sdk";
const whk = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
export const test = base.extend<{ whk: WebhooksCC; webhookEndpoint: Endpoint }>({
whk: async ({}, use) => {
await use(whk);
},
webhookEndpoint: async ({}, use, testInfo) => {
const endpoint = await whk.endpoints.create({
name: `pw-${testInfo.parallelIndex}-${Date.now()}`,
expiresIn: "15m",
});
try {
await use(endpoint);
} finally {
await whk.endpoints.delete(endpoint.slug);
}
},
});
export { expect };Assert on a webhook your app sends
This test configures the app's outgoing webhook URL through its own settings page, completes a checkout, and waits for the checkout.completed webhook:
// tests/checkout-webhook.spec.ts
import { matchAll, matchMethod, matchJsonField } from "@webhooks-cc/sdk";
import { assertRequest } from "@webhooks-cc/sdk/testing";
import { test, expect } from "./fixtures";
test("checkout sends a checkout.completed webhook", async ({ page, whk, webhookEndpoint }) => {
await page.goto("/settings/webhooks");
await page.getByLabel("Webhook URL").fill(webhookEndpoint.url!);
await page.getByRole("button", { name: "Save" }).click();
await page.goto("/checkout");
await page.getByLabel("Email").fill("qa@example.com");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
const request = await whk.requests.waitFor(webhookEndpoint.slug, {
timeout: "15s",
match: matchAll(matchMethod("POST"), matchJsonField("event", "checkout.completed")),
});
assertRequest(
request,
{
method: "POST",
bodyJson: { event: "checkout.completed", data: { email: "qa@example.com" } },
},
{ throwOnFailure: true }
);
});bodyJson is a partial match: only the fields you list are compared, so the test keeps passing when the payload gains fields. When the assertion fails, the error lists every field that differs.
Complete a flow with a signed provider event
Many flows finish only when a provider's webhook arrives: the order page says "Waiting for payment" until Stripe sends checkout.session.completed. Instead of paying through Stripe's hosted page in the test, send the event yourself, signed with the same secret your handler checks:
// tests/payment-confirmed.spec.ts
import { test, expect } from "./fixtures";
test("order page turns paid when Stripe confirms", async ({ page, whk }) => {
await page.goto("/checkout");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByText("Waiting for payment")).toBeVisible();
const orderId = new URL(page.url()).searchParams.get("order")!;
const res = await whk.sendTo("http://localhost:3000/api/webhooks/stripe", {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
body: {
id: `evt_e2e_${orderId}`,
type: "checkout.session.completed",
data: {
object: {
id: `cs_test_${orderId}`,
payment_status: "paid",
metadata: { orderId },
},
},
},
});
expect(res.status).toBe(200);
await expect(page.getByText("Payment received")).toBeVisible();
});sendTo computes the stripe-signature header, so your handler's constructEvent call runs unchanged. The same works for the other signed providers; for Standard Webhooks senders such as Polar, see Polar + Playwright.
Run it in CI
Let Playwright start the app and pass the secrets through the environment:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
fullyParallel: true,
// Room for the clicks before a webhook wait plus the wait itself; the
// default of 30 seconds can run out first.
timeout: 60_000,
use: { baseURL: "http://localhost:3000" },
webServer: {
command: "npm run start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});# .github/workflows/e2e.yml (excerpt)
env:
WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testEach captured webhook counts toward your plan's request quota; sendTo calls go straight to your app and do not. See Plans & Limits.
Tips
- One endpoint per test. The fixture above does this; a shared endpoint makes parallel tests pick up each other's webhooks.
- Match the exact webhook. An app may send several webhooks for one action. Match on the event field rather than taking the first request that arrives.
- Keep the test timeout above the webhook wait. Playwright's timeout covers the fixture, every click, and the wait, so a 30-second
waitForin a 30-second test can never finish. - Wait for the page first. Assert on the UI state that follows the action before waiting for the webhook, so a failing click fails with a clear message instead of a webhook timeout.
- Testing emails too? The same endpoint receives email. Test signup and login emails in Playwright reads confirmation codes and magic links in the same kind of test.