The billing entity
Every subscription, order, and credit balance belongs to one billing entity, identified by theentityId 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:
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
entityIdfield, taken from the checkout that created the row (convexBillingEntityIdin 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.
- Scoped to the entity: subscriptions and orders in
uiModel,snapshot,getBillingModel,getBillingSnapshot,subscriptions.list/listAllandorders.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.portalUrlthrows aConvexErrorwithcode: "shared-customer"(the Creem portal manages everything of the customer), and<BillingPortal>and<PaymentRecoveryButton>showportal.sharedCustomer.customers.updateBillingEmailreturns{ status: "shared-customer" }, and<BillingEmail>showsbillingEmail.sharedCustomer. - Still customer-wide:
transactions.searchand<BillingHistory>list the Creem customer’s transactions, and Customer Credits keep one balance per Creem customer.
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 noentityId. Until the backfill below gives them one:
- A row counts for the entity its metadata
convexBillingEntityIdrecords, 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.
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
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 oneConnectedBillingApi 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
- Wire the entity and export the API: Quickstart
- Enforce access on the server: Entitlements
- Custom auth, RBAC, and webhook middleware: Advanced
- Exact signatures and props: Reference