支付集成
Creem 支付平台集成完整指南
支付集成
Sistine Starter 使用 Creem 作为支付解决方案,支持订阅计划和一次性积分包购买。本文档详细介绍 Creem 的配置步骤、定价计划、Checkout 流程、Webhook 处理和安全机制。
Creem 简介
Creem 是一个现代化的支付平台,专为 SaaS 应用设计,具有以下特点:
- 简洁 API: 清晰的 RESTful API 设计
- 订阅管理: 内置订阅周期管理和自动续费
- Webhook 通知: 实时支付状态更新
- 安全可靠: HMAC-SHA256 签名验证
- 开发友好: 支持测试模式
配置步骤
1. 获取 Creem API Key
- 访问 Creem Dashboard
- 创建账号并登录
- 导航到 Settings > API Keys
- 复制 API Key (用于创建 Checkout Session)
2. 配置环境变量
将以下环境变量添加到 .env.local
:
# Creem API Key (必需)
CREEM_API_KEY="ck_live_xxxxxxxxxxxxxxxxxx"
# Webhook 签名密钥 (必需,在配置 Webhook 后获取)
CREEM_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxx"
# API 基础 URL (可选,默认为 https://api.creem.io)
CREEM_API_BASE="https://api.creem.io"
# 测试模式 (可选,开发环境使用)
CREEM_SIMULATE="true" # 跳过实际支付,直接成功
开发环境测试模式:
设置 CREEM_SIMULATE="true"
可以在本地开发时跳过实际支付流程,直接模拟支付成功:
# .env.local (开发环境)
CREEM_SIMULATE="true"
生产环境:
# .env (生产环境)
CREEM_SIMULATE="false" # 或删除此行
3. 创建产品和价格
在 Creem Dashboard 中创建产品:
订阅计划产品
产品名称 | 类型 | 价格 | 周期 |
---|---|---|---|
Starter Monthly | Subscription | $29.00 | Monthly |
Starter Yearly | Subscription | $290.00 | Yearly |
Pro Monthly | Subscription | $99.00 | Monthly |
Pro Yearly | Subscription | $990.00 | Yearly |
一次性积分包
产品名称 | 类型 | 价格 |
---|---|---|
200 Credits Pack | One-time | $5.00 |
创建产品后,复制每个产品的 Product ID (prod_xxxx
),并更新到 /Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts
。
4. 配置 Webhook
在 Creem Dashboard 中设置 Webhook:
- 导航到 Settings > Webhooks
- 点击 Add Endpoint
- 填写以下信息:
- URL:
https://your-domain.com/api/payments/creem/webhook
- Events: 选择以下事件:
checkout.completed
subscription.paid
subscription.active
- URL:
- 保存后复制 Webhook Secret (
whsec_xxx
) - 更新环境变量
CREEM_WEBHOOK_SECRET
本地开发 Webhook 测试:
使用 ngrok 或 Cloudflare Tunnel 将本地服务暴露到公网:
# 使用 ngrok
ngrok http 3000
# Webhook URL 示例
https://abc123.ngrok.io/api/payments/creem/webhook
定价计划配置
所有定价计划配置在 /Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts
:
月付订阅计划
月付计划会在支付成功后立即发放全部积分。
export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
starter_monthly: {
key: "starter_monthly",
kind: "subscription",
priceCents: 2900, // $29.00
currency: "usd",
creditsPerCycle: 1000, // 每月 1000 积分
cycle: "month",
creemPriceId: "prod_6oSIwPL8m6scklr3fwdkC9", // Creem 产品 ID
grantSchedule: { mode: "per_cycle" }, // 立即发放
},
pro_monthly: {
key: "pro_monthly",
kind: "subscription",
priceCents: 9900, // $99.00
currency: "usd",
creditsPerCycle: 10000, // 每月 10000 积分
cycle: "month",
creemPriceId: "prod_5Xzh9qV5TWeTQtRxjZPEHM",
grantSchedule: { mode: "per_cycle" },
},
};
计划 | 价格/月 | 积分/月 | 发放方式 |
---|---|---|---|
Starter Monthly | $29 | 1,000 | 立即发放全部 |
Pro Monthly | $99 | 10,000 | 立即发放全部 |
年付订阅计划 (分期发放)
年付计划采用分期发放机制:首次支付后立即发放第一个月的积分,后续每月自动发放剩余积分。
export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
starter_yearly: {
key: "starter_yearly",
kind: "subscription",
priceCents: 29000, // $290.00 (年付)
currency: "usd",
creditsPerCycle: 12000, // 全年总共 12000 积分
cycle: "year",
creemPriceId: "prod_2V1LbGt2bLmZpKgmASTiCN",
grantSchedule: {
mode: "installments", // 分期发放模式
grantsPerCycle: 12, // 分 12 次发放
intervalMonths: 1, // 每月发放一次
creditsPerGrant: 1000, // 每次发放 1000 积分
initialGrants: 1, // 首次立即发放 1 次
},
},
pro_yearly: {
key: "pro_yearly",
kind: "subscription",
priceCents: 99000, // $990.00 (年付)
currency: "usd",
creditsPerCycle: 120000, // 全年总共 120000 积分
cycle: "year",
creemPriceId: "prod_2xyljTJW1IlT8FUDrucU3X",
grantSchedule: {
mode: "installments",
grantsPerCycle: 12,
intervalMonths: 1,
creditsPerGrant: 10000, // 每次发放 10000 积分
initialGrants: 1,
},
},
};
计划 | 价格/年 | 总积分 | 每月发放 | 发放次数 |
---|---|---|---|---|
Starter Yearly | $290 | 12,000 | 1,000 | 12 次 |
Pro Yearly | $990 | 120,000 | 10,000 | 12 次 |
年付分期发放流程:
用户购买年付计划
↓
立即发放 1000/10000 积分 (第 1 个月)
↓
记录到 subscriptionCreditSchedule 表
↓
Cron 定时任务每小时检查
↓
每月自动发放剩余积分 (共 11 次)
一次性积分包
一次性积分包支付成功后立即发放全部积分。
export const oneTimePacks: Record<PackKey, OneTimePack> = {
pack_200: {
key: "pack_200",
kind: "one_time",
priceCents: 500, // $5.00
currency: "usd",
credits: 200, // 200 积分
creemPriceId: "prod_3SiroZeMbMQidMVFDMUzKy",
},
};
积分包 | 价格 | 积分数 | 发放方式 |
---|---|---|---|
200 Credits Pack | $5 | 200 | 立即发放 |
修改定价计划
如需调整价格或积分数量:
- 编辑
/Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts
- 修改对应计划的
priceCents
和creditsPerCycle
/credits
- 在 Creem Dashboard 中创建对应的新产品
- 更新
creemPriceId
为新产品的 ID
示例: 创建新的 500 积分包:
// constants/billing.ts
export type PackKey = "pack_200" | "pack_500"; // 添加新 Key
export const oneTimePacks: Record<PackKey, OneTimePack> = {
pack_200: { /* ... */ },
pack_500: {
key: "pack_500",
kind: "one_time",
priceCents: 2000, // $20.00
currency: "usd",
credits: 500,
creemPriceId: "prod_YOUR_NEW_PRODUCT_ID",
},
};
Checkout 流程
创建 Checkout Session
用户点击购买按钮时,前端调用 /api/payments/creem/checkout
创建支付会话。
API 端点: POST /api/payments/creem/checkout
请求体:
{
"key": "starter_monthly" | "pro_yearly" | "pack_200",
"successUrl": "https://yourdomain.com/dashboard?payment=success",
"cancelUrl": "https://yourdomain.com/pricing?payment=cancelled"
}
响应:
{
"url": "https://checkout.creem.io/xxxxxxxxxxxxx"
}
前端集成示例:
// components/pricing-card.tsx
"use client";
import { useState } from "react";
import { useSession } from "@/lib/auth-client";
export function PricingCard({ planKey }: { planKey: string }) {
const session = useSession();
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
if (!session.data) {
// 未登录,跳转到登录页
window.location.href = "/login";
return;
}
setLoading(true);
try {
const res = await fetch("/api/payments/creem/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: planKey,
successUrl: `${window.location.origin}/dashboard?payment=success`,
cancelUrl: `${window.location.origin}/pricing?payment=cancelled`,
}),
});
const data = await res.json();
if (data.url) {
// 重定向到 Creem Checkout 页面
window.location.href = data.url;
} else {
alert(data.error || "Failed to create checkout session");
}
} catch (error) {
console.error("Checkout error:", error);
alert("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<button onClick={handleCheckout} disabled={loading}>
{loading ? "Processing..." : "Purchase"}
</button>
);
}
Checkout 实现逻辑
核心实现位于 /Users/mac/Documents/product/sistine-starter-vibe-to-production/lib/payments/creem.ts
:
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<CreateCheckoutResult> {
const apiKey = getEnv("CREEM_API_KEY");
const simulate = process.env.CREEM_SIMULATE === "true";
if (simulate) {
// 测试模式:跳过实际支付
return { url: "/api/payments/creem/redirect-placeholder?success=1" };
}
// 创建 Checkout Payload
const payload = {
product_id: params.creemPriceId, // Creem 产品 ID
success_url: params.successUrl,
metadata: {
userId: params.userId, // 用户 ID
key: params.key, // 计划标识
kind: params.kind, // "subscription" 或 "one_time"
},
};
const base = process.env.CREEM_API_BASE || "https://api.creem.io";
const endpointUrl = `${base}/v1/checkouts`;
const res = await fetch(endpointUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey, // Creem 使用 x-api-key 头
},
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`Creem checkout create failed: ${res.status}`);
}
const data = await res.json();
const redirectUrl = data.checkout_url || data.url;
return { url: redirectUrl };
}
关键参数说明:
参数 | 说明 |
---|---|
product_id | Creem 产品 ID (creemPriceId ) |
success_url | 支付成功后的重定向 URL |
metadata.userId | 用户 ID,用于 Webhook 中识别用户 |
metadata.key | 计划标识 (starter_monthly , pack_200 等) |
metadata.kind | 类型 (subscription 或 one_time ) |
Webhook 配置和处理
Webhook 事件类型
Sistine Starter 监听以下 Creem Webhook 事件:
事件 | 说明 | 处理逻辑 |
---|---|---|
checkout.completed | 支付成功 | 记录支付、发放积分、创建订阅 |
subscription.paid | 订阅续费成功 | 记录支付、发放积分 |
subscription.active | 订阅激活 | 更新订阅状态为 active |
Webhook 处理流程
Webhook 处理逻辑位于 /Users/mac/Documents/product/sistine-starter-vibe-to-production/app/api/payments/creem/webhook/route.ts
:
export async function POST(req: NextRequest) {
// 1. 读取原始请求体 (用于签名验证)
const rawBody = await req.text();
// 2. 验证 Webhook 签名
const ok = verifyWebhookSignature(req.headers, rawBody);
if (!ok) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// 3. 解析事件
const event = JSON.parse(rawBody);
const type = event.eventType; // "checkout.completed" 等
const mainObject = event.object || {};
const metadata = mainObject.metadata || {};
// 4. 提取关键信息
const userId = metadata.userId;
const key = metadata.key; // 计划标识
const kind = metadata.kind; // "subscription" 或 "one_time"
const paymentId = mainObject.order?.id || event.id;
const amountCents = mainObject.order?.amount || 0;
// 5. 幂等性检查 (防止重复处理)
const existing = await db
.select()
.from(paymentTable)
.where(eq(paymentTable.providerPaymentId, paymentId));
if (existing.length > 0) {
return NextResponse.json({ received: true }); // 已处理,跳过
}
// 6. 计算积分发放数量
let creditsToGrant = 0;
if (kind === "one_time") {
creditsToGrant = oneTimePacks[key].credits;
} else if (kind === "subscription") {
const plan = subscriptionPlans[key];
const schedule = getGrantSchedule(key);
if (schedule) {
// 年付计划:仅发放首次积分
const initialGrant = computeInitialGrant(schedule);
creditsToGrant = initialGrant.creditsNow;
} else {
// 月付计划:发放全部积分
creditsToGrant = plan.creditsPerCycle;
}
}
// 7. 插入支付记录
await db.insert(paymentTable).values({
id: paymentId,
provider: "creem",
providerPaymentId: paymentId,
userId,
amountCents,
currency: "usd",
status: "succeeded",
type: kind,
planKey: kind === "subscription" ? key : undefined,
creditsGranted: creditsToGrant,
});
// 8. 创建或更新订阅记录 (仅订阅类型)
if (kind === "subscription") {
await db.insert(subscriptionTable).values({
id: subscriptionId,
provider: "creem",
providerSubId: subscriptionId,
userId,
planKey: key,
status: "active",
currentPeriodEnd: extractCurrentPeriodEnd(),
});
}
// 9. 发放积分 (事务性操作)
await db.transaction(async tx => {
// 增加用户积分
await tx
.update(userTable)
.set({ credits: sql`${userTable.credits} + ${creditsToGrant}` })
.where(eq(userTable.id, userId));
// 记录到积分账本
await tx.insert(creditLedger).values({
id: paymentId,
userId,
delta: creditsToGrant,
reason: kind === "one_time" ? "one_time_pack" : "subscription_cycle",
paymentId,
});
// 更新用户订阅计划
if (kind === "subscription") {
await tx
.update(userTable)
.set({ planKey: key })
.where(eq(userTable.id, userId));
}
// 设置积分发放调度 (年付计划)
if (scheduleResetContext) {
await resetSubscriptionSchedule({
subscriptionId,
userId,
derivedSchedule: schedule,
grantsRemaining: initialGrant.grantsRemaining,
totalCreditsRemaining: initialGrant.totalCreditsRemaining,
nextGrantAt: initialGrant.nextGrantAt,
});
}
});
// 10. 发送购买确认邮件
await sendPurchaseEmail(userEmail, {
orderId: paymentId,
plan: key,
amount: `$${(amountCents / 100).toFixed(2)}`,
credits: creditsToGrant,
type: kind,
});
return NextResponse.json({ received: true });
}
关键处理逻辑
1. 幂等性保护
通过 providerPaymentId
确保同一支付不会被重复处理:
const existing = await db
.select()
.from(paymentTable)
.where(eq(paymentTable.providerPaymentId, paymentId));
if (existing.length > 0) {
return NextResponse.json({ received: true }); // 已处理
}
2. 积分发放策略
- 月付订阅: 立即发放
creditsPerCycle
全部积分 - 年付订阅: 仅发放
creditsPerGrant
(首次),剩余通过定时任务发放 - 一次性积分包: 立即发放
credits
全部积分
3. 订阅记录管理
仅为 subscription
类型创建 subscription
表记录:
if (kind === "subscription" && subscriptionId) {
await db.insert(subscriptionTable).values({
id: subscriptionId,
provider: "creem",
providerSubId: subscriptionId,
userId,
planKey: key,
status: "active",
currentPeriodEnd: extractedDate,
});
}
年付分期发放机制详解
年付订阅采用分期发放机制,避免用户一次性获得全年积分后立即取消订阅。
数据库表结构
CREATE TABLE "subscription_credit_schedule" (
"subscription_id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"credits_per_grant" INTEGER NOT NULL, -- 每次发放的积分数
"grants_remaining" INTEGER NOT NULL, -- 剩余发放次数
"total_credits_remaining" INTEGER NOT NULL, -- 剩余总积分
"next_grant_at" TIMESTAMP NOT NULL, -- 下次发放时间
"created_at" TIMESTAMP DEFAULT NOW(),
"updated_at" TIMESTAMP DEFAULT NOW()
);
发放流程
步骤 1: 首次支付
用户购买年付计划时:
// 计算首次发放
const initialGrant = computeInitialGrant(schedule);
// 立即发放首次积分 (1000 或 10000)
creditsToGrant = initialGrant.creditsNow; // 1000 (Starter) 或 10000 (Pro)
// 创建发放调度记录
await resetSubscriptionSchedule({
subscriptionId,
userId,
derivedSchedule: schedule,
grantsRemaining: 11, // 剩余 11 次
totalCreditsRemaining: 11000, // 剩余 11000 积分 (Starter)
nextGrantAt: new Date("2025-11-15"), // 30 天后
});
subscriptionCreditSchedule
表记录:
subscriptionId: "sub_xxx"
userId: "user_123"
creditsPerGrant: 1000
grantsRemaining: 11
totalCreditsRemaining: 11000
nextGrantAt: 2025-11-15T00:00:00Z
步骤 2: 定时任务自动发放
定时任务 /Users/mac/Documents/product/sistine-starter-vibe-to-production/app/api/cron/grant-subscription-credits/route.ts
每小时执行一次:
export async function GET(req: NextRequest) {
// 1. 验证 Cron 认证
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. 查询到期的发放任务
const now = new Date();
const schedules = await db
.select()
.from(subscriptionCreditSchedule)
.where(
sql`${subscriptionCreditSchedule.nextGrantAt} <= ${now}
AND ${subscriptionCreditSchedule.grantsRemaining} > 0`
);
// 3. 逐个处理
for (const schedule of schedules) {
await db.transaction(async tx => {
// 发放积分
await tx
.update(userTable)
.set({
credits: sql`${userTable.credits} + ${schedule.creditsPerGrant}`
})
.where(eq(userTable.id, schedule.userId));
// 记录到账本
await tx.insert(creditLedger).values({
id: randomUUID(),
userId: schedule.userId,
delta: schedule.creditsPerGrant,
reason: "subscription_cycle",
paymentId: schedule.subscriptionId,
});
// 更新调度记录
const newGrantsRemaining = schedule.grantsRemaining - 1;
const newTotalRemaining = schedule.totalCreditsRemaining - schedule.creditsPerGrant;
if (newGrantsRemaining > 0) {
// 还有剩余,计算下次发放时间
const nextGrantAt = new Date(schedule.nextGrantAt);
nextGrantAt.setMonth(nextGrantAt.getMonth() + 1); // 30 天后
await tx
.update(subscriptionCreditSchedule)
.set({
grantsRemaining: newGrantsRemaining,
totalCreditsRemaining: newTotalRemaining,
nextGrantAt,
updatedAt: new Date(),
})
.where(eq(subscriptionCreditSchedule.subscriptionId, schedule.subscriptionId));
} else {
// 全部发放完毕,删除调度记录
await tx
.delete(subscriptionCreditSchedule)
.where(eq(subscriptionCreditSchedule.subscriptionId, schedule.subscriptionId));
}
});
}
return NextResponse.json({ processed: schedules.length });
}
步骤 3: 部署 Cron Job
在 Vercel 或其他平台上配置定时任务:
Vercel Cron (推荐):
在 vercel.json
中添加:
{
"crons": [
{
"path": "/api/cron/grant-subscription-credits",
"schedule": "0 * * * *"
}
]
}
外部 Cron 服务 (如 cron-job.org):
# 每小时执行一次
0 * * * * curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
https://yourdomain.com/api/cron/grant-subscription-credits
环境变量:
CRON_SECRET="your-random-cron-secret-key"
发放时间线示例
以 Starter Yearly ($290, 12000 积分) 为例:
2025-10-15 用户购买 → 立即发放 1000 积分
2025-11-15 定时任务 → 发放 1000 积分 (剩余 10 次)
2025-12-15 定时任务 → 发放 1000 积分 (剩余 9 次)
2026-01-15 定时任务 → 发放 1000 积分 (剩余 8 次)
...
2026-09-15 定时任务 → 发放 1000 积分 (最后一次,删除调度记录)
订阅续费处理
当年付订阅续费时,Webhook 会再次触发 subscription.paid
事件:
// Webhook 处理逻辑
if (scheduleResetContext) {
// 重置发放调度 (重新开始 12 次发放)
await resetSubscriptionSchedule({
subscriptionId,
userId,
derivedSchedule: schedule,
grantsRemaining: 11,
totalCreditsRemaining: 11000,
nextGrantAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
}
这样可以确保每次续费后重新开始 12 个月的分期发放周期。
安全机制
1. Webhook 签名验证
Creem 使用 HMAC-SHA256 签名确保 Webhook 来源可信:
export function verifyWebhookSignature(headers: Headers, rawBody: string): boolean {
// 获取签名头
const signature = headers.get("creem-signature") || headers.get("x-creem-signature");
if (!signature) {
return false;
}
const webhookSecret = process.env.CREEM_WEBHOOK_SECRET;
if (!webhookSecret) {
return false;
}
// 计算预期签名
const computedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(rawBody)
.digest("hex");
// 时序安全比较 (防止时序攻击)
const sigBuf = Buffer.from(signature);
const compBuf = Buffer.from(computedSignature);
if (sigBuf.length !== compBuf.length) {
return false;
}
return crypto.timingSafeEqual(sigBuf, compBuf);
}
签名验证流程:
Creem 发送 Webhook
↓
计算 HMAC-SHA256(rawBody, CREEM_WEBHOOK_SECRET)
↓
在 creem-signature 头中附带签名
↓
我们的服务器重新计算签名
↓
比较两个签名 (timing-safe)
↓
验证通过 → 处理事件
验证失败 → 返回 400 错误
2. 幂等性保护
通过 providerPaymentId
防止重复处理同一支付:
const existing = await db
.select()
.from(paymentTable)
.where(eq(paymentTable.providerPaymentId, paymentId));
if (existing.length > 0) {
// 已处理,直接返回成功
return NextResponse.json({ received: true });
}
场景:
- Creem 重试 Webhook (网络故障)
- 同时收到
checkout.completed
和subscription.paid
(同一支付)
3. 事务性操作
积分发放、账本记录、订阅更新在同一数据库事务中完成:
await db.transaction(async tx => {
// 1. 增加积分
await tx.update(userTable).set({ credits: sql`...` });
// 2. 记录账本
await tx.insert(creditLedger).values({ ... });
// 3. 更新订阅
await tx.update(userTable).set({ planKey: key });
// 4. 设置发放调度
await resetSubscriptionSchedule(...);
});
确保以上操作要么全部成功,要么全部失败,避免数据不一致。
4. 元数据验证
验证 Webhook 中的 metadata
包含必需字段:
const userId = metadata?.userId;
const key = metadata?.key;
const kind = metadata?.kind;
if (!userId || !key || !kind) {
return NextResponse.json({ error: "Missing metadata" }, { status: 400 });
}
5. Cron 端点保护
定时任务端点通过 CRON_SECRET
保护:
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
测试环境设置
使用 CREEM_SIMULATE
在本地开发时,设置 CREEM_SIMULATE="true"
跳过实际支付:
# .env.local
CREEM_SIMULATE="true"
行为:
createCheckoutSession
返回本地模拟端点- 点击购买后立即重定向到成功页面
- 不会调用 Creem API,不会产生实际费用
模拟 Webhook:
创建脚本手动触发 Webhook:
# test-webhook.sh
#!/bin/bash
WEBHOOK_URL="http://localhost:3000/api/payments/creem/webhook"
WEBHOOK_SECRET="whsec_test_secret"
PAYLOAD='{
"id": "evt_test_123",
"eventType": "checkout.completed",
"object": {
"id": "checkout_test_123",
"metadata": {
"userId": "user_test_123",
"key": "starter_monthly",
"kind": "subscription"
},
"order": {
"id": "order_test_123",
"amount": 2900,
"currency": "USD",
"subscription_id": "sub_test_123"
}
}
}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "creem-signature: $SIGNATURE" \
-d "$PAYLOAD"
常见问题
Q: Webhook 没有被触发怎么办?
排查步骤:
- 检查 Creem Dashboard 中的 Webhook 配置是否正确
- 查看 Webhook 日志,确认是否发送成功
- 检查
CREEM_WEBHOOK_SECRET
是否匹配 - 确认服务器可以从公网访问 (使用 ngrok 测试)
- 查看服务器日志,确认请求是否到达
Q: 签名验证失败?
常见原因:
CREEM_WEBHOOK_SECRET
配置错误- 请求体被中间件修改 (需要使用原始
rawBody
) - 字符编码问题
解决方法:
// 确保读取原始请求体
const rawBody = await req.text();
// 不要使用 req.json(),会丢失原始字节
Q: 如何测试年付分期发放?
方法 1: 修改时间间隔
临时修改 constants/billing.ts
:
grantSchedule: {
mode: "installments",
grantsPerCycle: 12,
intervalMonths: 0.001, // 约 43 秒 (仅用于测试!)
creditsPerGrant: 1000,
initialGrants: 1,
}
方法 2: 手动触发 Cron
curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
http://localhost:3000/api/cron/grant-subscription-credits
Q: 如何查看支付历史?
import { db } from "@/lib/db";
import { payment } from "@/lib/db/schema";
import { eq, desc } from "drizzle-orm";
// 查询用户的所有支付记录
const payments = await db
.select()
.from(payment)
.where(eq(payment.userId, userId))
.orderBy(desc(payment.createdAt));
Q: 如何处理退款?
Creem 的退款需要在 Dashboard 中手动操作:
- 在 Creem Dashboard 中找到对应订单
- 点击 "Refund" 并确认
- Creem 会发送
refund.created
Webhook (可选监听) - 手动扣除用户积分 (或实现自动退款逻辑)
手动扣除积分:
import { deductCredits } from "@/lib/credits";
await deductCredits(userId, 1000, "refund", paymentId);
Q: 如何取消订阅?
用户可以在仪表板中取消订阅,或管理员在后台操作:
import { db } from "@/lib/db";
import { subscription } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
await db
.update(subscription)
.set({ status: "cancelled" })
.where(eq(subscription.id, subscriptionId));
Q: 积分发放失败怎么办?
查看 creditLedger
表确认是否记录:
const ledger = await db
.select()
.from(creditLedger)
.where(eq(creditLedger.paymentId, paymentId));
if (ledger.length === 0) {
// 未记录,需要手动补发
await refundCredits(userId, amount, "subscription_cycle", paymentId);
}
最佳实践
1. 监控 Webhook 日志
定期检查 Webhook 处理日志,确保所有事件都被正确处理:
// 添加日志记录
console.log(`[Creem Webhook] Processed ${type} for user ${userId}`);
2. 设置告警
监控以下指标:
- Webhook 签名验证失败次数
- 支付记录重复次数
- 积分发放失败次数
3. 定期审计
每月审计支付记录和积分账本,确保数据一致:
// 检查积分账本总和是否匹配用户余额
const ledgerSum = await db
.select({ total: sql`SUM(delta)` })
.from(creditLedger)
.where(eq(creditLedger.userId, userId));
const userCredits = await getUserCredits(userId);
if (ledgerSum[0].total !== userCredits) {
// 数据不一致,需要修复
}
4. 提供清晰的支付反馈
在成功页面显示支付详情:
// app/[locale]/dashboard/page.tsx
const searchParams = useSearchParams();
const paymentSuccess = searchParams.get("payment") === "success";
if (paymentSuccess) {
return (
<div className="bg-green-50 p-4 rounded">
<h2>Payment Successful!</h2>
<p>Your credits have been added to your account.</p>
</div>
);
}
5. 处理边界情况
- 用户在支付过程中关闭页面
- Webhook 延迟到达
- 订阅在发放期间取消
相关文档
总结
Creem 支付集成为 Sistine Starter 提供了完整的订阅和一次性购买能力,通过:
- 灵活定价: 支持月付、年付和积分包
- 分期发放: 年付计划分期发放,降低滥用风险
- 安全可靠: 签名验证、幂等性保护、事务性操作
- 自动化: Webhook 自动处理支付,Cron 自动发放积分
遵循本文档的配置步骤和最佳实践,你可以快速为你的 SaaS 应用集成完整的支付功能。