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 密钥。

.env
CREEM_API_KEY=your_api_key
CREEM_WEBHOOK_SECRET=your_webhook_secret

测试模式和生产环境使用不同的 API 密钥和 webhook 密钥。testMode 默认为 false,因此每当您使用测试模式密钥时,都应将其设为 true。

将插件添加到 auth 配置中

auth.ts
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",
        }),
    ],
});

添加客户端插件

auth-client.ts
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 migrate

migrate 使用 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 自动重定向。

选项描述
productIdCreem 产品 ID。必填。
units单位数量。默认值:1。
successUrlCreem 在付款后将客户发送到的位置。会覆盖 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,因此请先检查会话:

app/dashboard/page.tsx
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 可让您在同一处配置和移除自有系统中的访问权限:

auth.ts
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 事件
onGrantAccesssubscription_activesubscription.active
onGrantAccesssubscription_trialingsubscription.trialing
onGrantAccesssubscription_paidsubscription.paid
onRevokeAccesssubscription_pausedsubscription.paused
onRevokeAccesssubscription_expiredsubscription.expired
onRevokeAccesssubscription_canceledsubscription.canceled

其他事件不会调用这两个回调:

  • subscription.scheduled_cancel 会保留访问权限,直到当前周期结束。插件会将状态存储为 scheduled_cancel,并将 cancelAtPeriodEnd: true,而 hasAccessGranted 会在 periodEnd 过后拒绝访问。如果您在插件表以外跟踪访问权限,请存储周期结束时间并自行执行相关规则。
  • 退款、争议和付款失败不会撤销访问权限。请使用下方的事件回调处理这些情况。
  • 一次性购买不会创建订阅事件。请在 onCheckoutCompleted 中授予相应访问权限。

事件回调

每个事件也都有自己的回调。回调会接收扁平化的事件数据(可用于解构),并将 Better Auth 端点上下文作为第二个参数。

回调事件
onCheckoutCompletedcheckout.completed
onRefundCreatedrefund.created
onDisputeCreateddispute.created
onSubscriptionActivesubscription.active
onSubscriptionTrialingsubscription.trialing
onSubscriptionPaidsubscription.paid
onSubscriptionCanceledsubscription.canceled
onSubscriptionScheduledCancelsubscription.scheduled_cancel
onSubscriptionExpiredsubscription.expired
onSubscriptionUnpaidsubscription.unpaid
onSubscriptionPastDuesubscription.past_due
onSubscriptionPausedsubscription.paused
onSubscriptionUpdatesubscription.update
auth.ts
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 等待结果:

app/api/webhooks/creem/route.ts
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 是否属于该用户。

actions.ts
"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

Table
Field
Type
Attributes
Description
creemCustomerId ?
string
-
The Creem customer ID
hadTrial ?
boolean
-
Whether the user has already had a trial. Defaults to false

订阅

表名:creem_subscription

Table
Field
Type
Attributes
Description
id
string
PK
Unique identifier for each subscription
productId
string
-
The Creem product ID
referenceId
string
-
The ID of the user who owns the subscription
creemCustomerId ?
string
-
The Creem customer ID
creemSubscriptionId ?
string
-
The Creem subscription ID
creemOrderId ?
string
-
The Creem order ID
status
string
-
The subscription status. Defaults to pending
periodStart ?
Date
-
Start of the current billing period
periodEnd ?
Date
-
End of the current billing period
cancelAtPeriodEnd ?
boolean
-
Whether the subscription cancels at the end of the current period

自定义 Schema

要重命名表或列,请传入 schema 选项。字段映射为列名:

auth.ts
creem({
    // ... other options
    schema: {
        creem_subscription: {
            modelName: "billing_subscription",
            fields: { referenceId: "user_id" },
        },
        user: {
            fields: { creemCustomerId: "creem_customer_id" },
        },
    },
});

选项

服务端

选项类型描述
apiKeystring你的 Creem API 密钥。必填。
webhookSecretstringWebhook 签名密钥。仅在设置此项时才会注册 Webhook 路由。
testModeboolean使用 Creem 测试 API。默认值:false。
defaultSuccessUrlstring结账的默认 successUrl。接受相对或绝对 URL。
persistSubscriptionsboolean在数据库中存储客户和订阅。默认值:true。
schemaobject重命名插件的表和列。参见自定义 Schema。
onGrantAccessfunction当订阅事件授予访问权限时调用。参见授予和撤销访问权限。
onRevokeAccessfunction当订阅事件撤销访问权限时调用。

各事件回调列于事件回调中。

客户端

选项类型描述
persistSubscriptionsboolean当服务端禁用持久化时设为 false,这样会话类型就不会包含 creemCustomerId 和 hadTrial。默认值:true。

资源