@mmailaender/convex-creem
v0.3.0
Published
A Creem component for Convex.
Maintainers
Readme
Convex Creem Component
Add subscriptions, one-time purchases, and billing to your Convex app with Creem.
Check out the Svelte example and React example for complete integrations.
Table of Contents
- Quick Start — Backend
- Quick Start — Frontend (UI Widgets)
- Entity Model
- Scenarios
- Advanced
- API Reference
- Component Reference
- Troubleshooting
Quick Start — Backend
Complete these steps to use the billing API from your Convex functions. No frontend framework required.
1. Install
npm install @mmailaender/convex-creem2. Register component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import creem from "@mmailaender/convex-creem/convex.config";
const app = defineApp();
app.use(creem);
export default app;3. Set environment variables
npx convex env set CREEM_API_KEY <your_creem_api_key>
npx convex env set CREEM_WEBHOOK_SECRET <your_creem_webhook_signing_secret>4. Configure billing
// convex/billing.ts
import { Creem, type ApiResolver } from "@mmailaender/convex-creem";
import { api, components } from "./_generated/api";
import { query, internalAction } from "./_generated/server";
export const creem = new Creem(components.creem);
// Auth resolver — replace with your own auth logic
const resolve: ApiResolver = async (ctx) => {
const user = await ctx.runQuery(api.users.currentUser);
return {
userId: user._id,
email: user.email,
entityId: user._id, // For org billing: user.activeOrgId ?? user._id
};
};
// Generate Convex function exports — each calls resolve(), then delegates
const {
uiModel,
snapshot,
checkouts,
subscriptions,
products,
customers,
orders,
} = creem.api({ resolve });
export { uiModel, snapshot };
export const checkoutsCreate = checkouts.create;
export const subscriptionsUpdate = subscriptions.update;
export const subscriptionsCancel = subscriptions.cancel;
export const subscriptionsResume = subscriptions.resume;
export const subscriptionsPause = subscriptions.pause;
export const subscriptionsList = subscriptions.list;
export const subscriptionsListAll = subscriptions.listAll;
export const productsList = products.list;
export const productsGet = products.get;
export const customersRetrieve = customers.retrieve;
export const customersPortalUrl = customers.portalUrl;
export const ordersList = orders.list;
// Sync products from Creem (CLI / dashboard only)
export const syncBillingProducts = internalAction({
args: {},
handler: async (ctx) => {
await creem.syncProducts(ctx);
},
});5. Register webhooks
// convex/http.ts
import { httpRouter } from "convex/server";
import { creem } from "./billing";
const http = httpRouter();
creem.registerRoutes(http);
export default http;Use your Convex site URL + /creem/events as the webhook endpoint in your
Creem dashboard. The component automatically handles checkout.completed,
subscription.*, and product.* events.
For custom event handlers (e.g. sending emails on checkout), see Webhook event middleware.
6. Sync products
After configuring webhooks, pull your Creem products into the Convex database:
npx convex run billing:syncBillingProductsThis is an
internalAction— it can only be triggered from the CLI or the Convex dashboard.
You're done with the backend. You can now call api.billing.* from your
frontend or other Convex functions. If you only need the API (no UI widgets),
skip ahead to the API Reference.
Quick Start — Frontend (UI Widgets)
The component ships pre-built Svelte and React widgets that handle checkout, plan switching, cancellation, seat management, and billing state — all connected to Convex. Complete these three extra steps to use them.
7. Install UI primitives
The widgets are built on Ark UI headless primitives. Install the adapter for your framework:
React
npm install @ark-ui/reactSvelte
npm install @ark-ui/svelte8. Install Tailwind CSS
The widgets use Tailwind CSS v4. If your project doesn't have Tailwind yet, install it following the official guide.
9. Import styles
Add the component's design system import to your CSS entry point, after the Tailwind import:
@import "tailwindcss";
@import "@mmailaender/convex-creem/styles";This registers the component's design tokens, base styles, and @source
directives so Tailwind scans the library's component files automatically.
You're ready to use the UI widgets. Continue with the scenarios below.
Entity Model
By default, billing is scoped to the authenticated user — the entityId
returned from your resolver is used as the billing entity. All
creem.api({ resolve }) functions, checkout metadata, and webhook resolution
automatically use this entity.
For organization or team billing, return the org ID as entityId:
const resolve: ApiResolver = async (ctx) => {
const user = await ctx.runQuery(api.users.currentUser);
const org = await ctx.runQuery(api.orgs.getActiveOrg);
return {
userId: user._id,
email: user.email,
entityId: org?._id ?? user._id, // org billing or personal billing
};
};All billing operations scope to the entityId. Webhooks resolve
convexBillingEntityId from checkout metadata (falls back to convexUserId).
No other code changes needed.
For access control details, see Security & Access Control.
Scenarios
Both Svelte and React widgets share identical props and APIs — only the import path and framework boilerplate differ.
Convention: Where the markup is identical in both frameworks, examples are shown once. The only recurring difference is
class=(Svelte) vsclassName=(React) when passing CSS classes. Where Svelte and React syntax diverges (e.g. children rendering), both versions are shown.
Wire the billing API
Every widget needs a ConnectedBillingApi object. Create it once in your layout
or page component:
Svelte
<script lang="ts">
import { setupConvex } from "@mmailaender/convex-svelte";
import {
Subscription, Product, BillingPortal,
type ConnectedBillingApi,
} from "@mmailaender/convex-creem/svelte";
import { api } from "../convex/_generated/api.js";
setupConvex(import.meta.env.VITE_CONVEX_URL);
const billingApi: ConnectedBillingApi = {
uiModel: api.billing.uiModel,
checkouts: { create: api.billing.checkoutsCreate },
subscriptions: {
update: api.billing.subscriptionsUpdate,
cancel: api.billing.subscriptionsCancel,
resume: api.billing.subscriptionsResume,
},
customers: { portalUrl: api.billing.customersPortalUrl },
};
</script>React
import {
Subscription,
Product,
BillingPortal,
type ConnectedBillingApi,
} from "@mmailaender/convex-creem/react";
import { api } from "../convex/_generated/api";
const billingApi: ConnectedBillingApi = {
uiModel: api.billing.uiModel,
checkouts: { create: api.billing.checkoutsCreate },
subscriptions: {
update: api.billing.subscriptionsUpdate,
cancel: api.billing.subscriptionsCancel,
resume: api.billing.subscriptionsResume,
},
customers: { portalUrl: api.billing.customersPortalUrl },
};The
ConnectedBillingApiobject is the same shape in both frameworks. Only the Convex client setup differs:setupConvex()in Svelte vs<ConvexProvider>in React (see @mmailaender/convex-svelte and convex/react docs).
1. Subscriptions
1.1 Standard subscription plans
A typical pricing page with Free / Basic / Premium / Enterprise tiers. The billing toggle auto-derives from the cycles present in registered plans.
<Subscription.Root api={billingApi}>
<Subscription.Item type="free" title="Free" description="Up to 3 users" />
<Subscription.Item
planId="basic"
type="single"
productIds={{
"every-month": "prod_basic_monthly",
"every-year": "prod_basic_yearly",
}}
/>
<Subscription.Item
planId="premium"
type="single"
recommended
productIds={{
"every-month": "prod_premium_monthly",
"every-year": "prod_premium_yearly",
}}
/>
<Subscription.Item
type="enterprise"
title="Enterprise"
contactUrl="https://example.com/sales"
/>
</Subscription.Root>
<BillingPortal api={billingApi} />What you get:
- Pricing cards with auto-resolved titles, descriptions (rendered as Markdown), and prices from Creem product data
- Billing cycle toggle (monthly/yearly) — hidden when all plans share a single cycle
- "Current plan" badge on the active subscription
- Plan switching with confirmation dialog
- Trial countdown badge
- Cancel / resume subscription (with confirmation dialog)
- Scheduled cancellation banner with "Undo" button
1.2 Seat-based subscriptions
Two workflows for seat-based pricing:
User-selectable seats — the customer picks a quantity before checkout:
<Subscription.Root api={billingApi} showSeatPicker>
<Subscription.Item
type="seat-based"
productIds={{ "every-month": "prod_team_monthly" }}
/>
<Subscription.Item
type="seat-based"
productIds={{ "every-month": "prod_business_monthly" }}
/>
</Subscription.Root>Auto-derived seats — pass a fixed count (e.g. org member count) to checkout:
<Subscription.Root api={billingApi} units={orgMemberCount}>
<Subscription.Item
type="seat-based"
productIds={{ "every-month": "prod_team_monthly" }}
/>
</Subscription.Root>When subscriptions.update is provided in the API, active seat-based plans show
a "Change seats" control.
Tip: For auto-derived seats, keep the subscription in sync with your data. When your member count changes, call
subscriptions.updatewith the newunitsso the billing reflects the current seat count.
2. Products
2.1 Single one-time product
A standalone product purchased once. Shows "Owned" after purchase:
<Product.Root api={billingApi}>
<Product.Item type="one-time" productId="prod_license" />
</Product.Root>2.2 Repeating product (consumable)
Can be purchased multiple times — no "Owned" badge:
<Product.Root api={billingApi}>
<Product.Item type="recurring" productId="prod_credits" title="100 Credits" />
</Product.Root>2.3 Mutually exclusive product group
Use the transition prop to define upgrade paths between products. When the
user owns a lower-tier product, only valid upgrade paths are shown:
<Product.Root
api={billingApi}
transition={[
{
from: "prod_basic_license",
to: "prod_premium_license",
kind: "via_product",
viaProductId: "prod_basic_to_premium_upgrade",
},
]}
>
<Product.Item type="one-time" productId="prod_basic_license" />
<Product.Item type="one-time" productId="prod_premium_license" />
</Product.Root>Transition kinds:
via_product— checkout uses a dedicated upgrade product (delta pricing)direct— checkout uses the target product directly
3. Billing Portal
<BillingPortal> opens the Creem customer billing portal. It auto-hides when
the billing entity has no Creem customer record.
Pass permissions to control who can access the portal (e.g. only admins):
<BillingPortal api={billingApi} permissions={{ canAccessPortal: isAdmin }} /><!-- After a subscription group -->
<BillingPortal api={billingApi} />
<!-- Standalone with custom label -->
<BillingPortal api={billingApi}>Manage billing & invoices</BillingPortal>4. Feature Gating
Use BillingGate to conditionally render UI based on available billing actions:
Svelte
<BillingGate snapshot={billingSnapshot} requiredActions="portal">
{#snippet children()}
<p>You have portal access.</p>
{/snippet}
{#snippet fallback()}
<p>Upgrade to access the billing portal.</p>
{/snippet}
</BillingGate>React
<BillingGate
snapshot={billingSnapshot}
requiredActions="portal"
fallback={<p>Upgrade to access the billing portal.</p>}
>
<p>You have portal access.</p>
</BillingGate>Available actions: checkout, portal, cancel, reactivate,
switch_interval, update_seats, contact_sales.
5. Checkout Success
Show a confirmation banner when the user returns from checkout. The component parses Creem's query parameters automatically:
<CheckoutSuccessSummary />Advanced
Webhook event middleware
registerRoutes accepts an optional events object to run app-specific logic
alongside the component's automatic handling:
creem.registerRoutes(http, {
path: "/creem/events", // default
events: {
"checkout.completed": async (ctx, event) => {
// ctx is a Convex mutation context
// event has { type, data } from Creem
// Example: send confirmation email, grant entitlements, log analytics
},
"subscription.updated": async (ctx, event) => {
const data = event.data as { customerCancellationReason?: string };
if (data?.customerCancellationReason) {
console.log("Cancellation reason:", data.customerCancellationReason);
}
},
},
});Your handlers run after the component's built-in processing
(customer/subscription/order upserts). The ctx is a Convex mutation context —
you can read/write to your own tables.
Supported events: checkout.completed, subscription.active,
subscription.updated, subscription.canceled, subscription.paused,
subscription.resumed, product.created, product.updated.
Security & Access Control
Auth is the app's responsibility. The component is a sync engine — it reads from Convex DB and writes to the Creem API. Every class method takes explicit args; there is no hidden auth layer.
Choose your approach:
Quick start — use
creem.api({ resolve })to generate ready-to-export Convex functions. Each one calls yourresolvecallback to authenticate and determine theentityId. The billing entity is derived from the authenticated session, never from client input. See Step 4: Configure billing and thecreem.api({ resolve })reference.Full control — use the resource namespaces (
creem.subscriptions.*,creem.checkouts.*, etc.) directly in your own Convex functions. You handle auth, entity resolution, and permission checks yourself.The library exports shared arg validators that match exactly what the connected widgets send. Use them to keep your custom functions in sync:
| Export | Used by | | ------------------------ | ------------------------------------------------ | |
checkoutCreateArgs|<Subscription.Root>,<Product.Root>| |subscriptionUpdateArgs|<Subscription.Root>(plan switch, seat update) | |subscriptionCancelArgs|<Subscription.Root>(cancel button) | |subscriptionResumeArgs|<Subscription.Root>(resume button) | |subscriptionPauseArgs|<Subscription.Root>(pause button) |Example:
// convex/billing.ts
import {
Creem,
checkoutCreateArgs,
subscriptionCancelArgs,
} from "@mmailaender/convex-creem";
import { ConvexError } from "convex/values";
import { action, mutation } from "./_generated/server";
import { api, components } from "./_generated/api";
const creem = new Creem(components.creem);
async function resolveAuth(ctx) {
const session = await ctx.runQuery(api.auth.getSession);
if (!session) throw new ConvexError("Not authenticated");
const org = await ctx.runQuery(api.orgs.getActiveOrg);
return {
userId: session.userId,
email: session.user.email,
entityId: org?._id ?? session.userId,
role: session.user.role,
};
}
// Admin-only: create checkout
export const checkoutsCreate = action({
args: checkoutCreateArgs,
handler: async (ctx, args) => {
const auth = await resolveAuth(ctx);
if (auth.role !== "admin") throw new ConvexError("Forbidden");
return await creem.checkouts.create(ctx, {
entityId: auth.entityId,
userId: auth.userId,
email: auth.email,
...args,
});
},
});
// Admin-only: cancel subscription
export const subscriptionsCancel = mutation({
args: subscriptionCancelArgs,
handler: async (ctx, args) => {
const auth = await resolveAuth(ctx);
if (auth.role !== "admin") throw new ConvexError("Forbidden");
await creem.subscriptions.cancel(ctx, { entityId: auth.entityId, ...args });
},
});UI-side permissions — BillingPermissions controls which buttons are
enabled in the widgets. This is cosmetic gating only; enforce real permissions
server-side.
type BillingPermissions = {
canCheckout?: boolean;
canChangeSubscription?: boolean;
canCancelSubscription?: boolean;
canResumeSubscription?: boolean;
canUpdateSeats?: boolean;
canAccessPortal?: boolean;
};<script lang="ts">
const isAdmin = $derived(currentUser?.role === "admin" || currentUser?.role === "owner");
const permissions = $derived({
canCheckout: isAdmin,
canChangeSubscription: isAdmin,
canCancelSubscription: isAdmin,
canResumeSubscription: isAdmin,
canUpdateSeats: isAdmin,
});
</script>
<Subscription.Root api={billingApi} {permissions}>
...
</Subscription.Root>When a permission is false, the button renders as disabled (greyed out). When
omitted or undefined, all actions default to enabled.
Pre-checkout gate — onBeforeCheckout
Both <Subscription.Root> and <Product.Root> accept an onBeforeCheckout
callback that fires before the widget calls checkouts.create. Return
true to proceed, false to abort silently.
This is a generic hook — use it for authentication gates, terms acceptance, confirmation dialogs, analytics, or any logic that must run before checkout.
<Subscription.Root
api={billingApi}
onBeforeCheckout={(intent) => {
if (!currentUser) {
pendingCheckout.save(intent);
openSignInDialog();
return false;
}
return true;
}}
>
...
</Subscription.Root>CheckoutIntent — the object passed to the callback:
type CheckoutIntent = {
productId: string;
units?: number;
};Auto-resume after sign-in
The widget automatically resumes a pending checkout when the user becomes authenticated. The full flow:
- Unauthenticated user clicks "Subscribe" →
onBeforeCheckoutfires - Your callback saves the intent via
pendingCheckout.save(intent), opens your sign-in dialog/redirect, and returnsfalse - After sign-in, the Convex query re-fires →
model.userbecomes non-null - The widget detects the pending checkout and auto-triggers
checkouts.create
This works for both modal auth (Clerk, Auth0 popup) and redirect auth (OAuth) — no manual resume code needed.
Safety: The widget skips auto-resume if the user already has an active
subscription (<Subscription.Root>) or already owns the product
(<Product.Root>), preventing duplicate purchases after sign-in.
pendingCheckout is a tiny sessionStorage-based helper exported from the
library:
import { pendingCheckout } from "@mmailaender/convex-creem/svelte";
pendingCheckout.save(intent); // store before sign-in
pendingCheckout.load(); // read + auto-clear (used internally by widgets)
pendingCheckout.clear(); // manual clear if neededCustom billing UI model
uiModel (from creem.api({ resolve })) returns everything the connected
widgets need. If you need app-specific fields, write your own query using
creem.getBillingModel():
import { query } from "./_generated/server";
export const getCustomBillingModel = query({
args: {},
handler: async (ctx) => {
const user = await currentUser(ctx);
const billingData = await creem.getBillingModel(ctx, {
entityId: user?._id ?? null,
user: user ? { _id: user._id, email: user.email } : null,
});
return {
...billingData,
teamSize: user?.teamSize,
featureFlags: user?.featureFlags,
};
},
});Server endpoint overrides
Only needed for non-default API endpoints (e.g. test/staging):
npx convex env set CREEM_SERVER_IDX <index>
# or
npx convex env set CREEM_SERVER_URL <url>Leave both unset to use the default Creem production endpoint.
API Reference
Resource namespaces — creem.<namespace>.*
All methods take explicit arguments. Use them directly in your own Convex
functions, or let creem.api({ resolve }) generate ready-to-export wrappers.
creem.subscriptions.*
| Method | Data source | Description |
| ---------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| .getCurrent(ctx, { entityId }) | Convex DB | Current active subscription with product join |
| .list(ctx, { entityId }) | Convex DB | Active subscriptions (excludes ended + expired trials) |
| .listAll(ctx, { entityId }) | Convex DB | All subscriptions including ended |
| .update(ctx, { entityId, subscriptionId?, productId?, units?, updateBehavior? }) | Creem API | Unified plan switch (productId) or seat update (units). Pass subscriptionId when the entity has multiple active subscriptions. Optional proration override. |
| .cancel(ctx, { entityId, revokeImmediately? }) | Creem API | Cancel subscription |
| .pause(ctx, { entityId }) | Creem API | Pause an active subscription |
| .resume(ctx, { entityId }) | Creem API | Resume a paused or scheduled-cancel subscription |
creem.checkouts.*
| Method | Data source | Description |
| ------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
| .create(ctx, { entityId, userId, email, productId, successUrl?, fallbackSuccessUrl?, units?, metadata?, theme? }) | Creem API | Create checkout URL with 3-tier successUrl resolution and optional theme |
creem.products.*
| Method | Data source | Description |
| -------------------------- | ----------- | --------------------------------------------------- |
| .list(ctx, options?) | Convex DB | All synced products (public — no entityId needed) |
| .get(ctx, { productId }) | Convex DB | Single product by ID (public) |
creem.customers.*
| Method | Data source | Description |
| ------------------------------- | ----------- | --------------------------- |
| .retrieve(ctx, { entityId }) | Convex DB | Customer record by entity |
| .portalUrl(ctx, { entityId }) | Creem API | Customer billing portal URL |
creem.orders.*
| Method | Data source | Description |
| -------------------------- | ----------- | -------------------- |
| .list(ctx, { entityId }) | Convex DB | Paid one-time orders |
Composite helpers (top-level methods)
| Method | Description |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| creem.getBillingModel(ctx, { entityId, user? }) | Aggregates snapshot + products + subscriptions + orders into a single object for widgets. Graceful when entityId is null (returns public catalog only). |
| creem.getBillingSnapshot(ctx, { entityId }) | Resolved billing state (plan, status, available actions). Uses resolvePlan override if configured, otherwise built-in resolver. |
creem.api({ resolve }) — convenience exports
Generates ready-to-export Convex function definitions. Each function calls your
resolve callback, then delegates to the corresponding namespace method.
| Export | Wraps | Type | Description |
| ----------------------- | ----------------------- | ------ | ---------------------------------------------------------------------------------- |
| uiModel | getBillingModel | query | Calls resolve(), then getBillingModel. Graceful when unauthenticated. |
| snapshot | getBillingSnapshot | query | Calls resolve(), then getBillingSnapshot. Returns null when unauthenticated. |
| checkouts.create | checkouts.create | action | Auto-resolves auth |
| subscriptions.update | subscriptions.update | action | Auto-resolves auth |
| subscriptions.cancel | subscriptions.cancel | action | Auto-resolves auth |
| subscriptions.resume | subscriptions.resume | action | Auto-resolves auth |
| subscriptions.pause | subscriptions.pause | action | Auto-resolves auth |
| subscriptions.list | subscriptions.list | query | Auto-resolves auth |
| subscriptions.listAll | subscriptions.listAll | query | Auto-resolves auth |
| products.list | products.list | query | Public, no auth needed |
| products.get | products.get | query | Public, no auth needed |
| customers.retrieve | customers.retrieve | query | Auto-resolves auth |
| customers.portalUrl | customers.portalUrl | action | Auto-resolves auth |
| orders.list | orders.list | query | Auto-resolves auth |
Infrastructure
| Method | Description |
| ------------------------------------------------ | ------------------------------------------- |
| creem.syncProducts(ctx) | Pull products from Creem API into Convex DB |
| creem.registerRoutes(http, { path?, events? }) | Register webhook HTTP routes |
Direct API access — creem.sdk.*
The resource namespaces above cover all billing features that stay in sync
with Convex via webhooks. Some Creem API resources have no webhook support, so
the component cannot mirror them in Convex DB. For these, use creem.sdk.*
directly inside your own Convex actions — it's the same Creem SDK client,
already configured with your API key:
| Resource | Synced to Convex? | Access |
| ---------------- | -------------------- | -------------------------- |
| Subscriptions | Yes (webhook) | creem.subscriptions.* |
| Checkouts | Yes (webhook) | creem.checkouts.* |
| Products | Yes (webhook + sync) | creem.products.* |
| Customers | Yes (webhook) | creem.customers.* |
| Orders | Yes (webhook) | creem.orders.* |
| Licenses | No webhook | creem.sdk.licenses.* |
| Discounts | No webhook | creem.sdk.discounts.* |
| Transactions | No webhook | creem.sdk.transactions.* |
import { action } from "./_generated/server";
import { v } from "convex/values";
// Example: create a discount (not synced — Creem has no webhook for discounts)
export const createDiscount = action({
args: { code: v.string(), percentage: v.number() },
handler: async (ctx, args) => {
return await creem.sdk.discounts.create({
name: args.code,
code: args.code,
type: "percentage",
percentage: args.percentage,
duration: "forever",
appliesTo: [],
});
},
});Component Reference
All components share identical props across Svelte and React.
- Import:
@mmailaender/convex-creem/svelteor@mmailaender/convex-creem/react - CSS class prop:
classin Svelte,classNamein React - Children: Svelte
Snippet/ ReactReactNode - Svelte components use Svelte 5 runes and snippet rendering
(
{@render ...})
See the Svelte example and React example for complete integrations.
Widgets
These query Convex directly and manage billing state end-to-end.
<Subscription.Root>
Container for subscription plan cards. Handles billing cycle toggle, checkout, plan switching, cancellation, and seat management.
| Prop | Type | Default | Description |
| ------------------- | --------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api | ConnectedBillingApi | — | Required. Backend function references |
| permissions | BillingPermissions | all enabled | Disable actions based on user role |
| class/className | string | "" | Wrapper CSS class |
| successUrl | string | product's defaultSuccessUrl → current page | Override redirect after checkout. When omitted, uses the product's defaultSuccessUrl from Creem; if that is also unset, falls back to the current page. |
| units | number | — | Auto-derived seat count for seat-based plans |
| showSeatPicker | boolean | false | Show quantity picker on seat-based cards |
| twoColumnLayout | boolean | false | Use two-column card layout |
| updateBehavior | UpdateBehavior | "proration-charge-immediately" | How plan switches and seat updates are billed. See below. |
| onBeforeCheckout | (intent: CheckoutIntent) => Promise<boolean> \| boolean | — | Gate checkout (auth, terms, etc.). Return false to abort. |
| children | Snippet / ReactNode | — | <Subscription.Item> children |
UpdateBehavior controls how the Creem API handles plan switches and seat
changes:
"proration-charge-immediately"— prorate and charge the difference now (default)"proration-charge"— prorate, charge on next invoice"proration-none"— no proration, change takes effect on next billing cycle
<Subscription.Item>
Registers a plan inside <Subscription.Root>. Renders nothing on its own — the
root component renders the pricing cards.
| Prop | Type | Default | Description |
| ------------- | ---------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
| type | "free" \| "single" \| "seat-based" \| "enterprise" | — | Required. Plan type |
| planId | string | first product ID or type | Unique plan identifier |
| title | string | from Creem product data | Plan display title |
| description | string | from Creem product data | Plan subtitle (rendered as Markdown) |
| contactUrl | string | — | "Contact sales" link. Required when type="enterprise". |
| recommended | boolean | false | Highlight as recommended plan |
| productIds | Partial<Record<RecurringCycle, string>> | — | Creem product IDs keyed by billing cycle. Required when type="single" or type="seat-based". |
Supported billing cycles: every-month, every-three-months,
every-six-months, every-year.
Subscription and Subscription.Item are aliases — use whichever reads better
in your markup.
<Product.Root>
Container for one-time or repeating product cards. Handles ownership tracking, upgrade transitions, and checkout.
| Prop | Type | Default | Description |
| ------------------- | --------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api | ConnectedBillingApi | — | Required. Backend function references |
| permissions | BillingPermissions | all enabled | Disable actions based on user role |
| transition | Transition[] | [] | Upgrade path rules between products |
| class/className | string | "" | Wrapper CSS class |
| layout | "default" \| "single" | "default" | Card layout mode |
| styleVariant | "legacy" \| "pricing" | "legacy" | Visual style variant |
| showImages | boolean | false | Show product images on cards |
| pricingCtaVariant | "filled" \| "faded" | "faded" | Call-to-action button style |
| successUrl | string | product's defaultSuccessUrl → current page | Override redirect after checkout. When omitted, uses the product's defaultSuccessUrl from Creem; if that is also unset, falls back to the current page. |
| onBeforeCheckout | (intent: CheckoutIntent) => Promise<boolean> \| boolean | — | Gate checkout (auth, terms, etc.). Return false to abort. |
| children | Snippet / ReactNode | — | <Product.Item> children |
Transition types:
type Transition =
| { from: string; to: string; kind: "direct" }
| { from: string; to: string; kind: "via_product"; viaProductId: string };<Product.Item>
Registers a product inside <Product.Root>.
| Prop | Type | Default | Description |
| ------------- | --------------------------- | ----------------------- | --------------------------------------------------------- |
| productId | string | — | Required. Creem product ID |
| type | "one-time" \| "recurring" | — | Required. One-time shows "Owned" badge after purchase |
| title | string | from Creem product data | Card display title |
| description | string | from Creem product data | Card subtitle (rendered as Markdown) |
Product and Product.Item are aliases.
<BillingPortal>
Button that opens the Creem customer billing portal. Auto-hides when the billing
entity has no Creem customer record, or when canAccessPortal is false.
| Prop | Type | Default | Description |
| ------------------- | ----------------------- | ------------------ | --------------------------------------------------------- |
| api | ConnectedBillingApi | — | Required. Backend function references |
| permissions | BillingPermissions | all enabled | Control portal access (e.g. { canAccessPortal: false }) |
| class/className | string | "" | Button CSS class |
| children | Snippet / ReactNode | "Manage billing" | Custom button label |
Presentational components
Lower-level building blocks for custom layouts. These do not call Convex directly — pass data and callbacks as props.
<PricingSection>
Renders a grid of pricing cards with an optional billing cycle toggle.
| Prop | Type | Description |
| ----------------------- | ------------------------- | ------------------------------------- |
| plans | UIPlanEntry[] | Plan definitions |
| snapshot | BillingSnapshot \| null | Current billing state |
| selectedCycle | RecurringCycle | Active billing cycle |
| products | ConnectedProduct[] | Product data for price resolution |
| subscriptionProductId | string \| null | Currently subscribed product |
| subscriptionStatus | string \| null | Subscription status |
| units | number | Seat count |
| showSeatPicker | boolean | Show quantity picker |
| subscribedSeats | number \| null | Current seat count |
| isGroupSubscribed | boolean | Whether group has active subscription |
| disableCheckout | boolean | Disable checkout buttons |
| disableSwitch | boolean | Disable plan switch buttons |
| disableSeats | boolean | Disable seat controls |
| onCycleChange | (cycle) => void | Billing cycle change handler |
| onCheckout | (payload) => void | Checkout handler |
| onSwitchPlan | (payload) => void | Plan switch handler |
| onUpdateSeats | (payload) => void | Seat update handler |
<PricingCard>
A single plan card with price, description, and action button. Same props as
<PricingSection> for a single plan (see source for full list).
<BillingToggle>
Billing cycle segment control (e.g. Monthly / Yearly).
| Prop | Type | Description |
| --------------- | ------------------ | ---------------- |
| cycles | RecurringCycle[] | Available cycles |
| value | RecurringCycle | Selected cycle |
| onValueChange | (cycle) => void | Change handler |
| className | string | CSS class |
<CheckoutButton>
Styled checkout button. Supports both onCheckout callback and href link
modes.
| Prop | Type | Description |
| ------------ | ----------------------- | ---------------------------------- |
| productId | string | Product ID |
| href | string | Link mode: direct URL |
| disabled | boolean | Disable button |
| className | string | CSS class |
| onCheckout | (payload) => void | Callback mode: { productId } |
| children | Snippet / ReactNode | Button label (default: "Checkout") |
<OneTimeCheckoutButton>
Same as <CheckoutButton> with default label "Buy now".
<CustomerPortalButton>
Styled button for opening the customer billing portal.
| Prop | Type | Description |
| -------------- | ----------------------- | ---------------------------------------- |
| href | string | Link mode: direct URL |
| disabled | boolean | Disable button |
| className | string | CSS class |
| onOpenPortal | () => void | Callback mode |
| children | Snippet / ReactNode | Button label (default: "Manage billing") |
<BillingGate>
Conditionally renders children based on available billing actions.
| Prop | Type | Description |
| ----------------- | -------------------------------------- | --------------------------------------- |
| snapshot | BillingSnapshot \| null | Current billing state |
| requiredActions | AvailableAction \| AvailableAction[] | Actions that must be available |
| children | Snippet / ReactNode | Rendered when all actions are available |
| fallback | Snippet / ReactNode | Rendered otherwise |
<CheckoutSuccessSummary>
Displays a success banner after checkout. Parses Creem query params automatically.
| Prop | Type | Description |
| ----------- | ----------------------- | ------------------------------------------------------------ |
| params | CheckoutSuccessParams | Manual params (overrides URL parsing) |
| search | string | Query string to parse (defaults to window.location.search) |
| className | string | CSS class |
React also exports a useCheckoutSuccessParams() hook that returns the parsed
params directly.
<TrialLimitBanner>
Shows a trial expiration notice.
| Prop | Type | Description |
| ------------- | ------------------------- | ----------------------- |
| snapshot | BillingSnapshot \| null | Current billing state |
| trialEndsAt | string \| null | Override trial end date |
| className | string | CSS class |
<ScheduledChangeBanner>
Shows a cancellation-scheduled notice with optional "Undo" button.
| Prop | Type | Description |
| ----------- | ------------------------- | ------------------------------------------------- |
| snapshot | BillingSnapshot \| null | Current billing state |
| isLoading | boolean | Loading state for resume button |
| onResume | () => void | Resume handler (shows "Undo cancellation" button) |
| className | string | CSS class |
<PaymentWarningBanner>
Shows a warning for pending, refunded, or partially refunded payments.
| Prop | Type | Description |
| ----------- | ------------------------- | --------------------- |
| snapshot | BillingSnapshot \| null | Current billing state |
| payment | PaymentSnapshot \| null | Override payment data |
| className | string | CSS class |
<OneTimePaymentStatusBadge>
Inline status badge for one-time payments.
| Prop | Type | Description |
| ----------- | ----------------------------------------------------------- | -------------- |
| status | "pending" \| "paid" \| "refunded" \| "partially_refunded" | Payment status |
| className | string | CSS class |
Troubleshooting
Webhooks not receiving events Verify your Creem dashboard webhook URL
matches <CONVEX_SITE_URL>/creem/events. Check that CREEM_WEBHOOK_SECRET
matches the signing secret in Creem. Check the Convex dashboard logs for
verification errors.
Products not syncing Run npx convex run billing:syncBillingProducts after
setting up webhooks. Ensure CREEM_API_KEY is set and the key has read access
to products.
Widgets rendering unstyled Ensure both Tailwind CSS v4 and
@import "@mmailaender/convex-creem/styles" are in your CSS entry point. The
styles import must come after the Tailwind import.
Checkout URL missing from response The Creem API returned no checkout URL. Verify the product ID exists and is active in your Creem dashboard. Check the Convex dashboard logs for the full error.
Entity/org billing not scoping correctly Ensure billingEntityId is
returned from getUserInfo. If omitted, userId is used as the billing entity.
Verify that checkout metadata includes convexBillingEntityId by checking
webhook logs.
"Customer not found" when opening billing portal The customer record is created on first checkout. If the user hasn't completed a checkout yet, there's no customer to link to the portal.
