Creem

Creem is a financial OS that enables teams and individuals selling software globally to split revenue and collaborate on financial workflows without any tax compliance headaches. This plugin integrates Creem with Better Auth, bringing payment processing and subscription management directly into your authentication layer.

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

This plugin is maintained by the Creem team.
Need help? Reach out to our team anytime on Discord.

Features

  • Database Persistence - Automatically synchronize customer and subscription data with your database
  • Access Management - Automatically grant or revoke access to users based on their subscription status
  • Customer Synchronization - Synchronize Creem customer IDs with your database users
  • Checkout Integration - Create checkout sessions either automatically for authenticated users or manually for unauthenticated users
  • Customer Portal - Enable users to manage subscriptions, view invoices, and update payment methods
  • Subscription Management - Cancel, retrieve, and track subscription details for authenticated users or manually for unauthenticated users
  • Transaction History - Search and filter transaction records for authenticated users or manually for unauthenticated users
  • Webhook Processing - Handle Creem webhooks securely with signature verification
  • Flexible Architecture - Use Better Auth endpoints or direct server-side functions
  • Trial Abuse Prevention - Users can only get one trial per account across all plans (when using database mode)

Installation

Install the plugin

npm install @creem_io/better-auth

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

Get your API Key

Get your Creem API Key from the Creem dashboard, under the 'Developers' menu and add it to your environment variables:

# .env
CREEM_API_KEY=your_api_key_here

Test Mode and Production have different API keys. Make sure you're using the correct one for your environment.

Configuration

Server Configuration

Configure Better Auth with the Creem plugin:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { creem } from "@creem_io/better-auth";

export const auth = betterAuth({
  database: {
    // your database config
  },
  plugins: [
    creem({
      apiKey: process.env.CREEM_API_KEY!,
      webhookSecret: process.env.CREEM_WEBHOOK_SECRET, // Optional, webhooks are automatically enabled when passing a signing secret
      testMode: true, // Optional, use test mode for development
      defaultSuccessUrl: "/success", // Optional, redirect to this URL after successful payments
      persistSubscriptions: true, // Optional, enable database persistence (default: true)
    }),
  ],
});

Client Configuration

Standard Setup

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

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
  plugins: [creemClient()],
});

Enhanced TypeScript Support (React-Only)

For improved TypeScript IntelliSense and autocomplete:

// lib/auth-client.ts
import { createCreemAuthClient } from "@creem_io/better-auth/create-creem-auth-client";
import { creemClient } from "@creem_io/better-auth/client";

export const authClient = createCreemAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
  plugins: [creemClient()],
});

The createCreemAuthClient wrapper provides enhanced TypeScript support and cleaner parameter types. It's optimized for use with the Creem plugin.

Database Migration

If you're using database persistence (persistSubscriptions: true), generate and run the database schema:

npx @better-auth/cli generate
npx @better-auth/cli migrate

Depending on your database adapter, additional setup steps may be required. Refer to the Better Auth adapter documentation for details.

Webhook Setup

Create Webhook Endpoint

In your Creem dashboard, create a webhook endpoint pointing to your local or production server pointing to:

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

(/api/auth is the default Better Auth server path)

Check step 3 if local development.

Configure Webhook Secret

Copy the webhook signing secret from Creem and add it to your environment:

CREEM_WEBHOOK_SECRET=your_webhook_secret_here

Update your server configuration:

creem({
  apiKey: process.env.CREEM_API_KEY!,
  webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
  testMode: true,
})

Local Development (Optional)

For local testing, use a tool like ngrok to expose your local server:

ngrok http 3000

Add the ngrok URL to your Creem webhook settings.

Database Schema

When persistSubscriptions: true, the plugin creates the following schema:

Subscription Table

