Use Cases (更新: 2026/6/1)

用 Claude Code 实现 Stripe Checkout:Sessions、Webhook 与幂等性

用 Claude Code 安全实现 Stripe Checkout:hosted checkout、webhook、metadata、幂等性、测试模式与恢复检查。

用 Claude Code 实现 Stripe Checkout:Sessions、Webhook 与幂等性

Stripe Checkout 可以让团队不再自己维护银行卡输入表单,但它并不会自动替你管理订单状态。真正可上线的实现,还需要 Checkout Sessions API、hosted checkout 跳转、webhook fulfillment、metadata.order_id、幂等性、测试模式,以及退款和取消后的对账流程。

Claude Code 在这里的价值不是“生成一段能跑的支付代码”,而是把高风险实现拆成可审查的小任务:先写订单表,再写环境变量校验,再写 Checkout Route Handler,再写 webhook 签名校验和 fulfillment,最后让它按安全清单做 review。这样既能提速,也能避免把密钥、订单权限和退款逻辑混在一起。

本文使用 Next.js App Router 与 Node TypeScript。示例代码不把 Stripe secret key 写死在代码里,先创建内部订单,再创建 Stripe Checkout Session,并用一个幂等的服务端函数完成发货、开通权限或预约确认。

先理解官方流程

Stripe 的 Checkout Sessions API 是 hosted Checkout、embedded Checkout 和自定义流程的后端入口。对于大多数产品,hosted checkout 是最稳妥的起点,因为支付页面、认证步骤、错误提示和支付方式细节都由 Stripe 处理。

How Checkout works 说明了核心生命周期:你的应用创建 Checkout Session,用户在 Stripe 托管页面完成支付,然后通过 checkout.session.completed 等 webhook 事件触发 fulfillment。fulfillment 指的是支付成功后真正交付用户购买的东西,例如下载权限、SaaS 套餐、培训名额或发货任务。

Stripe 的 Fulfill orders 特别强调:必须使用 webhook,并且 fulfillment 函数要能安全地被同一个 Checkout Session 调用多次。API reference 也说明,Session 代表一次支付尝试,成功后会关联 Customer、PaymentIntent 或 Subscription。

支付系统的整体设计可以继续参考Claude Code 支付集成Claude Code 安全最佳实践Claude Code 环境变量管理

推荐架构

flowchart LR
  Buyer["购买者"] --> App["Next.js 应用"]
  App --> CheckoutRoute["/api/checkout"]
  CheckoutRoute --> Orders["Order 表"]
  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

Checkout Route 只负责创建或复用内部订单、创建 Stripe Session、返回 session.url。Webhook Route 只负责读取 raw body、校验 Stripe 签名、把已支付事件交给 fulfillment。Fulfillment 函数负责幂等性:同一个 Session 被 webhook 和成功页同时调用,也只能交付一次。

环境变量与数据库

npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init

本地只使用测试模式。不要把 sk_test_sk_live_whsec_ 的真实值贴进 Claude Code prompt。

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

先创建订单,再创建 Checkout Session。这样你可以把内部订单 ID 写入 client_reference_idmetadata.order_id,后续 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
}

服务端工具

// 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);

创建 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 });
}

这里的关键点是:Stripe secret key 只在服务端使用,metadata 只放内部 ID,不放邮箱、地址或卡信息;idempotencyKey 防止创建 Session 的重试造成重复;成功页不直接开通权限。

幂等 fulfillment 与 webhook

// 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 };
  });
}
// 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":
      await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
      break;
    case "checkout.session.async_payment_failed":
      await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
      break;
    case "checkout.session.expired":
      await markSession((event.data.object as Stripe.Checkout.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 });
}

测试、失败案例与恢复

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

把 CLI 输出的 whsec_ 设置到 STRIPE_WEBHOOK_SECRET,重启 Next.js,然后用测试卡 4242 4242 4242 4242 完成支付。不要只看 Stripe Dashboard,还要检查数据库:Order.status 是否变为 FULFILLED,Stripe ID 是否保存,成功页刷新后 FulfillmentLog 是否仍然只有一条。

常见失败有五类。第一,只在成功页开通权限;用户付款后关闭浏览器就会丢单。第二,没有写入 order_id;支付与业务订单无法对账。第三,只依赖 Stripe 的 idempotencyKey;它能保护 Session 创建重试,但不能保护 webhook fulfillment。第四,测试模式和生产模式的 key、price、webhook secret 混用。第五,退款和订阅取消只在 Dashboard 处理,应用数据库和权限没有同步。

三个常见用例的差异如下。

用例Checkout modefulfillment 动作重点风险
PDF 教材或模板payment开通下载权限不要只在成功页暴露文件链接
SaaS Pro 套餐subscription开通席位和配额要处理订阅取消和账单失败
培训或咨询定金payment锁定预约时段并通知运营退款后是否释放时段要提前定义

让 Claude Code review 时,可以使用下面这种粒度。

Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.

实际尝试前的确认点

实际试用时,请完成一次测试支付,观察 webhook 转发日志,检查数据库更新,刷新成功页,手动让 Session 过期,模拟退款,并取消一次测试订阅。只有当同一个 Checkout Session 被多次处理也不会重复开通权限时,才进入生产发布前的安全 review。

ClaudeCodeLab 可以协助团队 review Stripe Checkout 实现、设计 Claude Code 支付开发流程,或根据你的订单模型、退款规则和订阅权限做实装支援与培训。

#Claude Code #Stripe #支付 #Checkout #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。