Stripe Checkout Succeeds but Your App Never Records the Payment: How to Fix It
Quick answer: Card charged but your app never marked the order paid? Fix Stripe webhooks: verify with the raw body, use the right whsec_ secret, and stop unlocking from /success.
The error
The customer pays, Stripe shows the payment as succeeded, but your database never updates and the user doesn't get access. Common messages in your logs or in Stripe's webhook delivery details:
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
Webhook Error: Webhook signature verification failed
400 Bad Request / 401 Unauthorized / 404 Not Found (webhook endpoint)
Sometimes there's no error at all, because the app was never listening for the webhook in the first place.
Why it happens
Stripe Checkout runs on Stripe's page. Your app only learns that a payment succeeded when Stripe sends a webhook (an HTTP POST) to your server. AI-built apps often get one of these wrong:
- Access is unlocked from the
/successpage instead of a webhook. If the user closes the tab before the redirect, nothing is recorded, and anyone who types/successinto the address bar gets the paid content for free. - The webhook secret doesn't match the endpoint. The Stripe CLI, the test-mode endpoint and the live-mode endpoint each have a different
whsec_...secret. - The request body is parsed as JSON before the signature check. Stripe signs the exact raw bytes it sent, so any re-serialized body fails verification.
- The webhook endpoint isn't reachable: wrong URL, not deployed, blocked by auth middleware, or behind your host's password protection.
- The handler runs but can't write to the database (for example, it uses the Supabase anon key and Row Level Security blocks the insert).
- The handler doesn't know which user paid, because the checkout session was created without a user ID.
Fixes
1. Grant access only from a verified webhook, never from /success
Treat the success page as a "thanks, we're confirming your payment" screen. Do the real work in the webhook handler when you receive checkout.session.completed and the session's payment_status is paid. If you accept delayed payment methods (like bank debits), also handle checkout.session.async_payment_succeeded.
The success page can then read the order status from your database and show "Payment confirmed" once the webhook has done its job.
2. Attach the user to the checkout session
When you create the session on your server, pass the user ID so the webhook knows who paid:
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_123', quantity: 1 }],
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
client_reference_id: user.id,
metadata: { user_id: user.id },
});
3. Verify the signature with the raw body
Read the body as text or raw bytes, then pass it straight to Stripe. Next.js App Router route handler:
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, NOT req.json()
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === 'paid') {
// mark the order paid for session.client_reference_id
}
}
return new Response('ok', { status: 200 });
}
Express: register the webhook route with express.raw({ type: 'application/json' }) before any global express.json() middleware. Next.js Pages Router: turn off the body parser for that route (export const config = { api: { bodyParser: false } }) and read the raw stream. Supabase Edge Functions (Deno): use await req.text() and the async verifier stripe.webhooks.constructEventAsync(...).
4. Use the right webhook secret for each environment
- Local testing: run
stripe listen --forward-to localhost:3000/api/stripe/webhook. The CLI prints its ownwhsec_...secret; use that one in.env.local. - Production: in the Stripe Dashboard, open the webhook endpoint you created for your live URL and reveal its signing secret. Put it in your host's environment variables and redeploy.
- Test mode and live mode are separate. A test-mode secret won't verify live events, and a
sk_test_key can't read live sessions. Switch both the secret key and the webhook secret together when you go live.
5. Make sure Stripe can reach the endpoint
In the Stripe Dashboard, open the webhook endpoint and look at recent event deliveries. The response code tells you what went wrong:
404: the URL path is wrong, or the route isn't deployed.401/403: auth middleware, a login redirect, or host password protection is blocking Stripe. Exclude the webhook route.400: usually signature verification (see steps 3 and 4).500: your handler crashed. Check your server logs.
Return a 2xx quickly. In live mode, Stripe retries failed deliveries for up to three days, and you can resend an event from the Dashboard once it's fixed.
6. Let the webhook write to the database, and handle duplicates
The webhook runs on your server without a logged-in user, so use a server-only credential (for Supabase, the service role or sb_secret_... key, kept out of frontend code). Stripe can deliver the same event more than once, so record event.id or the session ID and skip events you've already processed. That way a retry never grants access twice.
Prompt to paste into your AI tool
Payments succeed in Stripe but my app never records them. Move all fulfillment out of the /success page into a Stripe webhook route. Verify the signature with stripe.webhooks.constructEvent using the raw request body (not parsed JSON) and STRIPE_WEBHOOK_SECRET. Handle checkout.session.completed only when payment_status is "paid", find the user via client_reference_id, make the handler idempotent by storing event.id, and return 200 quickly. Don't change anything else.
FAQ
Can I just check the session on the /success page instead? Retrieving the session server-side with session_id is fine for showing a confirmation, but don't make it the only path. Users close tabs, and webhooks still fire.
How do I test without real money? Use test mode with stripe listen and a test card, or run stripe trigger checkout.session.completed. Before launch, many founders also make one small real purchase in live mode and refund it to confirm the live webhook works end to end.
Why does it work locally but not in production? Locally you're using the CLI's secret. Production needs the secret from the live endpoint in the Dashboard, set on your host, followed by a redeploy.
Related guides
- Invalid API key or undefined env vars on Vercel
- Supabase returns empty data with no error
- I leaked an API key or .env file: what now?
Still stuck? Paste your exact error into the free Error Fixer to get a plain-English cause, the fix, and a prompt to paste back into your AI tool.