Debounce helps but isn't enough. Belt and braces:
- Before creating a checkout session, check if the customer already has an active/trialing subscription. If yes, send them to the customer portal instead.
- Reuse one Stripe customer per user. Store
stripe_customer_idon your user and passcustomer:to the session. Without it every checkout can create a new customer and you can't even detect duplicates. - Disable the button while the request is running (real disabled state, not debounce).
- Make the webhook idempotent. Unique constraint on
stripe_subscription_idand upsert. Also store processedevent.ids:Insert first, and if it conflicts, skip. Stripe retries and can deliver an event more than once.create table stripe_events (id text primary key, processed_at timestamptz default now());
Refunding and cancelling in Stripe was right. Your webhook should then pick up customer.subscription.deleted and clean up the second row.