Implement Stripe Checkout with Claude Code: Sessions, Webhooks, and Idempotency
Build Stripe Checkout with Claude Code: hosted Sessions, webhooks, metadata, idempotency, test mode, and recovery.
Stripe Checkout is the fastest way to stop handling card forms yourself, but it does not remove your responsibility for order state. A production integration still needs a Checkout Sessions API route, hosted checkout redirects, webhook fulfillment, metadata.order_id, idempotency, test mode verification, and refund or cancellation reconciliation.
Claude Code is useful here when you give it narrow implementation slices instead of asking for a vague payment integration. Ask it for the schema, then environment validation, then the Checkout route, then the webhook handler, then a critical review. That keeps secrets out of prompts and makes each diff small enough to inspect.
This guide uses Next.js App Router and Node TypeScript. The code avoids hard-coded secret keys, uses hosted Checkout, writes the internal order before creating a Stripe Session, and fulfills the order through one idempotent server-side function.
What Stripe Expects
Stripe’s Checkout Sessions API is the backend for hosted Checkout, embedded Checkout, and custom flows. For most teams, hosted Checkout is the safest starting point because Stripe handles the payment form, authentication steps, validation, and many payment-method details.
The How Checkout works guide describes the lifecycle: your app creates a Checkout Session, the user pays on Stripe’s hosted page, and a webhook event such as checkout.session.completed triggers fulfillment. Fulfillment means granting the thing the user paid for: a download, SaaS access, a seat, shipping work, or a booking.
Stripe’s fulfillment guide is explicit about the critical design rule: use webhooks, and make fulfillment safe to run more than once for the same Checkout Session. The API reference also shows that a Checkout Session is created for each payment attempt and later references the Customer, PaymentIntent, or Subscription.
For broader payment architecture, pair this article with Claude Code payment integration, Claude Code security best practices, and Claude Code environment management.
Architecture
flowchart LR
Buyer["Buyer"] --> App["Next.js app"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Order table"]
CheckoutRoute --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Hosted Checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Hosted --> Success["/checkout/success"]
Success --> Fulfill
Fulfill --> Orders
The Checkout route creates or reuses an internal order, creates a Stripe Session, and returns session.url. The webhook verifies Stripe’s signature from the raw body and calls the same fulfillment function used by the success page. The fulfillment function owns idempotency: if it sees the same Session twice, it grants access only once.
Environment and Database
Install the runtime dependencies first.
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
Use test-mode values locally. Do not paste sk_test_, sk_live_, or whsec_ values into Claude Code prompts.
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
Create the order before the Checkout Session so order_id can travel through Stripe metadata and back into your webhook.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Order {
id String @id @default(cuid())
cartId String @unique
userId String
email String?
productKey String
quantity Int @default(1)
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeSubscriptionId String? @unique
amountTotal Int?
currency String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs FulfillmentLog[]
}
model FulfillmentLog {
id String @id @default(cuid())
orderId String
stripeCheckoutSessionId String
event String
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, event])
@@index([stripeCheckoutSessionId])
}
enum OrderStatus {
PENDING
PAID
FULFILLED
CANCELED
REFUNDED
FAILED
}
Shared Server Utilities
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});
export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
If your team pins Stripe API versions in code, match the installed stripe package and your Dashboard API version deliberately. Do not copy an old version date from a random article.
Create a Hosted Checkout Session
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
const checkoutRequestSchema = z.object({
cartId: z.string().min(8),
userId: z.string().min(1),
email: z.string().email().optional(),
productKey: z.enum(["ebook", "pro"]),
});
const priceByProduct = {
ebook: env.STRIPE_PRICE_EBOOK,
pro: env.STRIPE_PRICE_PRO,
} as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = checkoutRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
}
const { cartId, userId, email, productKey } = parsed.data;
const mode: Stripe.Checkout.SessionCreateParams.Mode =
productKey === "pro" ? "subscription" : "payment";
const order = await prisma.order.upsert({
where: { cartId },
update: {},
create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
});
if (order.stripeCheckoutSessionId) {
const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (existing.status === "open" && existing.url) {
return NextResponse.json({ url: existing.url, orderId: order.id });
}
return NextResponse.json(
{ error: "Checkout session is not open. Create a new cart." },
{ status: 409 },
);
}
const metadata = {
order_id: order.id,
cart_id: cartId,
product_key: productKey,
user_id: userId,
};
const session = await stripe.checkout.sessions.create(
{
mode,
line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
customer_email: email,
success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
client_reference_id: order.id,
metadata,
allow_promotion_codes: true,
payment_intent_data: mode === "payment" ? { metadata } : undefined,
subscription_data: mode === "subscription" ? { metadata } : undefined,
},
{ idempotencyKey: `checkout:${order.id}:v1` },
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({ url: session.url, orderId: order.id });
}
Notice what is not in this route: no secret key in source code, no card handling, no access granting, and no personally identifiable information in metadata. Email is sent as customer_email, not as metadata.
Idempotent Fulfillment
// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
function stripeObjectId(
value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent", "subscription"],
});
if (session.payment_status === "unpaid") {
return { status: "waiting_for_payment" as const, sessionId };
}
const orderId = session.metadata?.order_id ?? session.client_reference_id;
if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);
return prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: orderId, fulfilledAt: null },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
amountTotal: session.amount_total,
currency: session.currency,
fulfilledAt: new Date(),
},
});
if (updated.count === 0) {
return { status: "already_fulfilled" as const, orderId };
}
await tx.fulfillmentLog.create({
data: {
orderId,
stripeCheckoutSessionId: session.id,
event: "checkout.fulfilled",
},
});
return { status: "fulfilled" as const, orderId };
});
}
This is where you add the real business effect: create a license, enable a SaaS plan, reserve a seat, or queue shipment. External email and CRM calls should usually be jobs created from the transaction, not long webhook work.
Webhook Handler
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export const runtime = "nodejs";
function idFromStripeObject(value: string | { id: string } | null | undefined) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
data: { status },
});
}
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckout(session.id);
break;
}
case "checkout.session.async_payment_failed": {
const session = event.data.object as Stripe.Checkout.Session;
await markSession(session.id, "FAILED");
break;
}
case "checkout.session.expired": {
const session = event.data.object as Stripe.Checkout.Session;
await markSession(session.id, "CANCELED");
break;
}
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = idFromStripeObject(charge.payment_intent);
if (paymentIntentId) {
await prisma.order.updateMany({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: "REFUNDED" },
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await prisma.order.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
The raw body matters. If Claude Code changes this to await request.json() before signature verification, reject the diff.
Success Page
// app/checkout/success/page.tsx
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) {
return (
<main>
<h1>Checkout result</h1>
<p>Session ID was not found. Please contact support.</p>
</main>
);
}
const result = await fulfillCheckout(session_id);
return (
<main>
<h1>Payment received</h1>
<p>Your order status is {result.status}.</p>
<a href="/account/purchases">Open purchases</a>
</main>
);
}
The success page improves UX when the webhook is a few seconds behind. It does not replace the webhook, because users can pay and then close the browser before returning.
Test Mode Checklist
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
Use the signing secret printed by the CLI as STRIPE_WEBHOOK_SECRET, restart Next.js, and pay with Stripe’s common test card 4242 4242 4242 4242. Then verify the database, not only the Stripe Dashboard: Order.status should become FULFILLED, Stripe IDs should be saved, and refreshing the success page should not create a second fulfillment log.
Also test a canceled Checkout Session, an expired Session, a refund, and a subscription deletion. For delayed payment methods, handle checkout.session.async_payment_succeeded and checkout.session.async_payment_failed instead of assuming all payments are instant.
Real Use Cases
| Use case | Checkout mode | Fulfillment action | Risk to review |
|---|---|---|---|
| Digital template or PDF | payment | Grant download access | Do not reveal the file only on the success page |
| SaaS Pro plan | subscription | Enable plan limits and seats | Handle subscription deletion and billing failures |
| Paid workshop deposit | payment | Reserve a slot and notify ops | Decide what refund does to the reservation |
The common rule is to make your internal order the source of truth. Stripe IDs are excellent references, but the support team needs a business order that maps to the user, cart, fulfillment state, refund state, and entitlement.
Failure Cases
The most expensive mistake is fulfilling only from the success URL. It works in happy-path testing and fails in real life when a user closes the tab, loses connection, or an agent completes checkout without returning to your page.
The second mistake is missing metadata. Without order_id, finance can see a Stripe payment but engineering cannot safely map it to the app order. Put stable internal IDs in metadata, not sensitive user data.
The third mistake is treating Stripe idempotency as enough. The idempotencyKey protects Session creation retries. It does not stop your webhook code from granting the same license twice. Use database constraints and atomic updates.
The fourth mistake is mixing test and live objects. Test keys, live price IDs, and a webhook secret from another endpoint can produce confusing partial behavior. Keep mode-specific environment files and review them before release.
How to Use Claude Code Safely
Give Claude Code a scoped prompt such as:
Review only the Stripe Checkout implementation.
Confirm that secrets come from env, metadata contains order_id but no PII,
webhook signature verification uses the raw body, fulfillment is idempotent,
and the success page does not replace webhook fulfillment.
Return file and line references for every issue.
Do not give it broad write access to unrelated content while other workers are active. For payment work, also forbid destructive migrations, secret printing, and live-mode API calls unless you explicitly approve them.
Confirmation Points Before Trying This
When you try this implementation, complete one test-mode payment, watch the forwarded webhook, inspect the database, reload the success page, expire a Session, refund a charge, and cancel a subscription. The implementation is ready for a deeper production review only when the same Checkout Session can be processed repeatedly without granting access twice.
ClaudeCodeLab can help review Stripe Checkout implementations, train teams on Claude Code payment workflows, or build the first production-ready integration with your order and refund rules included.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.