Stripe Checkout is a hosted payment page that handles card input, validation, 3D Secure, and receipts — stripe.checkout.sessions.create({ line_items, mode, success_url, cancel_url }) creates a checkout session. Redirect to session.url sends customers to Stripe’s hosted page. mode: "payment" for one-time purchases; mode: "subscription" for recurring billing. line_items reference Price IDs from your Stripe dashboard or dynamically generated prices. metadata stores your order ID for webhook correlation. checkout.session.completed webhook fires when payment succeeds — retrieve session with stripe.checkout.sessions.retrieve(session.id, { expand: ["line_items"] }). Embedded checkout renders in your page with @stripe/stripe-js mountStripeElement. allow_promotion_codes: true enables coupon input. customer_creation: "always" creates Stripe Customer records. Claude Code generates checkout session creation, webhook handlers, embedded checkout React components, and subscription upgrade flows.
CLAUDE.md for Stripe Checkout
## Stripe Checkout Stack
- Version: stripe >= 14.25 (Node), @stripe/stripe-js >= 3.4 (browser)
- Session: stripe.checkout.sessions.create({ line_items: [{ price, quantity }], mode: "payment", success_url, cancel_url })
- Redirect: return Response.redirect(session.url) — or return { url: session.url }
- Webhook: stripe.webhooks.constructEvent(rawBody, sig, secret) → checkout.session.completed
- Retrieve: stripe.checkout.sessions.retrieve(id, { expand: ["line_items", "customer"] })
- Subscription: mode: "subscription", line_items: [{ price: "price_xxx", quantity: 1 }]
- Metadata: metadata: { orderId, userId } — accessible in webhook
- Embedded: stripe.initEmbeddedCheckout({ clientSecret }) + EmbeddedCheckout React component
Checkout Session Creation
// lib/stripe/checkout.ts — session creation utilities
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
})
// One-time payment checkout
export async function createPaymentCheckout(params: {
lineItems: { priceId: string; quantity: number }[]
userId: string
orderId: string
customerEmail?: string
successUrl: string
cancelUrl: string
metadata?: Record<string, string>
}) {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: params.lineItems.map(item => ({
price: item.priceId,
quantity: item.quantity,
})),
success_url: `${params.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: params.cancelUrl,
customer_email: params.customerEmail,
metadata: {
userId: params.userId,
orderId: params.orderId,
...params.metadata,
},
// Payment options
allow_promotion_codes: true,
billing_address_collection: "auto",
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB", "AU"],
},
payment_intent_data: {
capture_method: "automatic",
metadata: { orderId: params.orderId },
},
// Receipt email
payment_method_options: {
card: { request_three_d_secure: "automatic" },
},
// Tax calculation
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
})
return session
}
// Subscription checkout
export async function createSubscriptionCheckout(params: {
priceId: string
userId: string
customerEmail?: string
successUrl: string
cancelUrl: string
trialDays?: number
existingCustomerId?: string
}) {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: params.priceId, quantity: 1 }],
success_url: `${params.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: params.cancelUrl,
customer: params.existingCustomerId,
customer_email: !params.existingCustomerId ? params.customerEmail : undefined,
customer_creation: params.existingCustomerId ? undefined : "always",
metadata: { userId: params.userId },
subscription_data: {
trial_period_days: params.trialDays,
metadata: { userId: params.userId },
},
allow_promotion_codes: true,
billing_address_collection: "required",
})
return session
}
// Retrieve completed session with expanded data
export async function getCompletedSession(sessionId: string) {
return stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "line_items.data.price.product", "customer", "subscription"],
})
}
API Routes
// app/api/checkout/route.ts — create checkout session
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@clerk/nextjs/server"
import { createPaymentCheckout, createSubscriptionCheckout } from "@/lib/stripe/checkout"
import { db } from "@/lib/db"
import { z } from "zod"
const CheckoutSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("payment"),
orderId: z.string(),
items: z.array(z.object({ priceId: z.string(), quantity: z.number().int().positive() })),
}),
z.object({
type: z.literal("subscription"),
priceId: z.string(),
trialDays: z.number().int().optional(),
}),
])
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = CheckoutSchema.safeParse(await request.json())
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 })
const user = await db.user.findUniqueOrThrow({ where: { clerkId: userId } })
const origin = request.headers.get("origin") ?? process.env.NEXT_PUBLIC_APP_URL!
try {
if (body.data.type === "payment") {
const session = await createPaymentCheckout({
lineItems: body.data.items,
userId,
orderId: body.data.orderId,
customerEmail: user.email,
successUrl: `${origin}/checkout/success`,
cancelUrl: `${origin}/cart`,
})
return NextResponse.json({ url: session.url })
}
if (body.data.type === "subscription") {
const session = await createSubscriptionCheckout({
priceId: body.data.priceId,
userId,
customerEmail: user.email,
existingCustomerId: user.stripeCustomerId ?? undefined,
successUrl: `${origin}/dashboard?upgraded=true`,
cancelUrl: `${origin}/pricing`,
trialDays: body.data.trialDays,
})
return NextResponse.json({ url: session.url })
}
} catch (err) {
console.error("[Checkout] Error:", err)
return NextResponse.json({ error: "Checkout creation failed" }, { status: 500 })
}
}
Webhook Handler
// app/api/webhooks/stripe/route.ts — checkout.session.completed
import { NextRequest, NextResponse } from "next/server"
import { stripe, getCompletedSession } from "@/lib/stripe/checkout"
import { db } from "@/lib/db"
export async function POST(request: NextRequest) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
// Idempotency check
const processed = await db.stripeEvent.findUnique({ where: { eventId: event.id } })
if (processed) return NextResponse.json({ ok: true })
await db.stripeEvent.create({ data: { eventId: event.id, type: event.type } })
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session
if (session.payment_status !== "paid") break
const fullSession = await getCompletedSession(session.id)
const userId = session.metadata?.userId
if (session.mode === "payment") {
await db.order.update({
where: { id: session.metadata?.orderId },
data: {
status: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
},
})
}
if (session.mode === "subscription" && userId) {
const subscription = fullSession.subscription as Stripe.Subscription
await db.user.update({
where: { clerkId: userId },
data: {
stripeCustomerId: session.customer as string,
subscriptionId: subscription.id,
subscriptionStatus: subscription.status,
plan: "pro",
},
})
}
break
}
case "checkout.session.expired": {
const session = event.data.object as Stripe.Checkout.Session
if (session.metadata?.orderId) {
await db.order.update({
where: { id: session.metadata.orderId },
data: { status: "expired" },
})
}
break
}
}
return NextResponse.json({ ok: true })
}
export const config = { api: { bodyParser: false } }
Checkout Button Component
// components/checkout/CheckoutButton.tsx — trigger checkout
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
interface CheckoutButtonProps {
priceId?: string
orderId?: string
items?: { priceId: string; quantity: number }[]
type: "payment" | "subscription"
className?: string
children: React.ReactNode
}
export function CheckoutButton({ priceId, orderId, items, type, className, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleCheckout = async () => {
setLoading(true)
try {
const body = type === "subscription"
? { type: "subscription", priceId }
: { type: "payment", orderId, items }
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const { url, error } = await res.json()
if (error) throw new Error(error)
router.push(url)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
return (
<button onClick={handleCheckout} disabled={loading} className={className ?? "btn-primary"}>
{loading ? "Loading..." : children}
</button>
)
}
For the Stripe Elements alternative when a custom-styled embedded payment form within your own page UI is preferred over redirecting to Stripe’s hosted Checkout page — Stripe Elements with CardElement or PaymentElement keep customers on your site throughout checkout, see the Stripe Elements guide. For the Stripe Payment Links alternative when no-code payment links shared via email or social media (without building a checkout flow) are sufficient — Payment Links require zero development and work for simple product sales, though unlike Checkout Sessions they lack server-side metadata and webhook customization, see the Stripe dashboard docs. The Claude Skills 360 bundle includes Stripe Checkout skill sets covering sessions, webhooks, and subscription upgrades. Start with the free tier to try payment integration generation.