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
onGrantAccessandonRevokeAccess - Offer each user at most one trial across all plans
Installation
Install the plugin
npm install @creem_io/better-authThe 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.
CREEM_API_KEY=your_api_key
CREEM_WEBHOOK_SECRET=your_webhook_secretTest 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
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
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 migratemigrate 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.
| Option | Description |
|---|---|
productId | The Creem product ID. 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
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:
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:
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_cancelkeeps access until the current period ends. The plugin stores the statusscheduled_cancelwithcancelAtPeriodEnd: true, andhasAccessGranteddenies access onceperiodEndpasses. 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.
| 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 |
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:
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.
"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
Subscription
Table Name: creem_subscription
Customizing the Schema
To rename tables or columns, pass a schema option. Field mappings are column names:
creem({
// ... other options
schema: {
creem_subscription: {
modelName: "billing_subscription",
fields: { referenceId: "user_id" },
},
user: {
fields: { creemCustomerId: "creem_customer_id" },
},
},
});Options
Server
| Option | Type | Description |
|---|---|---|
apiKey | string | Your Creem API key. 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. |
onGrantAccess | function | Called when a subscription event grants access. See Grant and revoke access. |
onRevokeAccess | function | Called when a subscription event revokes access. |
The per-event callbacks are listed under Event callbacks.
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
- Creem Better Auth documentation
- 2.0 migration guide
- Plugin source and issue tracker
- Creem dashboard, with in-app live chat
- support@creem.io
- Creem Discord community