Missing idempotency in webhook endpoints (+ related robustness gaps)
Summary
A review of the Stripe webhook handler (src/payment/stripe/webhook.ts) against Stripe's official webhook best practices and signature verification docs surfaced a few correctness/robustness gaps. The most important is the lack of idempotency, which can double-count purchased credits.
What's already correct ✅
- Raw request body is preserved via
stripeMiddlewareConfigFn(express.jsonremoved,express.rawinstalled) — the most common webhook bug, handled correctly. - Signature verification via
stripeClient.webhooks.constructEvent(rawBody, signature, secret); missing signature header throws →400. - Explicit event-type allow-list; unhandled events return
2xxso Stripe doesn't retry them indefinitely. - Replay/timestamp tolerance handled by the SDK default.
Findings
High — No idempotency; duplicate invoice.paid double-counts credits
Stripe delivers events at-least-once and retries on any non-2xx response. updateUserCredits increments credits (credits: { increment: numOfCreditsPurchased } in src/payment/user.ts), so a redelivered invoice.paid event for the Credits10 plan grants the credits again. Subscription updates are naturally idempotent (they set absolute values), but credit grants are not.
Suggested fix: Persist each processed event.id (e.g. a ProcessedStripeEvent model with a unique constraint) and short-circuit if already seen, before performing any mutation. See "Handle duplicate events" in the Stripe docs.
Medium — Cancellation email can be sent repeatedly
customer.subscription.updated fires frequently. The handler sends the cancellation ("We hate to see you go") email on every update where cancel_at_period_end is true — not just on the transition. Combined with the idempotency gap, users can receive duplicate emails. Gate it on the actual false → true transition (tracked in the DB) or behind event-id dedupe.
Medium — Synchronous processing (including email send) before returning 2xx
Stripe recommends returning 2xx immediately and doing heavy work asynchronously, because a slow handler can time out → Stripe retries → duplicate processing. The handler currently does DB writes and an emailSender.send network call inside the request path before responding. Consider offloading to a Wasp job. (Acceptable at low volume; flagging as a scaling concern.)
Low — Deterministic business errors return 400 → repeated pointless retries
The catch-all returns 400 for any Error, which is correct for signature/transient failures (a retry is desirable) but also applies to deterministic failures like "There should be exactly one line item" / "Unable to extract price id". Stripe will keep retrying those even though they can never succeed. Consider distinguishing transient errors (400, retry) from permanent business-logic errors (log + 2xx, no retry) — similar to how UnhandledWebhookEventError is already handled.
Low — Event ordering assumed
Subscription handlers act on the event payload snapshot. Stripe does not guarantee ordering, so a stale customer.subscription.updated could overwrite newer state. The docs suggest fetching the latest object from the API when ordering matters. Likely fine given the current status-mapping logic, but noted.
Suggested priority
- Add event-id idempotency (fixes the High + first Medium at once).
- Distinguish retry vs. no-retry per error class.
- Consider async processing as volume grows.
References
Source: wasp-lang/open-saas