afterbuild/ops
ERR-710/Stripe · Checkout
ERR-710
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.

Last updated 17 April 2026 · 6 min read · By Hyder Shah
Direct answer
Stripe Checkout session expired means the session URL the customer clicked is past its 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

app/checkout/route.ts
typescript
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}
Paste as app/checkout/route.ts — every click creates a fresh Stripe session and redirects with no-store headers

Deeper fixes when the quick fix fails

01 · Idempotent redirect with authenticated user context

app/checkout/route.ts
typescript
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}
Carries the authenticated user into Stripe so repeat customers keep one customer id

02 · Client-side fallback for UX polish

components/CheckoutButton.tsx
typescript
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}
rel=nofollow discourages prefetchers from consuming the redirect before the real click

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.

AI builder × Stripe Checkout session expired
BuilderFrequencyPattern
LovableEvery Stripe scaffoldCreates session at page render; caches URL for every visitor
Bolt.newCommonEmbeds raw session URL in transactional emails
v0CommonStores session_id in database and redirects returning users back to it
CursorSometimesForgets force-dynamic — Vercel caches the redirect response
Replit AgentRareSets expires_at to 24h maximum — maximizes the prefetch window

Related errors we fix

Stop Stripe Checkout session expired recurring in AI-built apps

Still stuck with Stripe Checkout session expired?

Emergency triage · $299 · 48h turnaround
We restore service and write the root-cause report.
start the triage →

Stripe Checkout session expired questions

How long does a Stripe Checkout session stay valid?+
Stripe Checkout sessions expire 24 hours after creation by default. You can set expires_at explicitly when you create the session, with a minimum of 30 minutes and a maximum of 24 hours. Any click on the URL after expiry lands on the Session expired screen with no payment path forward. The session cannot be extended — a new one must be created on every attempt.
Why do my users see Session expired on the first click?+
Usually because a CDN or email client cached the checkout URL. Mailchimp, HubSpot, and some Gmail proxies prefetch links to scan for safety, which bumps Stripe's first-use logic. The user clicks a pre-consumed URL and Stripe reports it expired. Fix by generating the session on demand from a redirect route like /checkout that creates a fresh session every time, rather than embedding a raw checkout URL in the email.
Can I reuse a Stripe Checkout session URL?+
You can reload the URL any number of times before it expires or the customer completes payment. Once a customer has paid, the URL becomes invalid. If your app stores the session_id and redirects returning users back to it, they hit Session expired the moment they have already paid. Always generate a new session per checkout attempt and never persist the URL beyond the immediate redirect.
How do I disable CDN caching on my checkout redirect route?+
In Next.js add `export const dynamic = 'force-dynamic'` and `export const revalidate = 0` to the route segment. Also set the response Cache-Control header to `no-store, no-cache, must-revalidate`. Vercel's CDN respects these headers and will proxy every request. Without them, the first response gets cached and every subsequent user receives the same Stripe URL — which expires for everyone on the 24-hour mark.
How long does a Stripe Checkout fix take?+
Ten minutes for the redirect route pattern. Thirty minutes if you need to retrofit email campaigns to point at /checkout?plan=pro instead of raw session URLs. Stripe Integration Fix at a flat fee covers the redirect route, webhook verification, and a Playwright test that walks the full purchase flow. It is the same fix we ship for every AI-built app with a payment flow.
Next step

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.

About the author

Hyder Shah leads Afterbuild Labs, shipping production rescues for apps built in Lovable, Bolt.new, Cursor, Replit, v0, and Base44. our rescue methodology.

Sources