Payments

支付 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更新数据库中租户的 planstatuscurrentPeriodEnd
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 stripe
stripe login
stripe listen --forward-to localhost:3001/api/v1/webhooks/stripe

CLI 会打印本地 Webhook 签名密钥(whsec_...)。在本地开发时,将其设置为 .env.local 中的 STRIPE_WEBHOOK_SECRET

在另一个终端中,触发单个事件以测试您的处理程序:

# 模拟订阅更新成功
stripe trigger customer.subscription.updated

# 模拟支付失败
stripe trigger invoice.payment_failed

Stripe CLI 的签名密钥与生产环境的 Webhook 签名密钥不同。本地开发时在 .env.local 中使用 CLI 提供的密钥,生产环境中使用 Dashboard 中的密钥。

幂等性

Stripe 可能多次发送同一事件(至少一次投递语义)。请确保您的处理程序具备幂等性——处理同一事件两次的结果应与处理一次相同。

上述 subscription.updatedsubscription.deleted 处理程序已具备幂等性,因为它们使用了 upsert 式的数据库更新操作。

相关文档

How is this guide?

目录