> You are reading Better Auth documentation for `v1.8 (Beta)`. This is not the current stable release. APIs may differ from the latest stable version.

# Creem (/docs/beta/plugins/creem)

Better Auth plugin for Creem checkouts, subscriptions, and access control.



[Creem](https://creem.io) is a Merchant of Record for developers who sell software globally. Payments, subscriptions, tax compliance, chargebacks, revenue splits, payouts. All handled. You focus on your product.

The `@creem_io/better-auth` plugin connects Creem to your Better Auth users. It adds session-aware endpoints for checkout, the customer portal, subscriptions, and transaction history. Verified webhooks keep subscription state in your database and call your own access callbacks.

<Callout>
  This plugin is maintained by the Creem team. For bugs, issues or feature requests,
  please visit the [Creem GitHub repo](https://github.com/armitage-labs/creem).
  The full reference lives in the [Creem documentation](https://docs.creem.io/code/sdks/better-auth).
</Callout>

<Callout type="warn">
  Version 2.0 changed checkout and portal redirects, error responses, and cancellation access. If you are upgrading from 1.x, follow the [2.0 migration guide](https://docs.creem.io/code/sdks/better-auth/migration#upgrading-from-1-x).
</Callout>

<Card href="https://discord.gg/q3GKZs92Av" title="Get support on Creem Discord or in our in-app live-chat">
  Need help? Reach out to our team anytime on Discord.
</Card>

## Features [#features]

* Create checkouts for signed-in users or anonymous visitors
* Open the Creem customer portal for invoices, payment methods, and subscriptions
* Cancel and retrieve subscriptions, and search transactions
* Persist customers and subscriptions from verified webhooks
* Check access from local subscription state, including paid-up periods after cancellation
* Run your own provisioning logic with `onGrantAccess` and `onRevokeAccess`
* Offer each user at most one trial across all plans

## Installation [#installation]

<Steps>
  <Step>
    ### Install the plugin [#install-the-plugin]

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install @creem_io/better-auth
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @creem_io/better-auth
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @creem_io/better-auth
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @creem_io/better-auth
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    The plugin requires `better-auth` 1.5.6 or later and includes the Creem TypeScript SDK.

    <Callout>
      If you're using a separate client and server setup, make sure to install the plugin in both parts of your project.
    </Callout>
  </Step>

  <Step>
    ### Add environment variables [#add-environment-variables]

    Get your API key from the [Creem dashboard](https://creem.io/dashboard/developers). You get the webhook secret in the [webhook step](#set-up-webhooks).

    ```txt title=".env"
    CREEM_API_KEY=your_api_key
    CREEM_WEBHOOK_SECRET=your_webhook_secret
    ```

    <Callout type="warn">
      Test mode and production use different API keys and webhook secrets. `testMode` defaults to `false`, so set it to `true` whenever you use a test-mode key.
    </Callout>
  </Step>

  <Step>
    ### Add the plugin to your auth config [#add-the-plugin-to-your-auth-config]

    ```ts title="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",
            }),
        ],
    });
    ```
  </Step>

  <Step>
    ### Add the client plugin [#add-the-client-plugin]

    ```ts title="auth-client.ts"
    import { createAuthClient } from "better-auth/react";
    import { creemClient } from "@creem_io/better-auth/client";

    export const authClient = createAuthClient({
        plugins: [creemClient()],
    });
    ```

    The standard client infers the endpoint inputs and responses, plus the `creemCustomerId` and `hadTrial` session fields. The `createCreemAuthClient` wrapper from 1.x is deprecated.
  </Step>

  <Step>
    ### Migrate the database [#migrate-the-database]

    Run the migration or generate the schema to add the necessary tables to the database.

    <Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
      <Tab value="migrate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth@latest migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth@latest migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth@latest migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth@latest migrate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>

      <Tab value="generate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth@latest generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth@latest generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth@latest generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth@latest generate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>
    </Tabs>

    `migrate` applies the schema with Better Auth's built-in Kysely adapter. If Prisma, Drizzle, or another ORM manages your schema, `generate` writes the schema instead, and you apply it with your ORM's migration tooling.

    See the [Schema](#schema) section for the tables and fields the plugin adds. Skip this step if you set `persistSubscriptions: false`.
  </Step>

  <Step>
    ### Set up webhooks [#set-up-webhooks]

    In the [Creem dashboard](https://creem.io/dashboard/developers/webhooks), create a webhook endpoint that points to:

    ```txt
    https://your-domain.com/api/auth/creem/webhook
    ```

    `/api/auth` is the default path for the auth server. Copy the signing secret into `CREEM_WEBHOOK_SECRET`. The plugin only registers the webhook route when `webhookSecret` is set.

    For local development, expose your server with a tunnel such as [ngrok](https://ngrok.com) (`ngrok http 3000`) and register the tunnel URL instead.
  </Step>
</Steps>

## Usage [#usage]

Every client method returns Better Auth's `{ data, error }` result. When a request fails, `data` is `null` and `error` contains `message` and `status`. Calls through `auth.api` on the server throw an `APIError` instead.

### Checkout [#checkout]

```ts
const { data, error } = await authClient.creem.createCheckout({
    productId: "prod_abc123",
    successUrl: "/billing/success",
});

if (!error && data.url) {
    window.location.assign(data.url);
}
```

Checkout returns the URL and leaves navigation to you. Pass `redirect: true` to let Better Auth redirect automatically.

| Option         | Description                                                                  |
| -------------- | ---------------------------------------------------------------------------- |
| `productId`    | The Creem product ID. &#x2A;*Required.**                                     |
| `units`        | Number of units. Default: `1`.                                               |
| `successUrl`   | Where Creem sends the customer after payment. Overrides `defaultSuccessUrl`. |
| `discountCode` | A Creem discount code to apply.                                              |
| `customer`     | `{ email }`. Defaults to the signed-in user's email.                         |
| `customFields` | Up to three text or checkbox fields to collect during checkout.              |
| `metadata`     | Your own metadata. The plugin adds the signed-in user's ID as `referenceId`. |
| `requestId`    | Idempotency key, so a retried request doesn't create a second checkout.      |
| `redirect`     | Redirect to the checkout URL automatically. Default: `false`.                |

The webhooks link a purchase to a user through `metadata.referenceId`. A checkout created without a session has no `referenceId`, so the plugin can't store its customer or subscription for any user.

### Customer portal [#customer-portal]

The portal lets customers manage subscriptions, view invoices, and update payment methods.

```ts
const { data, error } = await authClient.creem.createPortal();

if (!error && data.url) {
    window.location.assign(data.url);
}
```

Like checkout, the portal defaults to `redirect: false`. The endpoint uses the signed-in user's stored `creemCustomerId`. It also accepts a `customerId`, but doesn't check that the ID belongs to the current user, so never pass one from untrusted input.

### Check access [#check-access]

`hasAccessGranted` reads the signed-in user's stored subscriptions, so it requires persistence.

```ts
const { data, error } = await authClient.creem.hasAccessGranted();

if (!error && data.hasAccessGranted) {
    console.log(`Access until ${data.subscription?.periodEnd}`);
}
```

A subscription grants access while its status is `active`, `trialing`, or `paid`. A subscription that is `scheduled_cancel`, `past_due`, or `unpaid` keeps access until its `periodEnd`, so customers keep the time they paid for. A `canceled` subscription doesn't grant access.

The request fails with status `401` when no user is signed in, and `400` when persistence is disabled. Deny access whenever `error` is set.

On the server, call the same endpoint with the request headers. `auth.api.hasAccessGranted` throws an `APIError` when the user is signed out, so check the session first:

```tsx title="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>;
}
```

### Cancel a subscription [#cancel-a-subscription]

```ts
const { data, error } = await authClient.creem.cancelSubscription();

if (!error) {
    console.log(data.message);
}
```

With persistence enabled, the endpoint cancels the user's stored subscription that is `active`, `trialing`, `unpaid`, or `past_due`. It only uses an `id` you pass when the user has no such subscription. With persistence disabled, `id` is required, and the endpoint doesn't check that it belongs to the signed-in user.

### Retrieve a subscription [#retrieve-a-subscription]

```ts
const { data, error } = await authClient.creem.retrieveSubscription();

if (!error && typeof data.product !== "string") {
    console.log(`${data.status}: ${data.product.name}`);
}
```

With persistence enabled, the endpoint returns the first subscription stored for the user. With persistence disabled, pass an `id`. `product` and `customer` can be IDs or expanded objects, so check their type before reading properties.

### Transaction history [#transaction-history]

```ts
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);
}
```

The response contains `items` and `pagination`. Amounts are in cents. The endpoint searches the signed-in user's `creemCustomerId` by default, and you can filter by `productId` or `orderId`.

## Webhooks [#webhooks]

The webhook route verifies the `creem-signature` header against `webhookSecret` and rejects requests that don't match. With persistence enabled, it then updates the user's `creemCustomerId` and the `creem_subscription` table before it calls your callbacks.

The plugin awaits every callback before it responds. Creem can deliver the same event more than once, so write callbacks that are safe to run twice.

### Grant and revoke access [#grant-and-revoke-access]

`onGrantAccess` and `onRevokeAccess` give you one place to provision and remove access in your own system:

```ts title="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 });
    },
});
```

| Callback         | Reason                  | Webhook event           |
| ---------------- | ----------------------- | ----------------------- |
| `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` |

No other event calls these two callbacks:

* `subscription.scheduled_cancel` keeps access until the current period ends. The plugin stores the status `scheduled_cancel` with `cancelAtPeriodEnd: true`, and `hasAccessGranted` denies access once `periodEnd` passes. If you track access outside the plugin's tables, store the period end and enforce it yourself.
* Refunds, disputes, and failed payments don't revoke access. Handle them with the event callbacks below.
* One-time purchases don't create subscription events. Grant their access in `onCheckoutCompleted`.

### Event callbacks [#event-callbacks]

Each event also has its own callback. It receives the event data, flattened for destructuring, and the Better Auth endpoint context as the second argument.

| Callback                        | Event                           |
| ------------------------------- | ------------------------------- |
| `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`           |

```ts title="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
    },
});
```

### Custom webhook route [#custom-webhook-route]

If you receive webhooks on your own route instead of the plugin's, verify the raw body with `validateWebhookSignature`. It returns a promise, so await it:

```ts title="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 });
}
```

Don't point Creem at both routes for the same events, or each event is processed twice.

## Server helpers [#server-helpers]

`@creem_io/better-auth/server` exports `createCheckout`, `createPortal`, `cancelSubscription`, `retrieveSubscription`, `searchTransactions`, and `createCreemClient` for code that doesn't go through a Better Auth endpoint. They call Creem with your API key and don't read the session. Authenticate the request and check that every customer or subscription ID belongs to the user before you pass it in.

```ts title="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);
}
```

Set `metadata.referenceId` yourself so the webhooks can link the purchase to the user.

<Callout type="warn">
  Use `auth.api.hasAccessGranted` for server-side access checks. The exported `checkSubscriptionAccess` and `getActiveSubscriptions` helpers expect a raw query builder that most Better Auth adapters don't provide, and they ignore scheduled cancellations and payment grace periods.
</Callout>

See the [Creem server API documentation](https://docs.creem.io/code/sdks/better-auth/server) for every helper.

## Trial abuse prevention [#trial-abuse-prevention]

With persistence enabled, the plugin sets `hadTrial` on the user when a subscription enters a trial. When that user starts another checkout while signed in, the plugin tells Creem to skip the trial on any plan. Checkouts from the server helpers only skip the trial when you pass `skipTrial`, as in the example above.

## Schema [#schema]

With `persistSubscriptions` enabled (the default), the plugin adds the following to your database. With `persistSubscriptions: false`, it adds no tables or fields, and `hasAccessGranted` and trial prevention are unavailable.

### User [#user]

Table Name: `user`



<DatabaseTable name="user" fields="creemUserTableFields" />

### Subscription [#subscription]

Table Name: `creem_subscription`



<DatabaseTable name="creem_subscription" fields="creemSubscriptionTableFields" />

### Customizing the Schema [#customizing-the-schema]

To rename tables or columns, pass a `schema` option. Field mappings are column names:

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

## Options [#options]

### Server [#server]

| Option                 | Type       | Description                                                                                              |
| ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------- |
| `apiKey`               | `string`   | Your Creem API key. &#x2A;*Required.**                                                                   |
| `webhookSecret`        | `string`   | Webhook signing secret. The webhook route is only registered when this is set.                           |
| `testMode`             | `boolean`  | Use the Creem test API. Default: `false`.                                                                |
| `defaultSuccessUrl`    | `string`   | Default `successUrl` for checkouts. Accepts relative or absolute URLs.                                   |
| `persistSubscriptions` | `boolean`  | Store customers and subscriptions in your database. Default: `true`.                                     |
| `schema`               | `object`   | Rename the plugin's tables and columns. See [Customizing the Schema](#customizing-the-schema).           |
| `onGrantAccess`        | `function` | Called when a subscription event grants access. See [Grant and revoke access](#grant-and-revoke-access). |
| `onRevokeAccess`       | `function` | Called when a subscription event revokes access.                                                         |

The per-event callbacks are listed under [Event callbacks](#event-callbacks).

### Client [#client]

| Option                 | Type      | Description                                                                                                                            |
| ---------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `persistSubscriptions` | `boolean` | Set to `false` when the server disables persistence, so session types don't include `creemCustomerId` and `hadTrial`. Default: `true`. |

## Resources [#resources]

* [Creem Better Auth documentation](https://docs.creem.io/code/sdks/better-auth)
* [2.0 migration guide](https://docs.creem.io/code/sdks/better-auth/migration)
* [Plugin source](https://github.com/armitage-labs/creem/tree/main/packages/integrations/better-auth) and [issue tracker](https://github.com/armitage-labs/creem/issues)
* [Creem dashboard](https://creem.io/dashboard), with in-app live chat
* [support@creem.io](mailto:support@creem.io)
* [Creem Discord community](https://discord.gg/q3GKZs92Av)

