Testing webhooks end-to-end with Playwright, Vitest or Jest
Your app sends a webhook when an order is paid, a user signs up or a document is ready. The test you want is simple to say — “after this action, a POST with this body and a valid signature reaches the customer's URL” — and awkward to write, because the request leaves your process. The trick: point the app at a real, disposable capture URL, then ask that URL what it received.
1. Install
npm i -D webhook-toolkit
2. Playwright: assert on the webhook your UI flow triggers
import { test, expect } from "@playwright/test";
import { WebhookToolkit } from "webhook-toolkit";
import crypto from "node:crypto";
const wt = new WebhookToolkit({ apiKey: process.env.WEBHOOK_TOOLKIT_KEY });
test("paying an order notifies the merchant webhook", async ({ page }) => {
// A fresh URL per test: parallel workers never see each other's requests.
const endpoint = await wt.createEndpoint({ name: "e2e-order-paid" });
// Configure the app under test to send its webhook there.
await page.goto("/settings/webhooks");
await page.getByLabel("Webhook URL").fill(endpoint.url);
await page.getByRole("button", { name: "Save" }).click();
// The user flow that should emit the webhook.
await page.goto("/orders/42");
await page.getByRole("button", { name: "Mark as paid" }).click();
const req = await wt.waitForRequest(endpoint.token, {
timeoutMs: 30_000,
filter: (r) => r.method === "POST" && JSON.parse(r.body).type === "order.paid",
});
expect(req.headers["content-type"]).toContain("application/json");
expect(JSON.parse(req.body).data.order_id).toBe(42);
// Verify the signature exactly like your customers will.
const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET!).update(req.body).digest("hex");
expect(req.headers["x-signature"]).toBe(expected);
});3. Vitest or Jest: the same, without a browser
import { WebhookToolkit } from "webhook-toolkit";
import { markOrderPaid } from "../src/orders";
const wt = new WebhookToolkit();
it("sends order.paid", async () => {
const endpoint = await wt.createEndpoint();
await markOrderPaid({ orderId: 42, webhookUrl: endpoint.url });
const req = await wt.waitForRequest(endpoint.token, { timeoutMs: 10_000 });
expect(JSON.parse(req.body)).toMatchObject({ type: "order.paid", data: { order_id: 42 } });
});Jest needs its ESM mode for this package (NODE_OPTIONS=--experimental-vm-modules); Vitest and Playwright work out of the box.
4. Testing the receiving side: signed events
import { sign } from "webhook-toolkit";
it("accepts a valid Stripe event and rejects a tampered one", async () => {
const { headers, body } = sign("stripe", {
secret: process.env.STRIPE_WEBHOOK_SECRET!,
payload: { id: "evt_1", type: "invoice.paid", data: { object: { amount_paid: 900 } } },
});
const ok = await fetch("http://localhost:3000/api/stripe/webhook", { method: "POST", headers, body });
expect(ok.status).toBe(200);
const bad = await fetch("http://localhost:3000/api/stripe/webhook", { method: "POST", headers, body: body.replace("900", "1") });
expect(bad.status).toBe(400);
});5. Debugging a failing run
Every URL has a web inspector at /e/<token>: log endpoint.inspectUrl in your test and open it when a run fails — you will see exactly what arrived (or that nothing did). The REST API is the same from any language, and AI agents can drive it through the MCP server.
Frequently asked questions
Why not mock the HTTP call instead?
Mocks prove your code calls a function; they do not prove the webhook leaves your system with the right URL, headers, body and signature. A capture URL receives the real HTTP request, so the test fails on the bugs users would actually see: wrong content type, re-serialized body, missing signature, retries.
Is it safe to run in CI?
Yes. Each test run creates its own URL, so parallel jobs never read each other's requests. Without an API key, URLs are anonymous and expire after 7 days (30 creations per IP per day); with a key from a free account they are tied to your account — set WEBHOOK_TOOLKIT_KEY as a CI secret.
What if the webhook is sent before I start waiting?
waitForRequest() first looks at the requests already captured by the URL since it was created, then long-polls. The order of trigger and wait does not matter, and a filter lets you skip unrelated requests.
Can I test the receiving side too?
Yes: sign() builds a payload with a valid signature for Stripe, GitHub, Shopify, Slack, Twilio, Mailgun, Svix, Paddle or Discord, so you can POST it to your own handler and assert it is accepted — and that a tampered one is rejected.