Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { WebhookSubscriptionCreatedPayload } from "@polar-sh/sdk/models/com
import type { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js";
import {
WebhookVerificationError,
validateEvent,
type validateEvent,
} from "@polar-sh/sdk/webhooks";
import {
type FunctionReference,
Expand All @@ -36,6 +36,7 @@ import {
convertToDatabaseSubscription,
} from "../component/util.js";
import type { ComponentApi } from "../component/_generated/component.js";
import { validatePolarEvent } from "./webhooks.js";

export const subscriptionValidator = schema.tables.subscriptions.validator;
export type Subscription = Infer<typeof subscriptionValidator>;
Expand Down Expand Up @@ -486,7 +487,7 @@ export class Polar<
const body = await request.text();
const headers = Object.fromEntries(request.headers.entries());
try {
const event = validateEvent(body, headers, this.webhookSecret);
const event = validatePolarEvent(body, headers, this.webhookSecret);

// Built-in handling: persist subscriptions and products
switch (event.type) {
Expand Down
94 changes: 94 additions & 0 deletions src/client/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Buffer } from "buffer";
import { WebhookVerificationError } from "@polar-sh/sdk/webhooks";
import { Webhook } from "standardwebhooks";
import { describe, expect, test } from "vitest";
import { validatePolarEvent } from "./webhooks.js";

const body = JSON.stringify({
type: "customer.deleted",
timestamp: new Date().toISOString(),
data: {
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
modified_at: null,
metadata: {},
email: "customer@example.com",
email_verified: true,
type: "individual",
name: null,
billing_address: null,
tax_id: null,
organization_id: crypto.randomUUID(),
deleted_at: new Date().toISOString(),
avatar_url: "https://example.com/avatar.png",
},
});

/** A secret generated on or after 2026-09-08: whsec_ and a base64 key. */
function standardSecret() {
return `whsec_${Buffer.from(
crypto.getRandomValues(new Uint8Array(32)),
).toString("base64")}`;
}

/** Signs the way Polar does for secrets generated before 2026-09-08. */
function signWithLegacyKey(secret: string) {
return sign(new Webhook(Buffer.from(secret, "utf-8").toString("base64")));
}

/** Signs the way Polar does for secrets generated on or after 2026-09-08. */
function signWithStandardKey(secret: string) {
return sign(new Webhook(secret));
}

function sign(webhook: Webhook) {
const timestamp = new Date();
return {
"webhook-id": "msg_test",
"webhook-timestamp": Math.floor(timestamp.getTime() / 1000).toString(),
"webhook-signature": webhook.sign("msg_test", timestamp, body),
};
}

describe("validatePolarEvent", () => {
test("parses an event signed with a legacy secret", () => {
const secret = "polar_whs_legacy";
const event = validatePolarEvent(body, signWithLegacyKey(secret), secret);
expect(event.type).toBe("customer.deleted");
});

test("parses an event signed with a legacy whsec_ secret", () => {
const secret = "whsec_legacy-secret!";
const event = validatePolarEvent(body, signWithLegacyKey(secret), secret);
expect(event.type).toBe("customer.deleted");
});

test("parses an event signed with a Standard Webhooks secret", () => {
const secret = standardSecret();
const event = validatePolarEvent(body, signWithStandardKey(secret), secret);
expect(event.type).toBe("customer.deleted");
});

test("rejects a signature made with another Standard Webhooks secret", () => {
const headers = signWithStandardKey(standardSecret());
expect(() => validatePolarEvent(body, headers, standardSecret())).toThrow(
WebhookVerificationError,
);
});

test("rejects a signature made with another legacy whsec_ secret", () => {
const headers = signWithLegacyKey("whsec_legacy-secret!");
expect(() =>
validatePolarEvent(body, headers, "whsec_other-secret!"),
).toThrow(WebhookVerificationError);
});

test("rejects a body that differs from the signed one", () => {
const secret = standardSecret();
const headers = signWithStandardKey(secret);
const tamperedBody = body.replace("customer@example.com", "x@example.com");
expect(() => validatePolarEvent(tamperedBody, headers, secret)).toThrow(
WebhookVerificationError,
);
});
});
74 changes: 74 additions & 0 deletions src/client/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Buffer } from "buffer";
import { Webhook } from "standardwebhooks";
import {
WebhookVerificationError,
validateEvent,
} from "@polar-sh/sdk/webhooks";

const STANDARD_WEBHOOKS_SECRET_PREFIX = "whsec_";

/**
* Verifies a Polar webhook delivery and parses its event.
*
* Polar signs with one of two keys, depending on when the endpoint secret was
* generated (https://polar.sh/docs/integrate/webhooks/delivery):
*
* - Before 2026-09-08: the UTF-8 bytes of the whole secret. This is the only
* key `validateEvent` from `@polar-sh/sdk` 0.x knows.
* - On or after 2026-09-08: Standard Webhooks, where the key is the base64
* part of the `whsec_` secret.
*
* Secrets of both kinds can start with `whsec_`, so the legacy key is tried
* first and the Standard Webhooks key second.
*/
export function validatePolarEvent(
body: string,
headers: Record<string, string>,
secret: string,
): ReturnType<typeof validateEvent> {
try {
return validateEvent(body, headers, secret);
} catch (error) {
if (
!(error instanceof WebhookVerificationError) ||
!secret.startsWith(STANDARD_WEBHOOKS_SECRET_PREFIX)
) {
throw error;
}
if (!hasStandardWebhooksSignature(body, headers, secret)) {
throw error;
}
// validateEvent is the SDK's only public event parser, and it only knows
// the legacy key. The body is verified at this point, so sign it with the
// legacy key for the parser.
const legacySignature = new Webhook(legacyKey(secret)).sign(
headers["webhook-id"],
new Date(Number(headers["webhook-timestamp"]) * 1000),
body,
);
return validateEvent(
body,
{ ...headers, "webhook-signature": legacySignature },
secret,
);
}
}

function hasStandardWebhooksSignature(
body: string,
headers: Record<string, string>,
secret: string,
): boolean {
try {
// The constructor throws when the part after whsec_ is not base64, which
// means this is a legacy secret.
new Webhook(secret).verify(body, headers);
return true;
} catch {
return false;
}
}

function legacyKey(secret: string): string {
return Buffer.from(secret, "utf-8").toString("base64");
}