From a09ad1e09af9e87d7c7f286a457a0c061ee88d17 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 19 Sep 2026 01:42:42 +0300 Subject: [PATCH] fix(billing): read the payload that was actually sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliveries arrived intact, verified correctly, and applied to nobody. Checking the signature here rather than through the SDK means the body is exactly what was sent, and what is sent is snake_case. The SDK's parser renames fields to camelCase on the way through, so the reader written against it looked for `externalId` in a payload that says `external_id` — found nothing, decided the delivery was about a customer we did not create, and acknowledged it. That is the worst shape a bug of this kind can take. Every visible signal was healthy: a 200 back to the provider, no retries, no errors, and a plan that silently never changed. Both spellings are now read, so neither path can regress the other. --- packages/core/src/billing/polar.test.ts | 30 +++++++++++++++++++++++++ packages/core/src/billing/polar.ts | 19 +++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 989460fa..0e65e27b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -172,6 +172,36 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); }); + test('a raw snake_case payload is read, not just the SDK\u2019s camelCase', async () => { + // Verifying the signature ourselves hands back exactly what was sent, + // which is snake_case; the SDK's parser renames fields on the way + // through. Reading one spelling made every delivery verify correctly and + // then apply to nobody, which looks identical to working. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('b'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const body = JSON.stringify({ + type: 'subscription.revoked', + data: { customer: { external_id: 'tem_snake' }, product_id: PAID } + }); + const id = 'msg_snake'; + const timestamp = new Date(); + const signature = new Webhook(secret).sign(id, timestamp, body); + + const delivery = Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': signature + } + }); + + expect(delivery.teamId).toBe('tem_snake'); + expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' }); + }); + test('a tampered body under a valid-looking signature is refused', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 8c2e09ba..9cb06b6c 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -341,18 +341,27 @@ export namespace Polar { throw error; } + // Both spellings, for both paths. The SDK's parser renames fields to + // camelCase on the way through; verifying the signature ourselves + // hands back exactly what was sent, which is snake_case. Reading only + // one spelling makes every delivery arrive intact, verify correctly, + // and then quietly apply to nobody. const data = event.data ?? {}; - const customer = data.customer as { externalId?: string | null } | undefined; + const customer = data.customer as + | { externalId?: string | null; external_id?: string | null } + | undefined; // `externalId` is the team id we put on the customer. A delivery // without one is about a customer created some other way — by hand in // their dashboard, most likely — and there is nothing here it can // change. - const teamId = customer?.externalId ?? null; + const teamId = customer?.externalId ?? customer?.external_id ?? null; - // Both spellings, because which one a payload carries depends on - // whether the product was expanded into it. const product = data.product as { id?: string } | undefined; - const productId = (data.productId as string | undefined) ?? product?.id ?? null; + const productId = + (data.productId as string | undefined) ?? + (data.product_id as string | undefined) ?? + product?.id ?? + null; return { type: event.type, teamId, standing: standingFor(event.type, productId) }; }