FieldTypeDescription
idstringPrimary key
productIdstringCreem product ID
referenceIdstringYour user/organization ID
creemCustomerIdstringCreem customer ID
creemSubscriptionIdstringCreem subscription ID
creemOrderIdstringCreem order ID
statusstringSubscription status
periodStartdateBilling period start date
periodEnddateBilling period end date
cancelAtPeriodEndbooleanWhether subscription will cancel

User Table Extension

FieldTypeDescription
creemCustomerIdstringLinks user to Creem customer

Usage

Checkout

Create a checkout session to process payments:

"use client";

import { authClient } from "@/lib/auth-client";

export function SubscribeButton({ productId }: { productId: string }) {
  const handleCheckout = async () => {
    const { data, error } = await authClient.creem.createCheckout({
      productId,
      successUrl: "/dashboard",
      discountCode: "LAUNCH50", // Optional
      metadata: { planType: "pro" }, // Optional
    });

    if (data?.url) {
      window.location.href = data.url;
    }
  };

  return <button onClick={handleCheckout}>Subscribe Now</button>;
}

Checkout Options

  • productId (required) - The Creem product ID
  • units - Number of units (default: 1)
  • successUrl - Redirect URL after successful payment
  • discountCode - Discount code to apply
  • customer - Customer information (auto-populated from session)
  • metadata - Additional metadata (auto-includes user ID as referenceId)
  • requestId - Idempotency key for duplicate prevention

Customer Portal

Redirect users to manage their subscriptions:

const handlePortal = async () => {
  // No need to redirect, the portal will be opened in the same tab
  const { data, error } = await authClient.creem.createPortal();
};

Subscription Management

Cancel Subscription

When database persistence is enabled, the subscription is found automatically for the authenticated user:

const handleCancel = async () => {
  const { data, error } = await authClient.creem.cancelSubscription();

  if (data?.success) {
    console.log(data.message);
  }
};

If database persistence is disabled, provide the subscription ID:

const { data } = await authClient.creem.cancelSubscription({
  id: "sub_123456",
});

Retrieve Subscription

Get subscription details for the authenticated user:

const getSubscription = async () => {
  const { data } = await authClient.creem.retrieveSubscription();

  if (data) {
    console.log(`Status: ${data.status}`);
    console.log(`Product: ${data.product.name}`);
    console.log(`Price: ${data.product.price} ${data.product.currency}`);
  }
};

Check Access

Verify if the user has an active subscription (requires database mode):

const { data } = await authClient.creem.hasAccessGranted();

if (data?.hasAccess) {
  // User has active subscription access
  console.log(`Expires: ${data.expiresAt}`);
}

This function checks if the user has access for the current billing period. For example, if a user purchases a yearly plan and cancels after one month, they still have access until the year ends.

Transaction History

Search transaction records for the authenticated user:

const { data } = await authClient.creem.searchTransactions({
  productId: "prod_xyz789", // Optional filter
  pageNumber: 1,
  pageSize: 50,
});

if (data?.transactions) {
  data.transactions.forEach((tx) => {
    console.log(`${tx.type}: ${tx.amount} ${tx.currency}`);
  });
}

Webhook Handling

The plugin provides flexible webhook handling with both granular event handlers and high-level access control handlers.

These handlers provide the simplest and most powerful way to manage user access. They automatically handle all payment scenarios and subscription states, so you don't need to manage individual subscription events.

Database Persistence Required: These handlers require the database persistence option to be enabled in your plugin configuration.

Handler NameData Parameter TypeDescription
onGrantAccessGrantAccessContextCalled when a user should be granted access. Handles successful payments, active subscriptions, and trial periods. Use this to enable features, add user to groups, or update permissions.
onRevokeAccessRevokeAccessContextCalled when a user's access should be revoked. Handles cancellations, expirations, refunds, and failed payments. Use this to disable features, remove from groups, or revoke permissions.

Why use these handlers?

  • Single source of truth for access control
  • Handles all payment scenarios automatically
  • Reduces code complexity and potential bugs
  • Works for both one-time purchases and subscriptions
  • Takes current billing period and access expiration dates into consideration
