Fix guides

Stripe checkout works but the user is still on the free plan

Payment succeeds, the plan never unlocks. How to debug Stripe webhooks in an AI-built app: signature errors, wrong secret, raw body, and missing events.

What you are seeing

Why it happens

  1. No webhook endpoint is registered for the production URL, or it points at a preview deployment.
  2. The signing secret (whsec_...) in production is the one from stripe listen, not the dashboard endpoint's secret.
  3. The framework parsed the body as JSON before verification. Stripe needs the raw body to check the signature.
  4. The endpoint is not subscribed to checkout.session.completed (or the subscription events you rely on).
  5. The handler cannot tell which user paid because no client_reference_id or metadata was set on the session.
  6. Test mode keys in one place and live mode keys in another.

How to fix it

1. Pass your user id when you create the session

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  client_reference_id: user.id,
  line_items: [{ price: PRICE_ID, quantity: 1 }],
  success_url, cancel_url,
});

2. Verify against the raw body (Next.js App Router)

export async function POST(req) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature');
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }
  if (event.type === 'checkout.session.completed') {
    const s = event.data.object;
    await grantPlan(s.client_reference_id, s.customer);
  }
  return new Response('ok');
}

3. Replay a real event locally

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed

4. Check the production endpoint

In the Stripe dashboard, open Developers, then Webhooks, confirm the URL is your production domain, copy that endpoint's signing secret into your host's env vars, and redeploy.

How to know it is fixed

Buy with a test card in test mode on the deployed site. The webhook delivery should show 200 in Stripe, and the user's plan should change without a page refresh trick.