Skip to content

mmailaender/convex-creem

Repository files navigation

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

Complete these steps to use the billing API from your Convex functions. No frontend framework required.

1. Install

npm install @mmailaender/convex-creem

2. 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:syncBillingProducts

This 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/react

Svelte

npm install @ark-ui/svelte

8. 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) vs className= (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 ConnectedBillingApi object 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.update with the new units so 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 your resolve callback to authenticate and determine the entityId. The billing entity is derived from the authenticated session, never from client input. See Step 4: Configure billing and the creem.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 permissionsBillingPermissions 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:

  1. Unauthenticated user clicks "Subscribe" → onBeforeCheckout fires
  2. Your callback saves the intent via pendingCheckout.save(intent), opens your sign-in dialog/redirect, and returns false
  3. After sign-in, the Convex query re-fires → model.user becomes non-null
  4. 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 needed

Custom 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/svelte or @mmailaender/convex-creem/react
  • CSS class prop: class in Svelte, className in React
  • Children: Svelte Snippet / React ReactNode
  • 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.

Releases

Packages

Contributors

Languages