The payment cleared. The customer is in Stripe as an active subscriber. In your app, they are still on the free plan, locked out of what they just bought. Support gets an email, you flip the flag by hand, and it happens again next week.
When a paying customer is still on the free plan, the link between "Stripe says paid" and "my app says this user has the plan" is broken somewhere. That link has a few standard weak points. Check them in this order, then check the reverse problem, which is the one that costs you money.
1. The plan is flipped only on the success page
If the upgrade happens when the customer lands back on /success, it fails for anyone who closes the tab, loses connection, or pays through a path that never returns to your site (an invoice link, a payment retried after a failed card). The upgrade has to come from the Stripe event that reaches your server, which for a subscription means listening to the subscription and invoice events, not only the first checkout event. See Paid order never reached your CRM for the delivery side.
2. The Stripe customer was never linked to your user
Stripe knows a customer by email and customer ID. Your app knows a user by its own ID. If you did not store stripe_customer_id on the user, or pass your user ID into Checkout through client_reference_id or metadata, the webhook has no reliable way to find who paid. Matching on email breaks as soon as the customer pays with a different address than they signed up with.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
client_reference_id: user.id, // your user, read back in the webhook
customer_email: user.email,
line_items: [{ price: PRICE_ID, quantity: 1 }],
success_url: `${origin}/billing?done=1`,
cancel_url: `${origin}/pricing`,
})
3. Your plan lookup only knows the test prices
Prices exist separately in test and live mode, so the same plan has a different price_... ID in each. If your code maps price IDs to plans and the map contains only the test IDs, then every live customer falls through to the default branch, which is often free. This is a very common launch-day failure, because everything worked in testing.
const PLAN_BY_PRICE: Record<string, string> = {
[process.env.STRIPE_PRICE_PRO!]: 'pro', // set per environment, not hardcoded
}
const plan = PLAN_BY_PRICE[priceId]
if (!plan) throw new Error(`unknown price ${priceId}`) // fail loudly, not 'free'
Throwing on an unknown price turns a silent downgrade into an error you can see.
4. Events arrived out of order
Stripe's guide tells you not to rely on events arriving in the order they were created1. A handler that writes whatever the latest-arriving event says can let an older subscription.updated overwrite a newer one, leaving a paid account on the old plan. The safe approach is to treat the event as a signal and fetch the subscription's current state from the Stripe API before you write.
5. The session still carries the old plan
If the plan is stored in the login token or a cached user object, updating the database does not change what the logged-in browser sees until the token refreshes. The customer pays, the database says pro, and the page still says free until they log out and back in. Read the plan from the database on each request for anything that gates access, or refresh the session after the webhook.
6. The handler works once and breaks on renewals and cancellations
An upgrade handler that never handles failed payments or cancellations has the opposite bug waiting. Make sure you handle the events for a renewal that fails, a subscription that is canceled, and a plan that changes. Otherwise a customer whose card failed keeps paid access forever and a customer who upgraded stays on the old plan.
7. Nothing tells you when it happens
If the first you hear is a customer email, you have no alert. Add a daily check: list active subscriptions from Stripe, compare with users who have the paid plan in your database, and email yourself the differences in both directions.
The reverse problem: free users who get paid features
A paying customer stuck on free is a visible failure. The invisible one is a free user using paid features because the paywall exists only in the browser. Test it:
- Log in as a free account.
- Open the Network tab and trigger a paid action using a paid account's request as a model, or call the paid endpoint directly.
- If the server does the work, your paywall is a button, not a lock.
The same applies to limits: seats, projects, exports, API calls. Each one needs a server-side check against the plan stored in your database. This is the same discipline as the two-account test for cross-customer access: the browser's word is never the authority.
What the free check and the $47 scan can and cannot tell you
An outside scan has no login and no customer, so it cannot tell you whether a paying user was upgraded or whether a free user can reach a paid endpoint. The free Leak Check covers the outside view: whether the live site is reachable, the auth flow responds, a Stripe or other secret key is exposed in the client bundle, and Supabase exposure shows up.
The $47 Complete Scan probes whether the tables behind your plans and billing are readable or writable with no login at all. If a subscriptions or plan table accepts anonymous writes, anyone can upgrade themselves. It checks the stranger side only. The free-account-versus-paid-endpoint test above is yours to run.
Next step
Take one customer who paid and is stuck on free, and follow steps 1 to 3 for that account. Then run the free-account paywall test on your two most valuable paid features. After that, run the free Leak Check on your production URL. If you want billing, access rules and policies read together from the inside, that is the Ship Check.
Related: your app took the payment but no order showed up.
Sources
Market figures above are marked with a number that links here. Our own prices are marked as ours and come from our published pricing.
- 1. Receive Stripe events in your webhook endpoint, Stripe, checked November 5, 2026. Stripe's webhook guide: verify the signature against the raw request body, return a 2xx quickly, handle duplicate deliveries, and do not depend on event order.