// lib/auth.ts
import { betterAuth } from "better-auth";
import { creem } from "@creem_io/better-auth";

export const auth = betterAuth({
  database: {
    // your database config
  },
  plugins:[ 
    creem({
      apiKey: process.env.CREEM_API_KEY!,
      webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,

      onGrantAccess: async ({ reason, product, customer, metadata }) => {
        const userId = metadata?.referenceId as string;

        // Update your database specific logic
        await db.user.update({
          where: { id: userId },
          data: { 
            hasAccess: true, 
            subscriptionTier: product.name,
            accessReason: reason 
          },
        });

        console.log(`Granted ${reason} access to ${customer.email}`);
      },

      onRevokeAccess: async ({ reason, product, customer, metadata }) => {
        const userId = metadata?.referenceId as string;

        // Update your database specific logic
        await db.user.update({
          where: { id: userId },
          data: { 
            hasAccess: false, 
            revokeReason: reason 
          },
        });

        console.log(`Revoked access (${reason}) from ${customer.email}`);
      },
    }),
  ],
})

Grant Access Reasons

  • subscription_active - Subscription is active
  • subscription_trialing - Subscription is in trial period
  • subscription_paid - Subscription payment received

Revoke Access Reasons

  • subscription_paused - Subscription paused by user or admin
  • subscription_expired - Subscription expired without renewal
  • subscription_period_end - Current subscription period ended without renewal

Granular Event Handlers

For advanced use cases where you need fine-grained control over specific events, use these handlers:

Handler NameData Parameter TypeDescription
onCheckoutCompletedFlatCheckoutCompletedCalled when a checkout is completed successfully.
onRefundCreatedFlatRefundCreatedTriggered when a refund is issued for a payment.
onDisputeCreatedFlatDisputeCreatedInvoked when a payment dispute/chargeback is created.
onSubscriptionActiveFlatSubscriptionEventFired when a subscription becomes active.
onSubscriptionTrialingFlatSubscriptionEventSubscription enters a trial period.
onSubscriptionCanceledFlatSubscriptionEventCalled when a subscription is canceled.
onSubscriptionPaidFlatSubscriptionEventSubscription payment is received.
onSubscriptionExpiredFlatSubscriptionEventSubscription has expired (no renewal/payment).
onSubscriptionUnpaidFlatSubscriptionEventPayment for a subscription failed or remains unpaid.
onSubscriptionUpdateFlatSubscriptionEventSubscription settings/details updated.
onSubscriptionPastDueFlatSubscriptionEventSubscription payment is late or overdue.
onSubscriptionPausedFlatSubscriptionEventSubscription has been paused (by user or admin).

How to use a Webhook Handler

Handle individual webhook events with all properties flattened for easy access:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { creem } from "@creem_io/better-auth";

export const auth = betterAuth({
  database: {
    // your database config
  },
  plugins: [
    creem({
      apiKey: process.env.CREEM_API_KEY!,
      webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,

      onCheckoutCompleted: async (data) => {
        const { customer, product, order, webhookEventType } = data;
        console.log(`${customer.email} purchased ${product.name}`);
        
        // Perfect for one-time payments
        await sendThankYouEmail(customer.email);
      },

      onSubscriptionActive: async (data) => {
        const { customer, product, status } = data;
        // Handle active subscription
      },

      onSubscriptionTrialing: async (data) => {
        // Handle trial period
      },

      onSubscriptionCanceled: async (data) => {
        // Handle cancellation
      },

      onSubscriptionExpired: async (data) => {
        // Handle expiration
      },

      onRefundCreated: async (data) => {
        // Handle refunds
      },

      onDisputeCreated: async (data) => {
        // Handle disputes
      },
    }),
  ],
});

Custom Webhook Handler

Create your own webhook endpoint with signature verification:

