Stripe paid, but the user was not upgraded

Checkout shows paid and the account stays on the free plan? Upgrade the user from checkout.session.completed, not from the success page.

Reviewed October 17, 2026

What it means in plain English

Stripe took the money. Your database still says free. The success page is not a reliable place to grant access. Stripe says a customer can pay and then lose their connection before that page loads, so fulfillment has to run from a webhook. For subscriptions, and for payment methods that succeed later, Stripe says automatic fulfillment with webhooks is required.

Likely causes

  • The only "upgrade" code is in the success-page component. That code never ran, or the customer never opened the page.
  • The webhook endpoint is failing, so checkout.session.completed never reaches your handler. See Stripe webhook 404, redirect, or 500.
  • The handler rejects the signature, so it returns an error before it writes the row. See Stripe webhook signature verification failed.
  • Vercel Deployment Protection answers 401, and Stripe cannot send your bypass header unless it is in the URL. See Deployment Protection blocking webhooks.
  • The handler runs, then the Supabase update is refused by RLS because it used the publishable key instead of a server key that is allowed to write that row.

How to fix it, step by step

  1. Stripe Workbench → Webhooks → your endpoint → Event deliveries. Find checkout.session.completed for the payment. A failed delivery is a webhook problem, not a Checkout problem.
  2. On the server, verify the signature, then fulfill when payment_status is not unpaid. That is the check in Stripe's fulfillment sample. Grant the plan in that function, and record that this Checkout Session was already fulfilled so a retry does not upgrade the user twice.
  3. Stripe's guide says to listen for checkout.session.completed and checkout.session.async_payment_succeeded. Delayed methods fire the second event when the payment later succeeds.
  4. Keep the secret key and the whsec_ signing secret on the server. Update the user with a server client. If the row lives in Supabase, a browser client using the publishable key still has to pass RLS.
  5. You can also run the same fulfill function when the success page loads, so a customer who is still on the site gets access without waiting. Stripe says the webhook is still required, because the redirect is not guaranteed. Return a 2xx from the webhook quickly.
  6. Paste this to the AI tool:
Stripe Checkout can succeed while the user's plan in the database stays free. On the server, handle checkout.session.completed and checkout.session.async_payment_succeeded. Verify the Stripe-Signature header with the webhook secret. If session.payment_status is not "unpaid" and this session is not already fulfilled, update that user's plan once. Do not upgrade the user only in the success page. Do not put the secret key or whsec_ value in client code.

How to check it worked

  • Resend the event from Event deliveries. The delivery is 200, and the user's row shows the new plan.
  • A second resend does not create a second upgrade.
  • A new test payment upgrades the user even if you close the tab before the success page finishes loading.

Sources

More Stripe fixes

Keep going on your own.

The guides stay free. A deep leak report is $15. Fix packs are $24, or $49 for all three. There is no done-for-you repair.

See products