#688·open-saas

Missing idempotency in webhook endpoints (+ related robustness gaps)

Author: vincangerCreated May 27, 2026Updated Sep 17, 2026

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.json removed, express.raw installed) — 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 2xx so 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

  1. Add event-id idempotency (fixes the High + first Medium at once).
  2. Distinguish retry vs. no-retry per error class.
  3. Consider async processing as volume grows.

References