Fix guides / Stripe payment succeeded but the user is not upgraded
Stripe says paid, but your app still shows the free plan
A customer pays through Stripe Checkout, Stripe shows the payment as succeeded, and your app still treats them as a free user. In AI-built apps this is almost always the webhook: it is not arriving, not verified, or not finding the right user.
How unlocking is supposed to work
- Your app sends the user to Stripe Checkout, with something that identifies them.
- Stripe sends a
checkout.session.completedevent to your webhook endpoint. - Your webhook verifies it, finds the user, and updates their plan in your database.
Do not unlock features from the success page alone. Anyone can visit a success URL; only the webhook is proof of payment.
Step 1: is the event arriving?
Stripe Dashboard, Developers, Webhooks (Workbench), your endpoint. Look at recent deliveries:
- No endpoint at all, or it points to localhost or an old preview URL: add your production URL, for example
https://yourapp.com/api/stripe-webhook. - Endpoint exists in test mode only: live payments need a live-mode endpoint, with its own secret.
- The event is not selected: make sure
checkout.session.completedis in the endpoint's event list. - Response 400: signature problem. See our guide on Stripe signature errors.
- Response 401: on Supabase Edge Functions, deploy with
--no-verify-jwt. - Response 500: your code crashed. Read your function logs; a missing secret is the usual culprit.
Step 2: can the webhook tell which user paid?
Pass the user's id when you create the Checkout Session, then read it back in the webhook:
// creating the session (server side)
const session = await stripe.checkout.sessions.create({
mode: 'payment', // or 'subscription'
line_items: [{ price: PRICE_ID, quantity: 1 }],
client_reference_id: user.id,
customer_email: user.email,
success_url: `${origin}/thanks`,
cancel_url: `${origin}/pricing`,
});
// in the webhook
if (event.type === 'checkout.session.completed') {
const s = event.data.object;
const userId = s.client_reference_id;
// update this user's plan
}
If you use a Stripe Payment Link instead, add ?client_reference_id=USER_ID to the link URL so the session carries it.
Step 3: is the database update allowed?
The webhook runs without a logged-in user, so Row Level Security blocks a normal client. On the server only, use the Supabase service role key (never in front-end code) for this update, or a database function designed for it. Check that the column and table names in the update match your schema exactly.
Step 4: secrets on the server
Webhook code needs STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET on the server where it runs. For Supabase Edge Functions set them with supabase secrets set STRIPE_SECRET_KEY=... STRIPE_WEBHOOK_SECRET=.... Test keys start with sk_test_, live keys with sk_live_; do not mix modes.
Fixing customers who already paid
After the fix, find the missed events in the Dashboard and click Resend on each. Your webhook should be safe to run twice for the same event (check whether the user is already upgraded before changing anything).
Request a fix
Last updated 2026-10-19.