From e1565993768803a66a1df7c07c40b40c99f264a6 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:32:20 +0000 Subject: [PATCH] fix(backend): verify Stripe webhooks with constructEventAsync Bun matches the `worker` export condition of the `stripe` package, so it loads the SubtleCrypto provider. That provider computes an HMAC only in an async context, so the synchronous `constructEvent` throws before the payload parses. Every webhook delivery returned HTTP 400 and no subscription state was written. Switch to `await constructEventAsync`, which the Node and SubtleCrypto providers both support. Add a controller test that runs real signature verification, which fails on the old sync path under Bun and passes now. Generated-By: PostHog Desktop Task-Id: 78ad5519-9602-4a28-bb96-95bea3327c03 --- .../controllers/billing.webhook.controller.ts | 2 +- .../billing.webhook.service.db.test.ts | 46 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/billing/controllers/billing.webhook.controller.ts b/packages/backend/src/billing/controllers/billing.webhook.controller.ts index 4e2a31dc4..37209f15c 100644 --- a/packages/backend/src/billing/controllers/billing.webhook.controller.ts +++ b/packages/backend/src/billing/controllers/billing.webhook.controller.ts @@ -32,7 +32,7 @@ class BillingWebhookController { } try { - const event = getStripeClient().webhooks.constructEvent( + const event = await getStripeClient().webhooks.constructEventAsync( req.body, signature, CONFIG.STRIPE_WEBHOOK_SECRET, diff --git a/packages/backend/src/billing/services/billing.webhook.service.db.test.ts b/packages/backend/src/billing/services/billing.webhook.service.db.test.ts index 23b2c4961..c20ca335c 100644 --- a/packages/backend/src/billing/services/billing.webhook.service.db.test.ts +++ b/packages/backend/src/billing/services/billing.webhook.service.db.test.ts @@ -9,7 +9,10 @@ import { import { mockEnv } from "@backend/__tests__/helpers/mock.setup"; import billingWebhookController from "@backend/billing/controllers/billing.webhook.controller"; import { processStripeEvent } from "@backend/billing/services/billing.webhook.service"; -import { setStripeClientForTests } from "@backend/billing/services/stripe.client"; +import { + getStripeClient, + setStripeClientForTests, +} from "@backend/billing/services/stripe.client"; import mongoService from "@backend/common/services/mongo.service"; import { afterAll, @@ -85,11 +88,10 @@ describe("Stripe webhook", () => { using _env = mockEnv(stripeConfigured); setStripeClientForTests({ webhooks: { - constructEvent: () => { - throw new Error( - "No signatures found matching the expected signature", - ); - }, + constructEventAsync: () => + Promise.reject( + new Error("No signatures found matching the expected signature"), + ), }, } as unknown as Stripe); @@ -110,6 +112,38 @@ describe("Stripe webhook", () => { }); }); + it("verifies a real Stripe signature under the Bun crypto provider", async () => { + using _env = mockEnv(stripeConfigured); + setStripeClientForTests(undefined); + + const payload = JSON.stringify({ + id: "evt_real_1", + type: "invoice.paid", + created: 1_775_000_100, + data: { object: {} }, + }); + const signature = + await getStripeClient().webhooks.generateTestHeaderStringAsync({ + payload, + secret: stripeConfigured.STRIPE_WEBHOOK_SECRET, + }); + + const { res, json } = jsonRes(); + await billingWebhookController.handleStripe( + { + body: Buffer.from(payload), + headers: { "stripe-signature": signature }, + } as unknown as Request, + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.OK, + ); + expect(json).toHaveBeenCalledWith({ received: true }); + expect(await mongoService.billingEvent.countDocuments()).toBe(1); + }); + it("links customer and subscription ids from checkout.session.completed", async () => { using _env = mockEnv(stripeConfigured); const userId = mongoService.objectId();