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-authIf 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_hereTest 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 migrateDepending 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_hereUpdate 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 3000Add the ngrok URL to your Creem webhook settings.
Database Schema
When persistSubscriptions: true, the plugin creates the following schema:
Subscription Table
| Field | Type | Description |
|---|---|---|
id | string | Primary key |
productId | string | Creem product ID |
referenceId | string | Your user/organization ID |
creemCustomerId | string | Creem customer ID |
creemSubscriptionId | string | Creem subscription ID |
creemOrderId | string | Creem order ID |
status | string | Subscription status |
periodStart | date | Billing period start date |
periodEnd | date | Billing period end date |
cancelAtPeriodEnd | boolean | Whether subscription will cancel |
User Table Extension
| Field | Type | Description |
|---|---|---|
creemCustomerId | string | Links 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 IDunits- Number of units (default: 1)successUrl- Redirect URL after successful paymentdiscountCode- Discount code to applycustomer- Customer information (auto-populated from session)metadata- Additional metadata (auto-includes user ID asreferenceId)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.
High-Level Access Control Handlers (Recommended)
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 Name | Data Parameter Type | Description |
|---|---|---|
onGrantAccess | GrantAccessContext | Called 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. |
onRevokeAccess | RevokeAccessContext | Called 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 activesubscription_trialing- Subscription is in trial periodsubscription_paid- Subscription payment received
Revoke Access Reasons
subscription_paused- Subscription paused by user or adminsubscription_expired- Subscription expired without renewalsubscription_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 Name | Data Parameter Type | Description |
|---|---|---|
onCheckoutCompleted | FlatCheckoutCompleted | Called when a checkout is completed successfully. |
onRefundCreated | FlatRefundCreated | Triggered when a refund is issued for a payment. |
onDisputeCreated | FlatDisputeCreated | Invoked when a payment dispute/chargeback is created. |
onSubscriptionActive | FlatSubscriptionEvent | Fired when a subscription becomes active. |
onSubscriptionTrialing | FlatSubscriptionEvent | Subscription enters a trial period. |
onSubscriptionCanceled | FlatSubscriptionEvent | Called when a subscription is canceled. |
onSubscriptionPaid | FlatSubscriptionEvent | Subscription payment is received. |
onSubscriptionExpired | FlatSubscriptionEvent | Subscription has expired (no renewal/payment). |
onSubscriptionUnpaid | FlatSubscriptionEvent | Payment for a subscription failed or remains unpaid. |
onSubscriptionUpdate | FlatSubscriptionEvent | Subscription settings/details updated. |
onSubscriptionPastDue | FlatSubscriptionEvent | Subscription payment is late or overdue. |
onSubscriptionPaused | FlatSubscriptionEvent | Subscription 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:
Database Mode (Recommended)
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 Name | Description | Typical Usage |
|---|---|---|
CreemOptions | Configuration options for the Creem plugin, such as API keys and persistence settings. | Used to configure the plugin on the server. |
GrantAccessContext | Context passed to custom access control hooks when granting access to a user. | Used in custom access logic. |
RevokeAccessContext | Context passed to hooks when revoking user access due to subscription status changes. | Used in custom access logic. |
GrantAccessReason | Enum or type describing reasons for granting access (e.g., payment received, trial activated). | Returned in access-related hooks and events. |
RevokeAccessReason | Enum or type describing reasons for revoking access (e.g., canceled, payment failed). | Returned in access-related hooks and events. |
FlatCheckoutCompleted | Event object type for webhook payload when a checkout completes successfully. | Used in webhook handlers and event listeners. |
FlatRefundCreated | Event object type for webhook payload when a refund is created. | Used in webhook handlers and event listeners. |
FlatDisputeCreated | Event object type for webhook payload when a dispute is created. | Used in webhook handlers and event listeners. |
FlatSubscriptionEvent | Event object type for generic subscription events (created, updated, canceled, etc). | Used in webhook handlers and event listeners. |
Client-Side Types
| Type Name | Description |
|---|---|
CreateCheckoutInput | Input parameters for creating a checkout session. |
CreateCheckoutResponse | Response shape for a checkout session creation request. |
CheckoutCustomer | Customer information type used in a checkout session. |
CreatePortalInput | Input parameters for creating a customer portal session. |
CreatePortalResponse | Response data for a request to create a customer portal. |
CancelSubscriptionInput | Input parameters when cancelling a subscription. |
CancelSubscriptionResponse | Response data for a subscription cancellation request. |
RetrieveSubscriptionInput | Input for retrieving a specific subscription's details. |
SubscriptionData | Subscription information structure as returned by the API. |
SearchTransactionsInput | Filters and parameters for searching transactions. |
SearchTransactionsResponse | Response structure for a transaction search query. |
TransactionData | Data relating to individual transactions (e.g., payment, refund, etc). |
HasAccessGrantedResponse | The 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:
- User subscribes to "Starter" plan with 7-day trial
- User cancels subscription during the trial period
- User attempts to subscribe to "Premium" plan
- 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:
- Verify the webhook URL is correct in your Creem dashboard
- Check that the webhook signing secret matches
- Ensure all necessary events are selected in the Creem dashboard
- Review server logs for webhook processing errors
- Test webhook delivery using Creem's webhook testing tool
Subscription Status Issues
If subscription statuses aren't updating:
- Confirm webhooks are being received and processed
- Verify
creemCustomerIdandcreemSubscriptionIdfields are populated - Check that reference IDs match between your application and Creem
- Review webhook handler logs for errors
Database Mode Not Working
If database persistence isn't functioning:
- Ensure
persistSubscriptions: trueis set (it's the default) - Run migrations:
npx @better-auth/cli migrate - Verify database connection is working
- Check that schema tables were created successfully
- Review database adapter configuration
API Mode Limitations
Some functionalities are only available in database mode or require extra parameters to be passed:
checkSubscriptionAccessrequires passing theuserIdparametergetActiveSubscriptionsrequires passing theuserIdparameter- No automatic trial abuse prevention
- No access to
hasAccessGrantedclient method
To use these features, either enable database mode or implement custom logic using the Creem SDK directly.
Additional Resources
- Creem Documentation
- Creem Dashboard
- Better Auth Documentation
- Plugin GitHub Repository Additional Documentation
Support
For issues or questions:
- Open an issue on GitHub
- Contact Creem support at [email protected]
- Join our Discord community for real-time support and discussion.
- Chat with us directly using the in-app live chat on the Creem dashboard.