Skip to main content
Three ideas explain most of the component’s behaviour. Read this once and the rest of the documentation stops holding surprises.

The billing entity

Every subscription, order, and credit balance belongs to one billing entity, identified by the entityId your resolver returns. It is the first decision you make and the most awkward one to change later. For personal billing, return the user ID. For organization or team billing, return the org ID. Checkout metadata and webhook resolution follow automatically, with no other code changes:
Return null for an unauthenticated caller. Anything thrown from the resolver is treated as a real failure: it is logged and rethrown instead of quietly degrading a signed-in user to the logged-out pricing page. (Throwing the exported CreemNotAuthenticatedError is also accepted as an anonymous signal.)

One Creem customer, several entities

Creem keeps one customer per store and email address. When the same person checks out for two billing entities, for example their personal account and an organization, both entities map to the same Creem customer. The component therefore records an owner on every subscription and order:
  • The owner is the entityId field, taken from the checkout that created the row (convexBillingEntityId in its metadata, which Creem copies onto the subscription and the order).
  • It is write-once. Any verified webhook, even one that arrives out of order, can fill a missing owner, but nothing changes an existing one; a conflicting claim is logged and ignored.
  • Webhook side effects follow what the stored row accepted after the timestamp-guarded write: its owner and customer decide the customer mapping (and any re-pointing), and its status and start date decide whether app-owned plans end. A stale or conflicting event cannot trigger them through its own metadata, customer, or status.
What that scopes:
  • Scoped to the entity: subscriptions and orders in uiModel, snapshot, getBillingModel, getBillingSnapshot, subscriptions.list/listAll and orders.list, read through an index on the owner. The subscription commands (update, cancel, resume, pause, cancelScheduledUpdate) reject another entity’s subscription ID like an unknown one, and period-end updates are checked again when they apply.
  • Refused while the customer is shared: customers.portalUrl throws a ConvexError with code: "shared-customer" (the Creem portal manages everything of the customer), and <BillingPortal> and <PaymentRecoveryButton> show portal.sharedCustomer. customers.updateBillingEmail returns { status: "shared-customer" }, and <BillingEmail> shows billingEmail.sharedCustomer.
  • Still customer-wide: transactions.search and <BillingHistory> list the Creem customer’s transactions, and Customer Credits keep one balance per Creem customer.
A customer counts as shared for an entity when another entity is or ever was mapped to it, or owns one of its subscriptions or orders. The component keeps every mapping in a customerEntityHistory table, so re-pointing an entity to a new customer does not erase that evidence. creem.customers.isShared(ctx, { entityId }) exposes the check.

Upgrading an existing deployment

Rows written before this version have no entityId. Until the backfill below gives them one:
  • A row counts for the entity its metadata convexBillingEntityId records, and a claim naming another entity counts as evidence that the customer is shared.
  • A row that records no entity counts for an entity only while no other entity has touched the customer; otherwise it belongs to no entity.
  • Only the first 50 rows without an owner per table and customer are read. Until the backfill has run, an entity with more such rows can miss subscriptions and orders (and the access they grant) beyond those 50, and a customer with more than 50 counts as shared, so its untagged rows stay hidden and its portal and billing email are refused.
So run the backfill right after deploying. Run the component’s backfillBillingEntityTags mutation once after deploying. For each customer mapping it records the mapping in the history, then copies every recorded convexBillingEntityId into entityId, and only after that assigns the mapping’s entity to the rows that record none, when no other entity has ever been mapped to the customer or owns one of its rows. Ambiguous rows keep no owner. Paging contract: start without a cursor and pass back the returned cursor until isDone; the result also reports how many rows got an owner (processed). Each call examines at most batchSize rows (default and maximum 25) and moves past a customer only after examining all of its rows, so a long run of ambiguous rows does not hide later ones. The backfill is complete when a call returns isDone: true; a later run from the start then reports processed: 0 in every call, which you can use to check it. Running the whole backfill again is safe. It pages with its own cursor because Convex does not allow paginate() inside a component. The row cap does not bound bytes: if rows carry very large metadata and a call fails on Convex’s read limit, call again with the same cursor and a smaller batchSize.
convex/billing.ts
One case cannot be resolved: if an entity was re-pointed away from a customer before this version and left untagged rows there, nothing records that it was ever mapped, so those rows count for the entity that is still mapped to that customer. By default, the component tracks the entity’s active app-owned plan itself. Apps that own their plan assignment can override that from the resolver — see Advanced → Resolver plan overrides.

The billing state model

Billing state comes from four places. Three of them store data. The fourth is derived from the other three, and that distinction is worth learning early: Write to the first three through checkout, webhooks, and creem.appPlans.activate. Do not treat access as a source of truth. It is recomputed on every read.
snapshot (generated by creem.api({ resolve })) and creem.getBillingSnapshot(ctx, { entityId }) both return this shape. Everything is synced into Convex, so reading it is a local reactive query with no Creem API round-trip.

Easily confused pairs

The connected API contract

Widgets hold no Convex function references of their own. Every backend call goes through one ConnectedBillingApi object they read from the provider, and its fields decide what the UI can do. Export and wire only what your product should allow. connectCreemApi maps the conventional convex/billing.ts export names onto that shape for you. Every reference is typed by function kind, args, and return type, so a missing or mis-wired export is a compile error rather than a blank widget:
Building the object by hand still works when you wrap the generated functions in your own RBAC actions. See Advanced → Custom auth and RBAC.

Where to go next