Creem

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

Creem 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.

This plugin is maintained by the Creem team. For bugs, issues or feature requests, please visit the Creem GitHub repo. The full reference lives in the Creem documentation.

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.

Get support on Creem Discord or in our in-app live-chat

Need help? Reach out to our team anytime on Discord.

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

Install the plugin

npm install @creem_io/better-auth

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

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

Add environment variables

Get your API key from the Creem dashboard. You get the webhook secret in the webhook step.

.env
CREEM_API_KEY=your_api_key
CREEM_WEBHOOK_SECRET=your_webhook_secret

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.

Add the plugin to your auth config

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

Add the client plugin

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.

Migrate the database

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

npx auth@latest migrate

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 section for the tables and fields the plugin adds. Skip this step if you set persistSubscriptions: false.

Set up webhooks

In the Creem dashboard, create a webhook endpoint that points to:

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 (ngrok http 3000) and register the tunnel URL instead.

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

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.

OptionDescription
productIdThe Creem product ID. Required.
unitsNumber of units. Default: 1.
successUrlWhere Creem sends the customer after payment. Overrides defaultSuccessUrl.
discountCodeA Creem discount code to apply.
customer{ email }. Defaults to the signed-in user's email.
customFieldsUp to three text or checkbox fields to collect during checkout.
metadataYour own metadata. The plugin adds the signed-in user's ID as referenceId.
requestIdIdempotency key, so a retried request doesn't create a second checkout.
redirectRedirect 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

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

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

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

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:

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

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

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

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

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

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

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 });
    },
});
CallbackReasonWebhook event
onGrantAccesssubscription_activesubscription.active
onGrantAccesssubscription_trialingsubscription.trialing
onGrantAccesssubscription_paidsubscription.paid
onRevokeAccesssubscription_pausedsubscription.paused
onRevokeAccesssubscription_expiredsubscription.expired
onRevokeAccesssubscription_canceledsubscription.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

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.

CallbackEvent
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
    },
});

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:

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

@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.

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.

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.

See the Creem server API documentation for every helper.

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

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

Table Name: 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

Subscription

Table Name: 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

Customizing the Schema

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

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

Options

Server

OptionTypeDescription
apiKeystringYour Creem API key. Required.
webhookSecretstringWebhook signing secret. The webhook route is only registered when this is set.
testModebooleanUse the Creem test API. Default: false.
defaultSuccessUrlstringDefault successUrl for checkouts. Accepts relative or absolute URLs.
persistSubscriptionsbooleanStore customers and subscriptions in your database. Default: true.
schemaobjectRename the plugin's tables and columns. See Customizing the Schema.
onGrantAccessfunctionCalled when a subscription event grants access. See Grant and revoke access.
onRevokeAccessfunctionCalled when a subscription event revokes access.

The per-event callbacks are listed under Event callbacks.

Client

OptionTypeDescription
persistSubscriptionsbooleanSet to false when the server disables persistence, so session types don't include creemCustomerId and hadTrial. Default: true.

Resources