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:

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

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:

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

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.

Get the free "10 Prompts That Fix Broken AI Code" pack

Join the UnbreakKit newsletter: one short email a week with real errors from Cursor, Lovable, Bolt & Replit users and how to fix them. Unsubscribe anytime.

Newsletter signup powered by beehiiv. We only use your email to send the newsletter.