// app/api/webhooks/custom/route.ts
import { validateWebhookSignature } from "@creem_io/better-auth/server";

export async function POST(req: Request) {
  const payload = await req.text();
  const signature = req.headers.get("creem-signature");

  if (
    !validateWebhookSignature(
      payload,
      signature,
      process.env.CREEM_WEBHOOK_SECRET!
    )
  ) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(payload);
  // Your custom webhook handling logic

  return Response.json({ received: true });
}

Server-Side Functions

Use these utilities directly in Server Components, Server Actions, or API routes without going through Better Auth endpoints.

Import Server Utilities

import {
  createCheckout,
  createPortal,
  cancelSubscription,
  retrieveSubscription,
  searchTransactions,
  checkSubscriptionAccess,
  isActiveSubscription,
  formatCreemDate,
  getDaysUntilRenewal,
  validateWebhookSignature,
} from "@creem_io/better-auth/server";

Server Component Example

import { checkSubscriptionAccess } from "@creem_io/better-auth/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    redirect("/login");
  }

  const status = await checkSubscriptionAccess(
    {
      apiKey: process.env.CREEM_API_KEY!,
      testMode: true,
    },
    {
      database: auth.options.database,
      userId: session.user.id,
    }
  );

  if (!status.hasAccess) {
    redirect("/subscribe");
  }

  return (
    <div>
      <h1>Welcome to Dashboard</h1>
      <p>Subscription Status: {status.status}</p>
      {status.expiresAt && (
        <p>Renews: {status.expiresAt.toLocaleDateString()}</p>
      )}
    </div>
  );
}

Server Action Example

"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?.user) {
    throw new Error("Not authenticated");
  }

  const { url } = await createCheckout(
    {
      apiKey: process.env.CREEM_API_KEY!,
      testMode: true,
    },
    {
      productId,
      customer: { email: session.user.email },
      successUrl: "/success",
      metadata: { userId: session.user.id },
    }
  );

  redirect(url);
}

Middleware Example

Protect routes based on subscription status:

