Polar.sh + Playwright
Simulate the full Polar subscription lifecycle — created, updated, canceled, reactivated — by sending signed Standard Webhooks payloads to your localhost handler with Playwright.
Updated Mar 2026
Simulate the full Polar subscription lifecycle — created, updated, canceled, reactivated — by sending signed Standard Webhooks payloads directly to your localhost handler. No Polar dashboard needed.
Full lifecycle test
Use test.describe.serial so each step depends on the previous one creating state in your database:
import { test, expect } from "@playwright/test";
import { WebhooksCC } from "@webhooks-cc/sdk";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const WEBHOOK_URL = "http://localhost:3000/api/webhooks/polar";
test.describe.serial("Polar subscription lifecycle", () => {
test("subscription.created provisions tenant", async () => {
const res = await client.sendTo(WEBHOOK_URL, {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: {
type: "subscription.created",
timestamp: new Date().toISOString(),
data: {
id: "sub_test_001",
status: "active",
customer: {
id: "cust_001",
email: "[email protected]",
name: "Test User",
},
product: {
id: "prod_001",
name: "Pro Plan",
},
recurring_interval: "month",
current_period_start: "2026-03-08T00:00:00Z",
current_period_end: "2026-04-08T00:00:00Z",
},
},
});
const text = await res.text();
expect(res.status, `Server responded: ${text}`).toBe(200);
});
test("subscription.updated upgrades plan", async () => {
const res = await client.sendTo(WEBHOOK_URL, {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: {
type: "subscription.updated",
timestamp: new Date().toISOString(),
data: {
id: "sub_test_001",
status: "active",
customer: {
id: "cust_001",
email: "[email protected]",
name: "Test User",
},
product: {
id: "prod_002",
name: "Enterprise Plan",
},
recurring_interval: "year",
current_period_end: "2027-03-08T00:00:00Z",
},
},
});
const text = await res.text();
expect(res.status, `Server responded: ${text}`).toBe(200);
});
test("subscription.canceled suspends tenant", async () => {
const res = await client.sendTo(WEBHOOK_URL, {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: {
type: "subscription.canceled",
timestamp: new Date().toISOString(),
data: {
id: "sub_test_001",
status: "canceled",
cancel_at_period_end: true,
customer: {
id: "cust_001",
email: "[email protected]",
name: "Test User",
},
},
},
});
const text = await res.text();
expect(res.status, `Server responded: ${text}`).toBe(200);
});
});Inspect what the SDK sends
Use buildRequest to inspect the computed headers and signature without sending:
const { url, method, headers, body } = await client.buildRequest(
"http://localhost:3000/api/webhooks/polar",
{
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: { type: "subscription.created", data: { id: "sub_1" } },
}
);
console.log(headers["webhook-signature"]); // v1,<base64>
console.log(headers["webhook-id"]); // msg_<hex>
console.log(body); // JSON stringTips
- Capture first, test later — Don't build payloads from scratch. Point your Polar webhook to a webhooks.cc endpoint first, trigger a real event, then copy the captured payload as your test fixture. Polar's SDK validates many required fields in snake_case — getting them right manually is tedious.
- snake_case field names — Polar's webhook payloads use snake_case (
recurring_interval,current_period_end). Their Zod schema rejects camelCase. - Body timestamp — Polar requires a
timestampISO-8601 field in the body alongsidetypeanddata. The SDK generates the header timestamp but the body timestamp is your responsibility. - Error diagnosis — If your handler returns 500, read the response body:
const text = await res.text(); expect(res.status, text).toBe(200);
More examples
Standard Webhooks + Vitest
Handler testing with signed payloads.
Playwright E2E
Browser flow + webhook assertions.
Stripe + Vitest
Payment webhook assertions.