critical94%Security
Webhook signature verification skipped on retry path
src/billing/webhooks.ts:142
The retry branch re-processes the raw payload without calling verifyStripeSignature() — only the first-attempt path validates. A forged POST to the retry endpoint would be accepted and mutate subscription state. Move verification above the attempt branch so every path is covered.
high86%Correctness
Race condition between webhook handler and seat refresh
src/billing/sync.ts:73
refreshSeats() reads seat_count, then writes it back after an await on the Stripe API. A concurrent customer.subscription.updated webhook between the read and the write silently loses one of the two updates. Use an atomic UPDATE … SET seat_count = excluded.seat_count or take a row lock for the read-modify-write.
suggested change · committable
- const current = await getSeatCount(accountId);
- await stripe.subscriptions.update(subId, { quantity: current + 1 });
- await setSeatCount(accountId, current + 1);
+ await db.rpc('increment_seats_atomic', { account_id: accountId });
+ await stripe.subscriptions.update(subId, { quantity: await getSeatCount(accountId) });
high79%Architecture
Stripe client constructed inside the request handler
src/billing/index.ts:21
new Stripe(…) per request re-parses the key, re-creates the agent pool, and defeats keep-alive. Hoist it to module scope (the SDK is stateless across requests) — this is also where a request-scoped client quietly leaks API version overrides across handlers.
medium68%Correctness
Idempotency key derived from event.id only
src/billing/webhooks.ts:89
Stripe replays the same event.id on automatic retries, but a manually re-sent event from the dashboard mints a NEW id for the same logical change — the dedupe table won’t catch it. Key on (event.type, subscription.id, created) for state-transition events, or accept the rare double-apply and make the handler idempotent on content.