Standard Webhooks + Vitest
Test Standard Webhooks handlers (Polar, Svix, Clerk, Resend) locally using @webhooks-cc/sdk sendTo with signed payloads and Vitest assertions.
Updated Mar 2026
Test webhook handlers for services that use the Standard Webhooks spec — Polar.sh, Svix, Clerk, Resend, and others. Uses sendTo to send signed payloads directly to localhost.
Test a Polar webhook handler
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
describe("polar webhook handler", () => {
it("processes subscription.created", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/polar", {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: {
type: "subscription.created",
timestamp: new Date().toISOString(),
data: {
id: "sub_test_123",
status: "active",
customer: {
id: "cust_1",
email: "[email protected]",
name: "Test User",
},
recurring_interval: "month",
current_period_start: "2026-03-08T00:00:00Z",
current_period_end: "2026-04-08T00:00:00Z",
},
},
});
expect(res.status).toBe(200);
});
it("processes subscription.canceled", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/polar", {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
body: {
type: "subscription.canceled",
timestamp: new Date().toISOString(),
data: {
id: "sub_test_123",
status: "canceled",
customer: {
id: "cust_1",
email: "[email protected]",
name: "Test User",
},
},
},
});
expect(res.status).toBe(200);
});
it("rejects invalid signatures", async () => {
const res = await client.sendTo("http://localhost:3000/api/webhooks/polar", {
provider: "standard-webhooks",
secret: "d3Jvbmctc2VjcmV0", // wrong secret (base64 of "wrong-secret")
body: { type: "subscription.created", data: {} },
});
// Your handler should reject invalid signatures
expect(res.status).toBe(401);
});
});How Standard Webhooks signing works
The SDK generates three headers per the Standard Webhooks spec:
| Header | Value |
|---|---|
webhook-id | Unique message ID (e.g. msg_a1b2c3...) |
webhook-timestamp | Unix timestamp in seconds |
webhook-signature | v1,<base64(HMAC-SHA256(secret, msgId.timestamp.body))> |
Secrets with a whsec_ prefix are handled automatically. The SDK auto-detects whether the secret is base64-encoded (Svix, Clerk) or raw UTF-8 (Polar.sh) after stripping the prefix.
Body timestamp field
Many Standard Webhooks providers (including Polar.sh) require a timestamp ISO-8601 field in the payload body in addition to the webhook-timestamp header. The SDK generates the header automatically but you must include the body field yourself:
body: {
type: "subscription.created",
timestamp: new Date().toISOString(), // required by Polar
data: { /* ... */ },
}Capture and inspect with webhooks.cc
You can also send Standard Webhooks to a webhooks.cc endpoint using sendTemplate, then inspect the headers and signature:
import { WebhooksCC, isStandardWebhook, matchHeader } from "@webhooks-cc/sdk";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
describe("standard webhooks inspection", () => {
let endpoint: Awaited<ReturnType<typeof client.endpoints.create>>;
beforeAll(async () => {
endpoint = await client.endpoints.create({ name: "stdwhk-test" });
});
afterAll(async () => {
await client.endpoints.delete(endpoint.slug);
});
it("sends and captures a signed Standard Webhook", async () => {
await client.endpoints.sendTemplate(endpoint.slug, {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
event: "subscription.created",
body: { type: "subscription.created", data: { id: "sub_1" } },
});
const req = await client.requests.waitFor(endpoint.slug, {
timeout: "10s",
match: matchHeader("webhook-signature"),
});
expect(isStandardWebhook(req)).toBe(true);
expect(req.headers["webhook-id"]).toMatch(/^msg_subscription\.created_/);
expect(req.headers["webhook-signature"]).toMatch(/^v1,/);
const body = JSON.parse(req.body!);
expect(body.type).toBe("subscription.created");
});
});Deterministic signatures for snapshots
Pass a fixed timestamp to generate deterministic signatures, useful for snapshot testing:
const res = await client.sendTo("http://localhost:3000/api/webhooks", {
provider: "standard-webhooks",
secret: "dGVzdC1zZWNyZXQ=", // base64("test-secret")
timestamp: 1700000000, // fixed timestamp
body: { type: "test.event", data: {} },
});Services using Standard Webhooks
| Service | Secret format | Notes |
|---|---|---|
| Polar.sh | whsec_<raw-string> | Secret is raw UTF-8, not base64 after prefix |
| Svix | whsec_<base64> | Spec-compliant base64 after prefix |
| Clerk | whsec_<base64> | Spec-compliant |
| Resend | whsec_<base64> | Spec-compliant |
| Liveblocks | whsec_<base64> | Spec-compliant |
| Novu | whsec_<base64> | Spec-compliant |
The SDK auto-detects the format. If the part after whsec_ isn't valid base64, it falls back to raw UTF-8 bytes.
More examples
Stripe + Vitest
Payment webhook assertions.
GitHub + Jest
Push event verification.
Polar.sh + Playwright
Subscription lifecycle E2E testing.
Playwright E2E
Browser flow + webhook assertions.