One-time products
Sell single-purchase items with ownership checks via the products block of pricingConfig.
Non-recurring purchases (credit packs, add-ons, lifetime deals) declared in the products block of pricingConfig (packages/config/src/pricing.ts). They render and check out only when config.payment.enabled and pricingConfig.products.enabled are both true. pricingConfig.products ships { enabled: false, items: [] } - set enabled: true and add ProductItems to start selling.
Unlike plans, products grant ownership rather than a recurring entitlement - they never change plan. pricingConfig is a separate named export from @repo/config, not a key on config.
Declaring products
Each entry in pricingConfig.products.items is a ProductItem. The example below shows the full shape; the generated config ships an empty items: [] for you to fill. Set products.enabled: false to hide all one-time products without deleting the items.
// packages/config/src/pricing.ts
products: {
enabled: true,
items: [
{
id: "addon_api",
name: "pricing.products.item_4.name", // i18n key
description: "pricing.products.item_4.description",
stripePriceId: "price_...",
polarProductId: "...",
amounts: { USD: 99, EUR: 89 }, // major units, keyed by currency
purchaseLimit: 5, // max per entity
maxStock: 10, // global cap
},
],
},ProductItem fields
| Key | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable identifier used in code and storage. |
name | string | yes | Display name (use an i18n key, as in the example). |
description | string | no | Description (i18n key, as in the example). |
amounts | Record<string, number> | yes | Price per currency code in major units (e.g. { USD: 99 }). |
stripePriceId | string | no | Stripe Price ID for this product. |
polarProductId | string | no | Polar Product ID for this product. |
credits | number | no | Credits granted on purchase - turns it into a credit pack. |
purchaseLimit | number | no | Max units a single entity may own. undefined = unlimited. |
maxStock | number | no | Global stock cap across all buyers. undefined = unlimited. |
restrictToPlans | string[] | no | Only allow purchase for entities on these plan IDs. |
tracked | boolean | no | Record ownership in ownedProducts. Implicitly true when purchaseLimit or maxStock is set. |
featured | boolean | no | Highlight the item in product listings. |
Provider refs are direct fields (stripePriceId / polarProductId), so one product can map to either provider without code changes.
tracked: true, purchaseLimit, or maxStock is set (isProductTracked). An untracked product - say a credit pack that only sets credits - grants credits on purchase but writes no ownedProducts row, so ownsProduct() returns false for it. Set tracked: true if you need to query ownership.Ownership model
Tracked purchases are recorded in the ownedProducts table, keyed by billing entity - a user or an active organization, resolved per session by getBillingScope() (returns "user" unless config.tenancy.multiTenant and billingScope: "organization").
- One row per
(entityType, entityId, productId)holds the ownedquantity. purchaseLimitcaps units per entity;maxStockcaps units globally.- Webhooks keep
ownedProductsin sync after checkout - see Background jobs.
Limits are checked at checkout and re-checked when the webhook fires (parallel checkout tabs can slip past the first guard). An over-limit purchase that reaches the webhook is not auto-reversed - it logs an error for manual refund. An over-maxStock purchase is processed anyway with a warning.
Querying ownership
Construct Billing from a session, then await the entitlement checks. All methods are async and read local state kept in sync by provider webhooks.
import { Billing } from "@repo/payments";
const billing = await Billing(session);
await billing.ownsProduct("addon_api");| Method | Returns | Notes |
|---|---|---|
ownsProduct(id) | Promise<boolean> | true when owned quantity ≥ 1. |
getProductQuantity(id) | Promise<number> | 0 if none owned. |
getOwnedProductsWithQuantities() | Promise<OwnedProduct[]> | { productId, quantity } per tracked product. |
getOwnedProductIds() | Promise<string[]> | IDs of tracked products owned. |
Frequently asked questions
Are purchases owned by the user or the organization?
Whichever billing scope is active. Billing(session) routes via getBillingScope() - to the active organization when org-scoped, otherwise the user - so seats and add-ons follow team billing.
How do I make a product grant credits?
Set the credits field on a ProductItem (e.g. credits: 100) and that product becomes a credit pack - the credits land on the buyer's balance at checkout. See Credits.
How do I enforce a one-per-customer limit or run out of stock?
Set purchaseLimit: 1 for a per-entity cap and maxStock for a global cap. Use restrictToPlans to gate purchase behind a subscription tier.
Plans & Pricing
Configure subscription tiers and per-currency pricing across monthly, yearly, and lifetime billing intervals using pricingConfig.
Credits
Add usage-based credits to your SaaS with per-plan grants, recurring re-grants, custom top-ups, and automatic refills scoped to users or organizations.