This Checkout Session has expired. A new one must be created for this customer.
appears when:When a customer clicks an email link or reloads a page pointing at a Stripe Checkout URL more than 24 hours old, or when a CDN caches the session URL for many users
Stripe Checkout session expired
Stripe sessions last 24 hours by default. Email prefetchers and CDN caches consume the URL before the buyer does, leaving a dead link and a frustrated customer.
expires_at, or it was marked consumed by a prefetcher. Never embed raw session URLs in emails or cached pages. Instead link to a dynamic /checkout?price=... route that creates a new session on every request with cache-control: no-store.Quick fix for Stripe Checkout session expired
01// app/checkout/route.ts — create a fresh Stripe session on every request02import { NextResponse } from "next/server";03import Stripe from "stripe";04 05const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });06 07// Never cache this route — every request must mint a new session08export const dynamic = "force-dynamic";09export const revalidate = 0;10 11export async function GET(req: Request) {12 const { searchParams } = new URL(req.url);13 const priceId = searchParams.get("price");14 if (!priceId) return NextResponse.json({ error: "missing price" }, { status: 400 });15 16 const session = await stripe.checkout.sessions.create({17 mode: "subscription",18 line_items: [{ price: priceId, quantity: 1 }],19 // 30-minute expiry forces a fresh redirect on every attempt20 expires_at: Math.floor(Date.now() / 1000) + 30 * 60,21 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,22 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,23 });24 25 return NextResponse.redirect(session.url!, {26 status: 303,27 headers: { "cache-control": "no-store, no-cache, must-revalidate" },28 });29}Deeper fixes when the quick fix fails
01 · Idempotent redirect with authenticated user context
01// app/checkout/route.ts — pre-fill customer email and reuse customer id02import { NextResponse } from "next/server";03import { createClient } from "@/lib/supabase/server";04import Stripe from "stripe";05 06const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });07export const dynamic = "force-dynamic";08 09export async function GET(req: Request) {10 const { searchParams } = new URL(req.url);11 const priceId = searchParams.get("price");12 if (!priceId) return NextResponse.json({ error: "missing price" }, { status: 400 });13 14 const supabase = await createClient();15 const { data: { user } } = await supabase.auth.getUser();16 if (!user) return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/sign-in`);17 18 // reuse customer id if we've seen this user before19 const { data: profile } = await supabase.from("profiles").select("stripe_customer_id").eq("id", user.id).single();20 21 const session = await stripe.checkout.sessions.create({22 mode: "subscription",23 customer: profile?.stripe_customer_id ?? undefined,24 customer_email: profile?.stripe_customer_id ? undefined : user.email,25 line_items: [{ price: priceId, quantity: 1 }],26 expires_at: Math.floor(Date.now() / 1000) + 30 * 60,27 client_reference_id: user.id,28 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,29 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,30 });31 32 return NextResponse.redirect(session.url!, { status: 303, headers: { "cache-control": "no-store" } });33}02 · Client-side fallback for UX polish
01// components/CheckoutButton.tsx — client button that POSTs to /checkout02"use client";03 04export function CheckoutButton({ priceId }: { priceId: string }) {05 return (06 <a07 href={`/checkout?price=${priceId}`}08 rel="nofollow"09 className="btn-lime px-6 py-3"10 >11 Upgrade to Pro12 </a>13 );14}03 · Webhook handler that invalidates stale sessions
Subscribe to checkout.session.expired and checkout.session.completed. On expired, log and optionally email a retry link. On completed, mark the customer paid. This closes the loop so your internal reports match Stripe's view of the world.
Why AI-built apps hit Stripe Checkout session expired
Stripe Checkout sessions are single-use, time-bound URLs. The Stripe docs state a default expiry of 24 hours, configurable from 30 minutes to 24 hours via the expires_at field. Once expired, the URL renders a friendly error page with no path to payment. Stripe does this to reduce replay attacks and stale pricing risks, not to annoy your users. The constraint is fine; AI-generated code around it is not.
AI builders like Lovable and Bolt typically create a Stripe session once — during the pricing page render — and hardcode the URL into a <Link href={sessionUrl} />. The page is often a server component that Vercel caches aggressively. Every visitor receives the same URL. The first user to pay consumes it; everyone else hits expired. The same bug appears when a marketing email blast embeds a single checkout URL and Mailchimp delivers it to ten thousand inboxes across 72 hours.
The second pattern is prefetching. Gmail, Outlook, HubSpot, and many security scanners open every link in a new email before the human clicks, to check for phishing. Stripe sees the hit and starts the clock. Some link checkers go further and mark the URL as consumed on their single open. By the time the human clicks, Stripe reports expired even though the session is technically within the 24-hour window.
The fix is structural. Never persist checkout URLs. Always link from marketing and email to a dynamic route on your own domain — /checkout?price=price_123 — that creates a fresh session on every GET and 303-redirects to session.url. Set expires_at to 30 minutes. Send Cache-Control: no-store. This makes the window too short for prefetchers to consume and guarantees every customer gets their own session.
Stripe Checkout session expired by AI builder
How often each AI builder ships this error and the pattern that produces it.
| Builder | Frequency | Pattern |
|---|---|---|
| Lovable | Every Stripe scaffold | Creates session at page render; caches URL for every visitor |
| Bolt.new | Common | Embeds raw session URL in transactional emails |
| v0 | Common | Stores session_id in database and redirects returning users back to it |
| Cursor | Sometimes | Forgets force-dynamic — Vercel caches the redirect response |
| Replit Agent | Rare | Sets expires_at to 24h maximum — maximizes the prefetch window |
Related errors we fix
Stop Stripe Checkout session expired recurring in AI-built apps
- →Route every checkout through /checkout?price=... — never embed Stripe URLs anywhere.
- →Set expires_at to 30 minutes so prefetchers cannot consume the window before a human.
- →Return Cache-Control: no-store on every /checkout response.
- →Add rel=nofollow on marketing CTA links to discourage email prefetchers.
- →Subscribe to checkout.session.expired webhooks and surface the failure to ops.
Still stuck with Stripe Checkout session expired?
Stripe Checkout session expired questions
How long does a Stripe Checkout session stay valid?+
Why do my users see Session expired on the first click?+
Can I reuse a Stripe Checkout session URL?+
How do I disable CDN caching on my checkout redirect route?+
How long does a Stripe Checkout fix take?+
Ship the fix. Keep the fix.
Emergency Triage restores service in 48 hours. Break the Fix Loop rebuilds CI so this error cannot ship again.
Hyder Shah leads Afterbuild Labs, shipping production rescues for apps built in Lovable, Bolt.new, Cursor, Replit, v0, and Base44. our rescue methodology.