Review request

Subscriptions for a dog grooming SaaS, review my webhook and plan gating

Open · 4 views · asked by priya_ships · edited

Repo or live app

github.com/priyanair/groomer-plans

Unsure about: SecurityStructure

Three tiers, monthly. Cursor wired Stripe Checkout and a webhook that writes profiles.plan on checkout.session.completed. Cancellations and failed payments are handled by a second route. Twelve paying customers so far and one of them stayed on Pro after their card failed, which is what prompted this.

Comment

2 answers

lena_ops · edited

checkout.session.completed tells you someone started a subscription, not that they still have one. That is why your customer kept Pro.

Stop deriving the plan from individual events. On every customer.subscription.created, .updated and .deleted, write the whole state you care about:

await db.from('profiles').update({
  plan: sub.status === 'active' || sub.status === 'trialing' ? priceToPlan[sub.items.data[0].price.id] : 'free',
  current_period_end: new Date(sub.current_period_end * 1000)
}).eq('stripe_customer_id', sub.customer)

Stripe sends the full object every time, so the last event wins and out-of-order delivery stops mattering. Also store stripe_customer_id on the profile, not the session id.

Comment
jonas_k · edited

Separate finding in the same file: your webhook route reads the body with await req.json() and then calls stripe.webhooks.constructEvent on a re-stringified copy. Signature verification needs the exact raw bytes, so this passes locally with the CLI and fails against live traffic the moment Stripe formats anything differently.

In an App Router route handler use await req.text() and pass that string straight to constructEvent. Parse afterwards.

Comment