Webhook 处理机制
Webhook 是支付系统的重要组成部分,它允许支付提供商在支付事件发生时主动通知你的应用。本文档详细说明 Stripe 和 Creem 的 Webhook 处理机制。
Webhook 概述
什么是 Webhook?
Webhook 是支付提供商向你的应用发送的 HTTP POST 请求,用于通知支付相关事件的发生,如:
- 支付成功
- 订阅创建/更新/取消
- 退款处理
- 发票支付失败
为什么需要 Webhook?
- 可靠性: 即使前端验证失败,Webhook 也能确保订单被正确处理
- 实时性: 支付提供商可以立即通知你的应用
- 完整性: 处理所有支付相关事件,包括续费、取消等
Stripe Webhook
Webhook 端点
URL: /api/stripe/webhook
方法: POST
签名验证
所有 Stripe Webhook 请求都必须验证签名以确保安全性:
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const sig = headersList.get('stripe-signature');
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!sig || !webhookSecret) {
return apiResponse.badRequest('Webhook secret not configured');
}
// 验证签名
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return apiResponse.badRequest(`Webhook Error: ${err.message}`);
}
// 处理事件
await processWebhookEvent(event);
}支持的事件类型
系统处理以下 Stripe Webhook 事件:
1. checkout.session.completed
触发时机: 用户完成结账(仅一次性支付)
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session
) {
// 仅处理一次性支付
if (session.mode !== 'payment') return;
const userId = session.metadata?.userId;
const planId = session.metadata?.planId;
const priceId = session.metadata?.priceId;
// 创建订单记录
const orderData = {
userId,
provider: 'stripe',
providerOrderId: session.payment_intent,
orderType: 'one_time_purchase',
status: 'succeeded',
planId,
priceId,
amountTotal: toCurrencyAmount(session.amount_total),
// ... 其他字段
};
const { order, existed } = await createOrderWithIdempotency(
'stripe',
orderData,
session.payment_intent
);
if (!existed && order) {
// 授予一次性积分
await upgradeOneTimeCredits(userId, planId, order.id);
}
}2. invoice.paid
触发时机: 订阅发票支付成功(包括初始支付和续费)
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
const customerId = invoice.customer as string;
// 同步订阅数据
await syncSubscriptionData(subscriptionId, customerId);
// 如果是续费发票,创建订单并授予积分
if (invoice.billing_reason === 'subscription_cycle') {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const userId = subscription.metadata?.userId;
const planId = subscription.metadata?.planId;
// 创建续费订单
const orderData = {
userId,
provider: 'stripe',
providerOrderId: invoice.payment_intent,
orderType: 'subscription_renewal',
status: 'succeeded',
planId,
subscriptionId,
amountTotal: toCurrencyAmount(invoice.amount_paid),
// ... 其他字段
};
const { order, existed } = await createOrderWithIdempotency(
'stripe',
orderData,
invoice.payment_intent
);
if (!existed && order) {
// 授予订阅积分(重置月度积分)
const currentPeriodStart = subscription.current_period_start * 1000;
await upgradeSubscriptionCredits(
userId,
planId,
order.id,
currentPeriodStart
);
}
}
}3. customer.subscription.created / customer.subscription.updated
触发时机: 订阅创建或更新
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
subscription: Stripe.Subscription
) {
const customerId = typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
// 同步订阅数据到数据库
await syncSubscriptionData(subscription.id, customerId);
}4. customer.subscription.deleted
触发时机: 订阅被取消或删除
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
subscription: Stripe.Subscription,
isDeleted: boolean = false
) {
if (isDeleted) {
// 同步订阅状态
await syncSubscriptionData(subscription.id, customerId);
// 撤销剩余订阅积分
const userId = subscription.metadata?.userId;
if (userId) {
await revokeRemainingSubscriptionCreditsOnEnd(
'stripe',
subscription.id,
userId,
subscription.metadata
);
}
}
}5. invoice.payment_failed
触发时机: 订阅发票支付失败
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaymentFailed(
invoice: Stripe.Invoice
) {
const subscriptionId = invoice.subscription as string;
const customerId = invoice.customer as string;
// 发送支付失败通知邮件
await sendInvoicePaymentFailedEmail({
invoice,
subscriptionId,
customerId,
invoiceId: invoice.id
});
}6. charge.refunded
触发时机: 支付被退款
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleRefund(charge: Stripe.Charge) {
const paymentIntentId = charge.payment_intent as string;
// 查找原始订单
const originalOrder = await findOriginalOrderForRefund(
'stripe',
paymentIntentId
);
if (!originalOrder) {
console.warn(`Original order not found for refund: ${paymentIntentId}`);
return;
}
// 检查退款订单是否已存在
const refundId = charge.refunds.data[0]?.id;
if (await refundOrderExists('stripe', refundId)) {
return; // 已处理过
}
// 创建退款订单
const refundOrderData = {
userId: originalOrder.userId,
provider: 'stripe',
providerOrderId: refundId,
orderType: 'refund',
status: 'succeeded',
planId: originalOrder.planId,
amountTotal: `-${toCurrencyAmount(charge.amount_refunded)}`,
// ... 其他字段
};
await createOrderWithIdempotency('stripe', refundOrderData, refundId);
// 更新原始订单状态
await updateOrderStatusAfterRefund(
originalOrder.id,
charge.amount_refunded,
toCents(originalOrder.amountTotal)
);
// 撤销积分
if (originalOrder.orderType === 'one_time_purchase') {
await revokeOneTimeCredits(
charge.amount_refunded,
originalOrder,
refundId
);
} else {
await revokeSubscriptionCredits(originalOrder);
}
}7. radar.early_fraud_warning.created
触发时机: Stripe Radar 检测到潜在的欺诈行为
处理逻辑:
app/api/stripe/webhook/webhook-handlers.ts
export async function handleEarlyFraudWarningCreated(
warning: Stripe.Radar.EarlyFraudWarning
) {
const chargeId = warning.charge as string;
const charge = await stripe.charges.retrieve(chargeId);
// 发送管理员警告邮件
await sendFraudWarningAdminEmail({
warningId: warning.id,
chargeId,
customerId: charge.customer as string,
amount: charge.amount,
currency: charge.currency,
fraudType: warning.actionable ? 'Actionable' : 'Informational',
chargeDescription: charge.description,
actionsTaken: ['Email sent to admin']
});
// 如果确认是欺诈,可以自动退款
if (warning.actionable) {
// 执行退款
const refund = await stripe.refunds.create({ charge: chargeId });
// 发送用户退款通知
await sendFraudRefundUserEmail({
charge,
refundAmount: refund.amount
});
}
}Creem Webhook
Webhook 端点
URL: /api/creem/webhook
方法: POST
签名验证
Creem Webhook 使用 HMAC-SHA256 签名验证:
app/api/creem/webhook/route.ts
function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
const crypto = require('crypto');
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return computedSignature === signature;
}
export async function POST(req: Request) {
const rawBody = await req.text();
const headersList = await headers();
const signature = headersList.get('creem-signature');
const secret = process.env.CREEM_WEBHOOK_SECRET;
if (!verifySignature(rawBody, signature, secret)) {
return apiResponse.badRequest('Signature verification failed');
}
const payload = JSON.parse(rawBody);
await processWebhookEvent(payload);
}支持的事件类型
1. checkout.completed
触发时机: 一次性支付完成
处理逻辑:
app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentSucceeded(
payload: CreemCheckoutCompletedEvent
) {
const payment = payload.object;
const order = payment.order;
// 仅处理一次性支付
if (order.type !== 'onetime') return;
const userId = payment.metadata.userId;
const planId = payment.metadata.planId;
// 创建订单记录
const orderData = {
userId,
provider: 'creem',
providerOrderId: order.id,
orderType: 'one_time_purchase',
status: payment.status === 'completed' ? 'succeeded' : payment.status,
planId,
amountTotal: toCurrencyAmount(order.amount_paid),
// ... 其他字段
};
const { order: insertedOrder, existed } = await createOrderWithIdempotency(
'creem',
orderData,
order.id
);
if (!existed && insertedOrder) {
// 授予一次性积分
await upgradeOneTimeCredits(userId, planId, insertedOrder.id);
}
}2. subscription.paid
触发时机: 订阅支付成功(包括初始支付和续费)
处理逻辑:
app/api/creem/webhook/handlers.ts
export async function handleCreemInvoicePaid(
payload: CreemSubscriptionPaidEvent
) {
const subscription = payload.object;
const lastTransaction = subscription.last_transaction;
const orderId = lastTransaction.order;
const userId = subscription.metadata.userId;
const planId = subscription.metadata.planId;
// 创建订单记录
const orderData = {
userId,
provider: 'creem',
providerOrderId: orderId,
orderType: 'recurring',
status: lastTransaction.status === 'paid' ? 'succeeded' : lastTransaction.status,
planId,
subscriptionId: subscription.id,
amountTotal: toCurrencyAmount(lastTransaction.amount_paid),
// ... 其他字段
};
const { order: insertedOrder, existed } = await createOrderWithIdempotency(
'creem',
orderData,
orderId
);
if (!existed && insertedOrder) {
// 授予订阅积分
const currentPeriodStart = new Date(subscription.current_period_start_date).getTime();
await upgradeSubscriptionCredits(
userId,
planId,
insertedOrder.id,
currentPeriodStart
);
}
}3. subscription.active / subscription.update
触发时机: 订阅激活或更新
处理逻辑:
app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
payload: CreemSubscriptionUpdateEvent | CreemSubscriptionActiveEvent
) {
const subscription = payload.object;
// 同步订阅数据
await syncCreemSubscriptionData(
subscription.id,
subscription.metadata
);
}4. subscription.canceled / subscription.expired
触发时机: 订阅取消或过期
处理逻辑:
app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
payload: CreemSubscriptionCanceledEvent | CreemSubscriptionExpiredEvent,
isDeleted: boolean = false
) {
const subscription = payload.object;
// 同步订阅状态
await syncCreemSubscriptionData(subscription.id, subscription.metadata);
if (isDeleted) {
// 撤销剩余订阅积分
let userId = subscription.metadata?.userId;
if (!userId) {
// 从数据库查询用户 ID
const storeSubscription = await db
.select({ userId: subscriptionsSchema.userId })
.from(subscriptionsSchema)
.where(eq(subscriptionsSchema.subscriptionId, subscription.id))
.limit(1);
userId = storeSubscription[0]?.userId;
}
if (userId) {
await revokeRemainingSubscriptionCreditsOnEnd(
'creem',
subscription.id,
userId,
subscription.metadata
);
}
}
}5. refund.created
触发时机: 退款创建
处理逻辑:
app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentRefunded(
payload: CreemRefundCreatedEvent
) {
const refund = payload.object;
const orderId = refund.order;
// 检查退款订单是否已存在
if (await refundOrderExists('creem', refund.id)) {
return; // 已处理过
}
// 查找原始订单
const originalOrder = await findOriginalOrderForRefund('creem', orderId);
if (!originalOrder) {
console.warn(`Original order not found for refund: ${orderId}`);
return;
}
// 创建退款订单
const refundOrderData = {
userId: originalOrder.userId,
provider: 'creem',
providerOrderId: refund.id,
orderType: 'refund',
status: 'succeeded',
planId: originalOrder.planId,
amountTotal: `-${toCurrencyAmount(refund.amount)}`,
// ... 其他字段
};
await createOrderWithIdempotency('creem', refundOrderData, refund.id);
// 更新原始订单状态
await updateOrderStatusAfterRefund(
originalOrder.id,
refund.amount,
toCents(originalOrder.amountTotal)
);
// 撤销积分
if (originalOrder.orderType === 'one_time_purchase') {
await revokeOneTimeCredits(
refund.amount,
originalOrder,
refund.id
);
} else {
await revokeSubscriptionCredits(originalOrder);
}
}Webhook 测试
Stripe Webhook 测试
- 使用 Stripe CLI:
# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
# 登录
stripe login
# 转发 Webhook 到本地
stripe listen --forward-to localhost:3000/api/stripe/webhook
# 触发测试事件
stripe trigger checkout.session.completed- 使用 Stripe Dashboard:
- 进入 Stripe Dashboard > Developers > Webhooks
- 添加 Webhook 端点
- 发送测试事件
Creem Webhook 测试
- 使用 Creem Dashboard:
- 进入 Creem Dashboard > Settings > Webhooks
- 添加 Webhook URL
- 发送测试事件
- 本地测试:
使用 VS Code/Cursor 的 Ports 功能或者 ngrok 等工具将本地服务暴露到公网:
ngrok http 3000
# 使用 ngrok 提供的 URL 配置 Webhook生产环境验证
产品上线后,你可以创建一个 100% 优惠码测试支付流程是否正确运行。