用 Claude Code 实现 Stripe 支付:Checkout、Webhook、退款与税务交接
用 Claude Code 为小型 SaaS 实现 Stripe 支付:价格表、Checkout、Webhook、幂等、退款与税务交接。
给小型 SaaS、付费模板或内容产品接入支付时,真正困难的不是放一个“购买”按钮。你需要价格表、Checkout Session API、Webhook 签名验证、幂等的交付逻辑、退款后的权限处理,以及交给会计或税务人员的资料流。否则就会出现 Stripe 里已经有收入,但应用没有给用户开通权限,或者退款完成后用户仍然能访问付费功能的问题。
Claude Code 可以让这类工作更快,但前提是边界足够明确。不要只说“帮我加 Stripe 支付”。更好的做法是让它分步骤实现并审查:服务端价格表、Checkout 路由、Webhook raw body 验签、重复事件处理、fulfillment、退款、日志隐私。fulfillment 可以理解为“用户付款后,把教材、SaaS 权限、预约席位等真正交付给用户”的动作。幂等性是“同一件事执行两次,结果仍然只算一次”。
本文使用 Next.js App Router、Node.js、TypeScript 和 Stripe Checkout。代码适合小型 SaaS、付费资料包和 Claude Code 培训预约金这样的产品。上线前仍然需要根据你的公司、国家或地区、税务要求和退款政策做人工确认。
先看官方文档
涉及支付服务商的细节,以官方文档为准。Stripe Checkout Session 的创建参数看 Checkout Session create API,付款后的交付看 Checkout fulfillment,Webhook 接收和签名验证看 Receive Stripe events,请求重试和幂等键看 Idempotent requests。
退款看 Create a refund,税务资料收集看 Stripe Tax for Checkout,本地 Webhook 测试看 Stripe CLI。Next.js API 路由使用 Route Handlers,Claude Code 本身可以参考 Claude Code overview。
如果你未来换成 Paddle、Lemon Squeezy、PayPal 或本地支付服务,核心原则也一样:价格由服务端决定,付款完成由 Webhook 确认,同一个事件重复到达也不能重复发放权益,退款必须和应用里的权限状态同步。
适合的商业场景
| 场景 | 收费方式 | 交付内容 | 常见失败 |
|---|---|---|---|
| 提示词包、PDF 课程、模板库 | 一次性付款 | 开通下载权限并发送购买邮件 | 只在成功页展示下载链接,用户关闭浏览器后无法恢复 |
| 小型 SaaS Pro 计划 | 月订阅 | 开通 plan:pro,后续根据账单事件更新状态 | 忽略取消或扣款失败事件,导致付费功能长期开放 |
| Claude Code 培训预约金 | 一次性付款或发票流程 | 预留日程,并通知运营人员 | 退款后是否释放预约名额没有规则 |
Masa 在一个内容产品原型里踩过的坑是:一开始把成功页当成付款事实。测试卡可以跑通,但一旦考虑 Webhook 重试、浏览器关闭、异步支付和退款,就不可靠了。更稳妥的 Claude Code 指令是:“成功页只展示状态,真正开通权限只能走 Webhook 触发的 fulfillment 函数。”
架构图
flowchart LR
Buyer["购买者"] --> Pricing["价格表"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Order 表"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Entitlement 表"]
Hosted --> Success["成功页"]
Success --> ReadOnly["只读取订单状态"]
成功页不是发放权限的地方。它只显示订单状态。真正的状态变更在 Webhook 里发生:验证 Stripe 签名,记录事件 ID,然后把已支付的 Checkout Session 交给同一个 fulfillment 函数。
可以这样要求 Claude Code:
Implement Stripe Checkout in a Next.js App Router app.
Do not grant access from the success page alone.
Verify the Stripe webhook with the raw request body.
Make fulfillment safe if the same Checkout Session is processed twice.
Do not print secrets or read .env values into chat.
Before editing, explain the target files and design.
商品表与环境变量
浏览器只发送 productKey。价格、Stripe Price ID、收费模式和权限名称都留在服务端。
// src/lib/billing/catalog.ts
export const BILLING_PRODUCTS = {
promptPack: {
label: "Claude Code Prompt Pack",
mode: "payment",
entitlement: "download:prompt-pack",
priceEnv: "STRIPE_PRICE_PROMPT_PACK",
},
proMonthly: {
label: "SaaS Pro Monthly",
mode: "subscription",
entitlement: "plan:pro",
priceEnv: "STRIPE_PRICE_PRO_MONTHLY",
},
workshopDeposit: {
label: "Claude Code Workshop Deposit",
mode: "payment",
entitlement: "booking:workshop-deposit",
priceEnv: "STRIPE_PRICE_WORKSHOP_DEPOSIT",
},
} as const;
export type ProductKey = keyof typeof BILLING_PRODUCTS;
export function getBillingProduct(productKey: string) {
if (productKey in BILLING_PRODUCTS) {
return {
key: productKey as ProductKey,
...BILLING_PRODUCTS[productKey as ProductKey],
};
}
throw new Error(`Unknown productKey: ${productKey}`);
}
export function getPriceId(priceEnv: string) {
const priceId = process.env[priceEnv];
if (!priceId) throw new Error(`Missing environment variable: ${priceEnv}`);
return priceId;
}
本地先使用测试模式。不要把 sk_test_、sk_live_ 或 whsec_ 粘贴到 Claude Code 对话里。
APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PROMPT_PACK=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_WORKSHOP_DEPOSIT=price_xxxxxxxxxxxxxxxxx
用数据库支撑幂等性
生产环境不能用内存保存支付状态。StripeEvent.id 防止同一个 Webhook 事件重复处理,Entitlement 保存应用真正用来判断权限的记录。
// prisma/schema.prisma
model Order {
id String @id @default(cuid())
userId String
productKey String
entitlement String
status String @default("PENDING")
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String?
stripeSubscriptionId String?
stripeRefundId String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Entitlement {
id String @id @default(cuid())
userId String
key String
active Boolean @default(true)
source String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, key])
}
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}
npx prisma db push
Checkout API
Stripe SDK 和 Webhook 验签都应放在 Node.js runtime。这个 API 做五件事:确认用户、创建内部订单、创建 Checkout Session、保存 Session ID、返回跳转 URL。
// src/lib/billing/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function appUrl(path: string) {
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { appUrl, stripe } from "@/lib/billing/stripe";
import { requireUser } from "@/lib/auth";
export const runtime = "nodejs";
const checkoutSchema = z.object({
productKey: z.string().min(1),
});
export async function POST(req: NextRequest) {
const user = await requireUser();
const { productKey } = checkoutSchema.parse(await req.json());
const product = getBillingProduct(productKey);
const order = await prisma.order.create({
data: {
userId: user.id,
productKey: product.key,
entitlement: product.entitlement,
},
});
const session = await stripe.checkout.sessions.create(
{
mode: product.mode,
customer_email: user.email,
line_items: [{ price: getPriceId(product.priceEnv), quantity: 1 }],
client_reference_id: order.id,
metadata: {
order_id: order.id,
product_key: product.key,
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
cancel_url: appUrl("/pricing"),
},
{
idempotencyKey: `checkout:${order.id}`,
}
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
if (!session.url) {
return NextResponse.json({ error: "Checkout URL was not created" }, { status: 500 });
}
return NextResponse.json({ url: session.url });
}
automatic_tax 和 tax_id_collection 只是把税务所需的信息更好地收集和交接出去,不代表税务问题自动解决。税务登记、发票文字、退款会计处理仍然需要人工确认。
价格表组件
UI 只发送商品键,不发送金额或 Price ID。最终价格由 Stripe Checkout 中的 Price 决定。
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "US$39",
description: "一次性购买模板和学习资料",
},
{
productKey: "proMonthly",
name: "Pro",
price: "US$29/月",
description: "小型 SaaS 的月度计划",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "US$100",
description: "培训或咨询预约金",
},
];
export function PricingTable() {
const [loadingKey, setLoadingKey] = useState<string | null>(null);
async function startCheckout(productKey: string) {
setLoadingKey(productKey);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productKey }),
});
const data = await res.json();
if (!res.ok) {
setLoadingKey(null);
alert(data.error ?? "Checkout failed");
return;
}
window.location.assign(data.url);
}
return (
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<section key={card.productKey} className="rounded-lg border p-5">
<h3 className="text-lg font-semibold">{card.name}</h3>
<p className="mt-2 text-2xl font-bold">{card.price}</p>
<p className="mt-2 text-sm text-gray-600">{card.description}</p>
<button
className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-60"
disabled={loadingKey === card.productKey}
onClick={() => startCheckout(card.productKey)}
>
{loadingKey === card.productKey ? "跳转中..." : "前往 Checkout"}
</button>
</section>
))}
</div>
);
}
按钮禁用只是用户体验层面的防抖。真正的安全性来自服务端价格表、Stripe idempotency key、Webhook 事件 ID 和数据库唯一约束。
Webhook 验签和交付
不要在验签前调用 await req.json()。Stripe 需要未经修改的 raw body。
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { fulfillCheckoutSession } from "@/lib/billing/fulfillment";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json({ error: "Missing webhook signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
try {
await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
} catch {
return NextResponse.json({ received: true, duplicate: true });
}
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckoutSession(session.id);
}
if (event.type === "checkout.session.async_payment_failed") {
const session = event.data.object as Stripe.Checkout.Session;
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: session.id, status: "PENDING" },
data: { status: "CANCELED" },
});
}
return NextResponse.json({ received: true });
}
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
function stripeObjectId(value: string | { id: string } | null | undefined) {
if (!value) return undefined;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckoutSession(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent", "subscription"],
});
if (session.payment_status !== "paid") {
return { status: "waiting_for_payment" as const };
}
const orderId = session.metadata?.order_id;
if (!orderId) throw new Error(`Missing order_id metadata for ${session.id}`);
return prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error(`Order not found: ${orderId}`);
if (order.status === "FULFILLED") {
return { status: "already_fulfilled" as const, order };
}
const updatedOrder = await tx.order.update({
where: { id: order.id },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
fulfilledAt: new Date(),
},
});
await tx.entitlement.upsert({
where: {
userId_key: {
userId: order.userId,
key: order.entitlement,
},
},
update: { active: true, source: `stripe:${session.id}` },
create: {
userId: order.userId,
key: order.entitlement,
active: true,
source: `stripe:${session.id}`,
},
});
return { status: "fulfilled" as const, order: updatedOrder };
});
}
重点是:付费权限只能在一个地方发放。Webhook、修复脚本或订单状态检查都可以调用同一个函数,结果仍然稳定。
退款与权限回收
退款不是单纯的 Stripe API 调用。应用必须决定是否收回权限、部分退款是否保留访问、预约金退款后是否释放名额。下面是管理员接口的最小版本。
// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { requireAdmin } from "@/lib/auth";
export const runtime = "nodejs";
const refundSchema = z.object({
orderId: z.string().min(1),
amount: z.number().int().positive().optional(),
});
export async function POST(req: NextRequest) {
await requireAdmin();
const { orderId, amount } = refundSchema.parse(await req.json());
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order?.stripePaymentIntentId) {
return NextResponse.json({ error: "Refundable payment not found" }, { status: 404 });
}
const refund = await stripe.refunds.create(
{
payment_intent: order.stripePaymentIntentId,
amount,
metadata: { order_id: order.id },
},
{
idempotencyKey: `refund:${order.id}:${amount ?? "full"}`,
}
);
await prisma.$transaction([
prisma.order.update({
where: { id: order.id },
data: { status: "REFUNDED", stripeRefundId: refund.id },
}),
prisma.entitlement.updateMany({
where: { userId: order.userId, key: order.entitlement },
data: { active: false },
}),
]);
return NextResponse.json({ refundId: refund.id, status: refund.status });
}
请让 Claude Code 在退款规则不明确时先提问。退款政策是产品和运营决策,不是 SDK 能自动猜出的细节。
本地测试
用 Stripe CLI 把事件转发到本地路由。
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
把 CLI 显示的 whsec_... 写入 STRIPE_WEBHOOK_SECRET,重启 Next.js。基本成功路径可以使用 Stripe 测试卡 4242 4242 4242 4242、未来有效期、任意 CVC 和任意邮编。拒付、身份验证和其他支付方式请查看 Stripe 的 Testing。
至少检查四点:Checkout API 创建了内部订单;Webhook 收到 checkout.session.completed;订单变成 FULFILLED 且 Entitlement 只有一条;同一事件重复到达不会多发权限。
Claude Code 审查提示词
实现后,不要马上发布。让 Claude Code 做一次严格审查。
Review this payment diff critically.
Check these risks:
- Access is not granted from the success page alone.
- The webhook verifies the raw body before JSON parsing.
- Stripe Event ID and Checkout Session ID prevent duplicate processing.
- The browser cannot choose price IDs or amounts.
- Metadata does not contain email, address, card data, or other sensitive data.
- Refunds keep Order and Entitlement state consistent.
- Tax, receipts, and cancellation policy are handed off to operations.
Claude Code 可以写代码,但支付行为的最终责任仍在人。API key、Dashboard 设置、税务登记、条款、退款政策和隐私日志都需要人工确认。
总结与 CTA
支付集成要从收入生命周期设计,而不是从按钮设计。价格表只发送 productKey,服务端创建 Checkout Session,Webhook 验证签名,一个幂等的 fulfillment 函数负责发放权限。退款和税务交接不要留到上线后补。
更细的 Checkout 实现可阅读用 Claude Code 实现 Stripe Checkout。环境变量可参考Claude Code 环境变量管理,Webhook 设计可参考Claude Code Webhook 实现。
ClaudeCodeLab 提供 Claude Code 支付实现审查、团队培训和 SaaS/内容产品变现路径设计。需要一起梳理 Checkout、Webhook、权限、退款和运营规则时,可以查看咨询与培训;模板和检查清单在产品导航中也有整理。
我在本地 Next.js 项目中用 Stripe 测试模式验证了这个流程:Checkout 成功、Webhook 转发、重复事件处理和全额退款。最有价值的不是先做漂亮的价格卡,而是先把 Order 和 Entitlement 建模清楚。状态模型稳定后,Claude Code 的改动和人工审查都会更可靠。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。