import { checkSubscriptionAccess } from "@creem_io/better-auth/server";
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  const status = await checkSubscriptionAccess(
    {
      apiKey: process.env.CREEM_API_KEY!,
      testMode: true,
    },
    {
      database: auth.options.database,
      userId: session.user.id,
    }
  );

  if (!status.hasAccess) {
    return NextResponse.redirect(new URL("/subscribe", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

Utility Functions

import {
  isActiveSubscription,
  formatCreemDate,
  getDaysUntilRenewal,
} from "@creem_io/better-auth/server";

// Check if status grants access
if (isActiveSubscription(subscription.status)) {
  // User has access
}

// Format Creem timestamps
const renewalDate = formatCreemDate(subscription.next_billing_date);
console.log(renewalDate.toLocaleDateString());

// Calculate days until renewal
const days = getDaysUntilRenewal(subscription.current_period_end_date);
console.log(`Renews in ${days} days`);

Database Mode vs API Mode

The plugin supports two operational modes:

When persistSubscriptions: true (default), subscription data is stored in your database.

Benefits:

  • Fast access checks without API calls
  • Offline access to subscription data
  • Query subscriptions with SQL
  • Automatic synchronization via webhooks
  • Trial abuse prevention

Usage:

creem({
  apiKey: process.env.CREEM_API_KEY!,
  persistSubscriptions: true, // Default
})

API Mode

When persistSubscriptions: false, all data comes directly from the Creem API.

Benefits:

  • No database schema required
  • Simpler initial setup

Limitations:

  • Requires API call for each access check
  • Some features require custom implementation
  • No built-in trial abuse prevention

Usage:

creem({
  apiKey: process.env.CREEM_API_KEY!,
  persistSubscriptions: false,
})

In API mode, functions like checkSubscriptionAccess and hasAccessGranted have limited functionality and may require custom implementation using the Creem SDK directly.

Type Exports

Server-Side Types

Type NameDescriptionTypical Usage
CreemOptionsConfiguration options for the Creem plugin, such as API keys and persistence settings.Used to configure the plugin on the server.
GrantAccessContextContext passed to custom access control hooks when granting access to a user.Used in custom access logic.
RevokeAccessContextContext passed to hooks when revoking user access due to subscription status changes.Used in custom access logic.
GrantAccessReasonEnum or type describing reasons for granting access (e.g., payment received, trial activated).Returned in access-related hooks and events.
RevokeAccessReasonEnum or type describing reasons for revoking access (e.g., canceled, payment failed).Returned in access-related hooks and events.
FlatCheckoutCompletedEvent object type for webhook payload when a checkout completes successfully.Used in webhook handlers and event listeners.
FlatRefundCreatedEvent object type for webhook payload when a refund is created.Used in webhook handlers and event listeners.
FlatDisputeCreatedEvent object type for webhook payload when a dispute is created.Used in webhook handlers and event listeners.
FlatSubscriptionEventEvent object type for generic subscription events (created, updated, canceled, etc).Used in webhook handlers and event listeners.

Client-Side Types

Type NameDescription
CreateCheckoutInputInput parameters for creating a checkout session.
CreateCheckoutResponseResponse shape for a checkout session creation request.
CheckoutCustomerCustomer information type used in a checkout session.
CreatePortalInputInput parameters for creating a customer portal session.
CreatePortalResponseResponse data for a request to create a customer portal.
CancelSubscriptionInputInput parameters when cancelling a subscription.
CancelSubscriptionResponseResponse data for a subscription cancellation request.
RetrieveSubscriptionInputInput for retrieving a specific subscription's details.
SubscriptionDataSubscription information structure as returned by the API.
SearchTransactionsInputFilters and parameters for searching transactions.
SearchTransactionsResponseResponse structure for a transaction search query.
TransactionDataData relating to individual transactions (e.g., payment, refund, etc).
HasAccessGrantedResponseThe shape of the response indicating whether a user has access based on subscription status/rules.

Trial Abuse Prevention

When using database mode (persistSubscriptions: true), the plugin automatically prevents trial abuse. Users can only receive one trial across all subscription plans.

Example Scenario:

  1. User subscribes to "Starter" plan with 7-day trial
  2. User cancels subscription during the trial period
  3. User attempts to subscribe to "Premium" plan
  4. No trial is offered - user is charged immediately

This protection is automatic and requires no configuration. Trial eligibility is determined when the subscription is created and cannot be overridden.

Troubleshooting

Webhook Issues

If webhooks aren't being processed correctly:

  1. Verify the webhook URL is correct in your Creem dashboard
  2. Check that the webhook signing secret matches
  3. Ensure all necessary events are selected in the Creem dashboard
  4. Review server logs for webhook processing errors
  5. Test webhook delivery using Creem's webhook testing tool

Subscription Status Issues

If subscription statuses aren't updating:

  1. Confirm webhooks are being received and processed
  2. Verify creemCustomerId and creemSubscriptionId fields are populated
  3. Check that reference IDs match between your application and Creem
  4. Review webhook handler logs for errors

Database Mode Not Working

If database persistence isn't functioning:

  1. Ensure persistSubscriptions: true is set (it's the default)
  2. Run migrations: npx @better-auth/cli migrate
  3. Verify database connection is working
  4. Check that schema tables were created successfully
  5. Review database adapter configuration

API Mode Limitations

Some functionalities are only available in database mode or require extra parameters to be passed:

  • checkSubscriptionAccess requires passing the userId parameter
  • getActiveSubscriptions requires passing the userId parameter
  • No automatic trial abuse prevention
  • No access to hasAccessGranted client method

To use these features, either enable database mode or implement custom logic using the Creem SDK directly.

Additional Resources

Support

For issues or questions: