Stripe Billing 实施:订阅、发票和 Webhooks
Stripe 是计费基础设施事实上的标准,但正确实施它(幂等地处理 Webhook、管理订阅状态转换以及从失败的支付中恢复)比入门文档建议的要小心得多。一个支付系统在 99% 的时间里都能正常工作,而在另外 1% 的时间里默默地失败,比在 95% 的情况下大声失败的支付系统更糟糕。
本指南涵盖了具有 6 个 Webhook 事件类型的生产 Stripe 实现、订阅生命周期管理、自动许可证配置和退款处理。代码示例使用 NestJS 11,但这些模式适用于任何后端框架。
要点
- 切勿根据结帐重定向提供访问权限 - 始终等待
checkout.session.completedwebhook- 在处理任何事件之前使用
stripe.webhooks.constructEvent()验证 Webhook 签名- 使所有 Webhook 处理程序幂等 - Stripe 重试失败的 Webhook 长达 72 小时
- 将 Stripe 客户 ID、订阅 ID 和价格 ID 存储在数据库中 — 不要重新查询 Stripe 的所有信息
- 处理
invoice.payment_failed以触发催款电子邮件和订阅暂停charge.refunded和checkout.session.completed一起覆盖整个购买生命周期- 使用 Stripe 的
metadata字段将 Stripe 对象链接到您的内部记录- 切勿记录原始 Stripe Webhook 有效负载 - 它们包含敏感的卡数据
项目设置
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
存储这些环境变量:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
检查会话创建
结帐流程:您的后端创建一个 Stripe Checkout 会话,前端重定向到 Stripe,Stripe 收集付款,然后调用您的 webhook。切勿自行处理付款 - 始终重定向至 Stripe Checkout。
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Webhook 处理程序
Webhook 处理程序是 Stripe 集成中最关键的部分。每个事件都必须是幂等的:
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
重要:NestJS 需要 NestFactory.create() 中的 rawBody: true 来进行 Stripe 签名验证:
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
签出已完成的处理程序
这是核心配置处理程序。使其幂等:
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
客户门户
允许客户管理自己的订阅而无需联系支持人员:
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
客户门户允许用户取消订阅、更新付款方式、下载发票和查看账单历史记录,无需任何自定义 UI 工作。
前端集成
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
本地测试 Webhook
Stripe CLI 对于本地 Webhook 开发至关重要:
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
CLI 提供您的本地 Webhook 机密 — 在您的 .env.local 中使用它:
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
常见陷阱和解决方案
陷阱 1:在结帐重定向而不是 Webhook 上配置访问权限
结账成功重定向可以通过以下方式触发:浏览器后退/前进、重复选项卡或网络重试。始终基于 checkout.session.completed webhook 进行配置,而不是重定向 URL。
陷阱 2:不处理 webhook 重试(幂等性)
Stripe 重试失败的 webhook 72 小时。如果没有幂等性检查,重试会创建重复的订单、许可证和电子邮件。将 Stripe 会话 ID 存储在具有唯一约束的订单表中 — 重复插入会正常失败。
陷阱 3:不正确地使用原始主体中间件
Stripe 签名验证需要原始的、未解析的请求正文。如果 Express 的 JSON 主体解析器首先运行,则原始主体就会消失。在 NestJS 中使用 rawBody: true 并访问 req.rawBody,或配置 JSON 中间件以专门保留 webhook 路由的原始正文。
陷阱 4:缺少 Stripe API 调用的重试逻辑
Stripe API 调用可能会暂时失败。为幂等操作添加重试逻辑:
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
常见问题
我应该使用 Stripe Checkout 还是自定义付款方式?
始终使用 Stripe Checkout,除非您有无法使用的特定要求(罕见)。 Checkout 自动处理 PCI 合规性、3D 安全身份验证、Apple Pay、Google Pay、SEPA 和数十种其他支付方式。自定义付款表单(使用 Stripe Elements)要求您自行维护 PCI 合规性并手动实施每种付款方式。对于 95% 的 SaaS 产品来说,Checkout 是正确的选择。
如何处理 Stripe 的测试模式与实时模式?
使用单独的环境配置:开发中使用 STRIPE_SECRET_KEY=sk_test_... ,生产中使用 STRIPE_SECRET_KEY=sk_live_... 。 Stripe 测试模式与实时模式完全隔离——测试费用不会影响真实货币,并且测试模式下不会出现实时数据。在代码中使用 stripe.mode 来验证您使用的是正确的密钥,并在管理 UI 中添加一个显着的指示器来显示您所处的模式。
如果我的 webhook 服务器关闭且 Stripe 无法交付,会发生什么情况?
Stripe 按照指数退避计划重试失败的 Webhooks,最长可达 72 小时:5 分钟、30 分钟、1 小时、2 小时以及递增的间隔。 72小时后,该活动被视为失败。使用 Stripe 的事件仪表板手动重新传送失败的事件,或查询 stripe.events.list() 并重播它们。将您的处理程序设计为幂等的,以便重新传递是安全的。
如何实施计量/基于使用情况的计费?
Stripe Billing 支持通过 stripe.subscriptionItems.createUsageRecord() 计量使用。在每个计费周期结束时报告使用情况以及消耗的数量。在 Stripe 仪表板中将计量计划定价为“计量”定价。关键实施细节:在计费周期结束之前而不是之后报告使用情况 — Stripe 在周期结束时完成发票。
我应该如何在数据库中存储 Stripe ID?
将所有 Stripe ID 存储为 text 列(非数字)。在 stripe_customer_id、stripe_subscription_id 和 stripe_payment_intent_id 上创建索引 — 您将经常通过这些索引查找记录。在适当的情况下添加唯一约束(每个用户一条客户记录)。切勿将信用卡号或原始付款数据存储在您自己的数据库中。
后续步骤
正确实施 Stripe 计费(通过 Webhook 幂等性、订阅生命周期管理、自动配置和正确的退款处理)是一项艰巨的工程任务。 ECOSIRE 通过自动许可证配置和客户门户集成来处理生产中的 6 种 Stripe Webhook 事件类型。
如果您需要 Stripe 集成、订阅计费基础设施或全栈 SaaS 开发支持,请探索我们的开发服务。
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
2026 年 Shopify 支付网关(按国家/地区):美国、欧盟、印度、中东和北非、拉丁美洲
按国家/地区列出的 Shopify 支付网关完整指南:Shopify Payments、Stripe、Razorpay、Mercado Pago、Tap、PayMob、费用、资格、付款时间表。
Shopify Webhooks 2026:HMAC、重试、生产中的幂等性
构建可靠的 Shopify Webhook 接收器:HMAC 验证、重试策略、幂等性、死信队列和至少一次处理模式。
电子商务人工智能欺诈检测:在不阻止销售的情况下保护收入
实施 AI 欺诈检测,捕获 95% 以上的欺诈交易,同时将误报率控制在 2% 以下。机器学习评分、行为分析和投资回报率指南。