Use Cases (Updated: 6/1/2026)

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.

Implement Stripe Checkout with Claude Code: Sessions, Webhooks, and Idempotency

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 caseCheckout modeFulfillment actionRisk to review
Digital template or PDFpaymentGrant download accessDo not reveal the file only on the success page
SaaS Pro plansubscriptionEnable plan limits and seatsHandle subscription deletion and billing failures
Paid workshop depositpaymentReserve a slot and notify opsDecide 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.

#Claude Code #Stripe #payments #Checkout #TypeScript
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.