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
- Stripe shows a successful payment, but your app still shows the free plan.
- The Stripe dashboard lists webhook deliveries failing with 400 or 500.
- Logs show "No signatures found matching the expected signature for payload".
- It works locally with the Stripe CLI and fails in production.
Why it happens
- No webhook endpoint is registered for the production URL, or it points at a preview deployment.
- The signing secret (
whsec_...) in production is the one fromstripe listen, not the dashboard endpoint's secret. - The framework parsed the body as JSON before verification. Stripe needs the raw body to check the signature.
- The endpoint is not subscribed to
checkout.session.completed(or the subscription events you rely on). - The handler cannot tell which user paid because no
client_reference_idor metadata was set on the session. - 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.