支付 Webhooks
处理 Stripe Webhook 事件,实现订阅生命周期管理、发票支付与计划同步。
端点
所有 Stripe 事件均发送至 API 网关的单一端点:
POST /api/v1/webhooks/stripe该端点已预置于 backends/gateway/src/routes/webhooks/stripe.ts。您无需编写路由逻辑——仅在需要处理新事件时添加对应的处理程序。
签名验证
在处理任何事件之前,务必验证 Stripe Webhook 的签名。跳过验证将允许任何人向您的端点发送伪造事件,从而操纵账单状态。
Stripe 使用 Stripe-Signature 请求头对每个请求进行签名。处理程序使用 STRIPE_WEBHOOK_SECRET 进行验证:
import Stripe from "stripe";
import { PLANS } from "@nebutra/billing";
import { db } from "@nebutra/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new Response("Missing stripe-signature header", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await handleStripeEvent(event);
return new Response("OK", { status: 200 });
}已处理事件
| Stripe 事件 | 处理程序动作 |
|---|---|
customer.subscription.updated | 更新数据库中租户的 plan、status 和 currentPeriodEnd |
customer.subscription.deleted | 将租户降级至 FREE 计划 |
invoice.payment_succeeded | 记录支付成功;清除 past_due 标记 |
invoice.payment_failed | 将租户状态设为 past_due;触发支付失败邮件 |
事件处理程序实现
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
const orgId = subscription.metadata.orgId;
const priceId = subscription.items.data[0]?.price.id;
const plan = Object.values(PLANS).find(
(p) => p.stripePriceId === priceId
);
await db.tenant.update({
where: { id: orgId },
data: {
plan: plan?.id ?? "free",
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
const orgId = subscription.metadata.orgId;
await db.tenant.update({
where: { id: orgId },
data: {
plan: "free",
subscriptionStatus: "canceled",
currentPeriodEnd: null,
},
});
}async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const tenant = await db.tenant.findFirst({
where: { stripeCustomerId: customerId },
});
if (!tenant) return;
await db.tenant.update({
where: { id: tenant.id },
data: { subscriptionStatus: "past_due" },
});
// 通过 @nebutra/notifications 通知用户
await notifications.send({
id: crypto.randomUUID(),
type: "billing.payment_failed",
recipientId: tenant.ownerId,
tenantId: tenant.id,
channels: ["email"],
data: { invoiceUrl: invoice.hosted_invoice_url },
});
}在 Stripe Dashboard 中注册事件
设置生产环境 Webhook 端点时,只需启用以下事件:
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
checkout.session.completed进入 Stripe Dashboard → Developers → Webhooks → Add endpoint,填写 API URL 并选择上述事件。
使用 Stripe CLI 进行本地测试
# macOS
brew install stripe/stripe-cli/stripe
# npm(跨平台)
npm install -g stripestripe loginstripe listen --forward-to localhost:3001/api/v1/webhooks/stripeCLI 会打印本地 Webhook 签名密钥(whsec_...)。在本地开发时,将其设置为 .env.local 中的 STRIPE_WEBHOOK_SECRET。
在另一个终端中,触发单个事件以测试您的处理程序:
# 模拟订阅更新成功
stripe trigger customer.subscription.updated
# 模拟支付失败
stripe trigger invoice.payment_failedStripe CLI 的签名密钥与生产环境的 Webhook 签名密钥不同。本地开发时在 .env.local 中使用 CLI 提供的密钥,生产环境中使用 Dashboard 中的密钥。
幂等性
Stripe 可能多次发送同一事件(至少一次投递语义)。请确保您的处理程序具备幂等性——处理同一事件两次的结果应与处理一次相同。
上述 subscription.updated 和 subscription.deleted 处理程序已具备幂等性,因为它们使用了 upsert 式的数据库更新操作。
相关文档
How is this guide?
最后更新于