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

  1. Your app sends the user to Stripe Checkout, with something that identifies them.
  2. Stripe sends a checkout.session.completed event to your webhook endpoint.
  3. 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.completed is 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).

Still stuck? No shame in it: these bugs hide in settings your AI builder cannot see. Send us the error and your repo and we will fix it as a pull request within 24 hours, explained in plain English, for $49. Not fixed means full refund.
Request a fix

Last updated 2026-10-19.