docs/content/docs/plugins/creem.mdx
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.
<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-betterauth). </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>```package-install
@creem_io/better-auth
```
<Callout>
If you're using a separate client and server setup, make sure to install the plugin in both parts of your project.
</Callout>
Get your Creem API Key from the [Creem dashboard](https://creem.io/dashboard/developers), under the 'Developers' menu and add it to your environment variables:
```bash
# .env
CREEM_API_KEY=your_api_key_here
```
<Callout type="warn">
Test Mode and Production have different API keys. Make sure you're using the correct one for your environment.
</Callout>
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)
}),
],
});
// 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()],
});
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()],
});
If you're using database persistence (persistSubscriptions: true), generate and run the database schema:
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
package-install npx auth migrate
</Tab>
In your [Creem dashboard](https://creem.io/dashboard/developers/webhooks), create a webhook endpoint pointing to your local or production server pointing to:
```text
https://your-domain.com/api/auth/creem/webhook
```
(`/api/auth` is the default Better Auth server path)
<Callout type="info">
Check step 3 if local development.
</Callout>
Copy the webhook signing secret from Creem and add it to your environment:
```bash
CREEM_WEBHOOK_SECRET=your_webhook_secret_here
```
Update your server configuration:
```typescript
creem({
apiKey: process.env.CREEM_API_KEY!,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
testMode: true,
})
```
For local testing, use a tool like [ngrok](https://ngrok.com) to expose your local server:
```bash
ngrok http 3000
```
Add the ngrok URL to your Creem webhook settings.
When persistSubscriptions: true, the plugin creates the following schema:
Table Name: creem_subscription
| 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 |
| Field | Type | Description |
|---|---|---|
creemCustomerId | string | Links user to Creem customer |
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>;
}
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 as referenceId)requestId - Idempotency key for duplicate preventionRedirect 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();
};
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",
});
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}`);
}
};
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}`);
}
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}`);
});
}
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.
<strong> Database Persistence Required:</strong> 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?
// 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}`);
},
}),
],
})
subscription_active - Subscription is activesubscription_trialing - Subscription is in trial periodsubscription_paid - Subscription payment receivedsubscription_paused - Subscription paused by user or adminsubscription_expired - Subscription expired without renewalsubscription_period_end - Current subscription period ended without renewalFor 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). |
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
},
}),
],
});
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 });
}
Use these utilities directly in Server Components, Server Actions, or API routes without going through Better Auth endpoints.
import {
createCheckout,
createPortal,
cancelSubscription,
retrieveSubscription,
searchTransactions,
checkSubscriptionAccess,
isActiveSubscription,
formatCreemDate,
getDaysUntilRenewal,
validateWebhookSignature,
} from "@creem_io/better-auth/server";
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>
);
}
"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);
}
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*"],
};
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`);
The plugin supports two operational modes:
When persistSubscriptions: true (default), subscription data is stored in your database.
Benefits:
Usage:
creem({
apiKey: process.env.CREEM_API_KEY!,
persistSubscriptions: true, // Default
})
When persistSubscriptions: false, all data comes directly from the Creem API.
Benefits:
Limitations:
Usage:
creem({
apiKey: process.env.CREEM_API_KEY!,
persistSubscriptions: false,
})
| 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. |
| 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. |
When using database mode (persistSubscriptions: true), the plugin automatically prevents trial abuse. Users can only receive one trial across all subscription plans.
Example Scenario:
This protection is automatic and requires no configuration. Trial eligibility is determined when the subscription is created and cannot be overridden.
If webhooks aren't being processed correctly:
If subscription statuses aren't updating:
creemCustomerId and creemSubscriptionId fields are populatedIf database persistence isn't functioning:
persistSubscriptions: true is set (it's the default)npx auth migrateSome functionalities are only available in database mode or require extra parameters to be passed:
checkSubscriptionAccess requires passing the userId parametergetActiveSubscriptions requires passing the userId parameterhasAccessGranted client methodTo use these features, either enable database mode or implement custom logic using the Creem SDK directly.
For issues or questions: