Creem
用于 Creem 结账、订阅和访问控制的 Better Auth 插件。
Creem 是面向全球销售软件的开发者的记录商家(Merchant of Record)。支付、订阅、税务合规、拒付、收入分成、付款。全部由 Creem 处理。您只需专注于自己的产品。
@creem_io/better-auth 插件将 Creem 与您的 Better Auth 用户连接起来。它添加了与会话关联的结账、客户门户、订阅和交易历史记录端点。经过验证的 webhook 会将订阅状态保存在您的数据库中,并调用您自己的访问权限回调。
此插件由 Creem 团队维护。如需报告错误、问题或功能请求, 请访问 Creem GitHub 仓库。 完整参考文档请参阅 Creem 文档。
2.0 版本更改了结账和门户重定向、错误响应以及取消后的访问权限。如果您从 1.x 升级,请按照 2.0 迁移指南操作。
在 Creem Discord 或应用内实时聊天中获得支持
需要帮助?随时在 Discord 上联系我们的团队。
功能特性
- 为已登录用户或匿名访客创建结账
- 打开 Creem 客户门户,以管理发票、付款方式和订阅
- 取消和检索订阅,以及搜索交易
- 将经过验证的 webhook 中的客户和订阅信息持久化
- 根据本地订阅状态检查访问权限,包括取消后已付款的期限
- 使用
onGrantAccess和onRevokeAccess运行您自己的权限配置逻辑 - 为每位用户提供至多一次试用机会,适用于所有套餐
安装
安装插件
npm install @creem_io/better-auth此插件需要 better-auth 1.5.6 或更高版本,并包含 Creem TypeScript SDK。
如果您采用前后端分离的架构,请确保在项目的客户端和服务端部分都安装此插件。
添加环境变量
从 Creem 控制面板获取您的 API 密钥。您可以在配置 webhook 步骤中获取 webhook 密钥。
CREEM_API_KEY=your_api_key
CREEM_WEBHOOK_SECRET=your_webhook_secret测试模式和生产环境使用不同的 API 密钥和 webhook 密钥。testMode 默认为 false,因此每当您使用测试模式密钥时,都应将其设为 true。
将插件添加到 auth 配置中
import { betterAuth } from "better-auth";
import { creem } from "@creem_io/better-auth";
export const auth = betterAuth({
// ... your existing config
plugins: [
creem({
apiKey: process.env.CREEM_API_KEY!,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,
testMode: process.env.NODE_ENV !== "production",
defaultSuccessUrl: "/billing/success",
}),
],
});添加客户端插件
import { createAuthClient } from "better-auth/react";
import { creemClient } from "@creem_io/better-auth/client";
export const authClient = createAuthClient({
plugins: [creemClient()],
});标准客户端会推断端点的输入和响应,以及会话字段 creemCustomerId 和 hadTrial。1.x 中的 createCreemAuthClient 包装器已弃用。
迁移数据库
运行迁移或生成架构,以便在数据库中添加必要的表。
npx auth@latest migratemigrate 使用 Better Auth 内置的 Kysely 适配器应用架构。如果由 Prisma、Drizzle 或其他 ORM 管理架构,则 generate 会改为写入架构,然后由您使用 ORM 的迁移工具应用。
请参阅架构部分,了解插件添加的表和字段。如果您设置了 persistSubscriptions: false,则可跳过此步骤。
配置 webhook
在 Creem 控制面板中创建 webhook 端点,并将其指向:
https://your-domain.com/api/auth/creem/webhook/api/auth 是 auth 服务器的默认路径。将签名密钥复制到 CREEM_WEBHOOK_SECRET。只有设置了 webhookSecret,插件才会注册 webhook 路由。
在本地开发时,可使用 ngrok 等隧道工具公开您的服务器(ngrok http 3000),并改为注册隧道 URL。
使用方法
每个客户端方法都会返回 Better Auth 的 { data, error } 结果。请求失败时,data 为 null,而 error 包含 message 和 status。在服务器上通过 auth.api 发起的调用则会抛出 APIError。
结账
const { data, error } = await authClient.creem.createCheckout({
productId: "prod_abc123",
successUrl: "/billing/success",
});
if (!error && data.url) {
window.location.assign(data.url);
}结账会返回 URL,由您负责跳转。传入 redirect: true 可让 Better Auth 自动重定向。
| 选项 | 描述 |
|---|---|
productId | Creem 产品 ID。必填。 |
units | 单位数量。默认值:1。 |
successUrl | Creem 在付款后将客户发送到的位置。会覆盖 defaultSuccessUrl。 |
discountCode | 要使用的 Creem 折扣码。 |
customer | { email }。默认为已登录用户的电子邮件地址。 |
customFields | 最多三个结账时收集的文本或复选框字段。 |
metadata | 您自己的元数据。插件会将已登录用户的 ID 添加为 referenceId。 |
requestId | 幂等键,确保重试请求时不会创建第二笔结账。 |
redirect | 自动重定向到结账 URL。默认值:false。 |
webhook 通过 metadata.referenceId 将购买记录关联到用户。未通过会话创建的结账没有 referenceId,因此插件无法为任何用户保存其客户或订阅信息。
客户门户
门户允许客户管理订阅、查看发票和更新付款方式。
const { data, error } = await authClient.creem.createPortal();
if (!error && data.url) {
window.location.assign(data.url);
}与结账一样,门户的 redirect 默认值为 false。该端点使用已登录用户存储的 creemCustomerId。它也接受 customerId,但不会检查该 ID 是否属于当前用户,因此绝不要传入来自不可信输入的 ID。
检查访问权限
hasAccessGranted 会读取已登录用户存储的订阅,因此需要启用持久化。
const { data, error } = await authClient.creem.hasAccessGranted();
if (!error && data.hasAccessGranted) {
console.log(`Access until ${data.subscription?.periodEnd}`);
}订阅状态为 active、trialing 或 paid 时,用户可以访问。状态为 scheduled_cancel、past_due 或 unpaid 的订阅会保留访问权限,直到 periodEnd,因此客户可以使用已付费的剩余时间。状态为 canceled 的订阅不授予访问权限。
未有用户登录时,请求会返回状态码 401;禁用持久化时,请求会返回状态码 400。只要设置了 error,就应拒绝访问。
在服务器上,使用请求标头调用同一端点。用户退出登录时,auth.api.hasAccessGranted 会抛出 APIError,因此请先检查会话:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const requestHeaders = await headers();
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session) {
redirect("/sign-in");
}
const access = await auth.api.hasAccessGranted({
headers: requestHeaders,
});
if (!access.hasAccessGranted) {
redirect("/pricing");
}
return <h1>Dashboard</h1>;
}取消订阅
const { data, error } = await authClient.creem.cancelSubscription();
if (!error) {
console.log(data.message);
}启用持久化后,该端点会取消用户已存储且状态为 active、trialing、unpaid 或 past_due 的订阅。只有当用户没有此类订阅时,它才会使用您传入的 id。禁用持久化后,必须传入 id,且该端点不会检查它是否属于已登录用户。
检索订阅
const { data, error } = await authClient.creem.retrieveSubscription();
if (!error && typeof data.product !== "string") {
console.log(`${data.status}: ${data.product.name}`);
}启用持久化后,该端点会返回为用户存储的第一条订阅。禁用持久化后,请传入 id。product 和 customer 可以是 ID 或展开的对象,因此读取其属性前请先检查类型。
交易历史记录
const { data, error } = await authClient.creem.searchTransactions({
productId: "prod_abc123",
pageNumber: 1,
pageSize: 20,
});
for (const transaction of data?.items ?? []) {
console.log(transaction.type, transaction.amount, transaction.currency);
}响应包含 items 和 pagination。金额以分为单位。该端点默认使用已登录用户的 creemCustomerId 进行搜索,您也可以按 productId 或 orderId 筛选。
Webhook
webhook 路由会根据 webhookSecret 验证 creem-signature 标头,并拒绝不匹配的请求。启用持久化后,它会先更新用户的 creemCustomerId 和 creem_subscription 表,然后再调用您的回调。
插件会等待每个回调执行完毕后才响应。Creem 可能会多次发送同一事件,因此请确保回调可以安全地重复运行。
授予和撤销访问权限
onGrantAccess 和 onRevokeAccess 可让您在同一处配置和移除自有系统中的访问权限:
creem({
apiKey: process.env.CREEM_API_KEY!,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,
onGrantAccess: async ({ reason, product, metadata }) => {
const userId = metadata?.referenceId;
if (typeof userId !== "string") return;
await setPlanAccess({ userId, productId: product.id, enabled: true, reason });
},
onRevokeAccess: async ({ reason, product, metadata }) => {
const userId = metadata?.referenceId;
if (typeof userId !== "string") return;
await setPlanAccess({ userId, productId: product.id, enabled: false, reason });
},
});| 回调 | 原因 | Webhook 事件 |
|---|---|---|
onGrantAccess | subscription_active | subscription.active |
onGrantAccess | subscription_trialing | subscription.trialing |
onGrantAccess | subscription_paid | subscription.paid |
onRevokeAccess | subscription_paused | subscription.paused |
onRevokeAccess | subscription_expired | subscription.expired |
onRevokeAccess | subscription_canceled | subscription.canceled |
其他事件不会调用这两个回调:
subscription.scheduled_cancel会保留访问权限,直到当前周期结束。插件会将状态存储为scheduled_cancel,并将cancelAtPeriodEnd: true,而hasAccessGranted会在periodEnd过后拒绝访问。如果您在插件表以外跟踪访问权限,请存储周期结束时间并自行执行相关规则。- 退款、争议和付款失败不会撤销访问权限。请使用下方的事件回调处理这些情况。
- 一次性购买不会创建订阅事件。请在
onCheckoutCompleted中授予相应访问权限。
事件回调
每个事件也都有自己的回调。回调会接收扁平化的事件数据(可用于解构),并将 Better Auth 端点上下文作为第二个参数。
| 回调 | 事件 |
|---|---|
onCheckoutCompleted | checkout.completed |
onRefundCreated | refund.created |
onDisputeCreated | dispute.created |
onSubscriptionActive | subscription.active |
onSubscriptionTrialing | subscription.trialing |
onSubscriptionPaid | subscription.paid |
onSubscriptionCanceled | subscription.canceled |
onSubscriptionScheduledCancel | subscription.scheduled_cancel |
onSubscriptionExpired | subscription.expired |
onSubscriptionUnpaid | subscription.unpaid |
onSubscriptionPastDue | subscription.past_due |
onSubscriptionPaused | subscription.paused |
onSubscriptionUpdate | subscription.update |
creem({
apiKey: process.env.CREEM_API_KEY!,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,
onCheckoutCompleted: async ({ product, order, metadata }, ctx) => {
// One-time purchases: grant access here
},
onRefundCreated: async ({ refund_amount, order }, ctx) => {
// Decide whether this refund removes access
},
});自定义 webhook 路由
如果您在自己的路由上接收 webhook,而不是使用插件的路由,请使用 validateWebhookSignature 验证原始请求体。它会返回一个 promise,因此请使用 await 等待结果:
import { validateWebhookSignature } from "@creem_io/better-auth/server";
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get("creem-signature");
const valid = await validateWebhookSignature(
payload,
signature,
process.env.CREEM_WEBHOOK_SECRET!,
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(payload);
// Handle the verified event
return new Response(null, { status: 204 });
}不要让 Creem 同时将相同事件发送到这两个路由,否则每个事件都会被处理两次。
服务器端辅助函数
@creem_io/better-auth/server 导出了 createCheckout、createPortal、cancelSubscription、retrieveSubscription、searchTransactions 和 createCreemClient,供不通过 Better Auth 端点的代码使用。它们会使用您的 API 密钥调用 Creem,且不会读取会话。请验证请求,并在传入客户或订阅 ID 之前,检查每个 ID 是否属于该用户。
"use server";
import { createCheckout } from "@creem_io/better-auth/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function startCheckout(productId: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
const { url } = await createCheckout(
{
apiKey: process.env.CREEM_API_KEY!,
testMode: process.env.NODE_ENV !== "production",
},
{
productId,
customer: { email: session.user.email },
successUrl: "/billing/success",
metadata: { referenceId: session.user.id },
skipTrial: session.user.hadTrial === true,
},
);
redirect(url);
}请自行设置 metadata.referenceId,以便 webhook 将购买记录关联到用户。
在服务器端检查访问权限时,请使用 auth.api.hasAccessGranted。导出的 checkSubscriptionAccess 和 getActiveSubscriptions 辅助函数需要原始查询构建器,而大多数 Better Auth 适配器并不提供这种构建器;此外,它们会忽略预定取消和付款宽限期。
所有辅助函数请参阅 Creem 服务器 API 文档。
防止滥用试用
启用持久化后,当订阅进入试用状态时,插件会在用户上设置 hadTrial。当该用户登录后开始另一笔结账时,插件会告知 Creem 跳过任何套餐的试用期。只有在服务器辅助函数中传入 skipTrial 时,结账才会跳过试用期,如上例所示。
架构
启用 persistSubscriptions(默认值)后,插件会向您的数据库添加以下内容。设置 persistSubscriptions: false 时,不会添加表或字段,且无法使用 hasAccessGranted 和试用防护功能。
用户
表名:user
订阅
表名:creem_subscription
自定义 Schema
要重命名表或列,请传入 schema 选项。字段映射为列名:
creem({
// ... other options
schema: {
creem_subscription: {
modelName: "billing_subscription",
fields: { referenceId: "user_id" },
},
user: {
fields: { creemCustomerId: "creem_customer_id" },
},
},
});选项
服务端
| 选项 | 类型 | 描述 |
|---|---|---|
apiKey | string | 你的 Creem API 密钥。必填。 |
webhookSecret | string | Webhook 签名密钥。仅在设置此项时才会注册 Webhook 路由。 |
testMode | boolean | 使用 Creem 测试 API。默认值:false。 |
defaultSuccessUrl | string | 结账的默认 successUrl。接受相对或绝对 URL。 |
persistSubscriptions | boolean | 在数据库中存储客户和订阅。默认值:true。 |
schema | object | 重命名插件的表和列。参见自定义 Schema。 |
onGrantAccess | function | 当订阅事件授予访问权限时调用。参见授予和撤销访问权限。 |
onRevokeAccess | function | 当订阅事件撤销访问权限时调用。 |
各事件回调列于事件回调中。
客户端
| 选项 | 类型 | 描述 |
|---|---|---|
persistSubscriptions | boolean | 当服务端禁用持久化时设为 false,这样会话类型就不会包含 creemCustomerId 和 hadTrial。默认值:true。 |