Products & prices
The unified Product and Price models, what a product means per provider, and which identifier a checkout accepts.
products.list and products.get return a normalized Product with its prices already attached, so
you can render a pricing page from one call without knowing how the provider models its catalog.
The models
id?string
The provider identifier of the product, coerced to a string.
stringname?string
Display name.
stringdescription?string
Display description, when the provider has one.
stringprices?Price[]
The product's prices. May be empty when the provider has no price attached.
Price[]raw?unknown
The untouched provider payload.
unknownEach Price:
id?string
The provider identifier of the price.
stringcheckoutRef?string
The identifier checkouts.create accepts for this price. Provider-dependent — see the table below.
stringtype?'one_time' | 'recurring'
Whether buying this price starts a subscription.
'one_time' | 'recurring'model?'fixed' | 'custom' | 'metered' | 'seat_based' | 'tiered'
The pricing model. Only fixed is fully normalized.
'fixed' | 'custom' | 'metered' | 'seat_based' | 'tiered'amount?number | null
Amount in the currency's minor units (e.g. 2499 = $24.99). null unless model is fixed.
number | nullcurrency?string
Lowercase ISO 4217 code, e.g. usd.
stringinterval?'day' | 'week' | 'month' | 'year'
Billing interval for recurring prices.
'day' | 'week' | 'month' | 'year'intervalCount?number
How many intervals per billing cycle.
numbertrialDays?number
Trial length in days. Only set when the provider expresses the trial in days or weeks — month/year trials are left undefined; read raw.
numberraw?unknown
The untouched provider payload.
unknownWhat a “product” is per provider
This is the single most important mapping in the SDK. Providers disagree about which object is the
purchasable unit, and revenue-sdk always normalizes to the purchasable one.
| Provider | Unified Product is… |
Product.id |
Price.checkoutRef is… |
|---|---|---|---|
| Polar | a Polar product | Polar product ID | the product ID |
| Lemon Squeezy | a Lemon Squeezy variant | variant ID (as a string) | the variant ID |
| Stripe | a Stripe product | Stripe product ID | the price ID (price_…) |
| Paddle | a Paddle product | Paddle product ID | the price ID (pri_…) |
| Dodo Payments | a Dodo product | Dodo product ID | the product ID |
const { items: products } = await client.products.list();
for (const product of products) {
for (const price of product.prices) {
// Correct across all five providers.
console.log(product.name, price.checkoutRef, price.amount, price.currency);
}
}
The same rule applies to subscriptions.changePlan({ product }) — the product parameter is a
checkoutRef, not a Product.id.
Lemon Squeezy: products are variants
Lemon Squeezy’s own “product” is a container; the thing a checkout can actually sell is a variant.
revenue-sdk therefore maps Product to a variant and reads the variant’s current price from its
price-model relationship. (The /v1/prices collection is an append-only price history and must not be
used for the current price.)
The practical consequences:
- One
Productalways has exactly onePrice. - All Lemon Squeezy IDs are numeric on the wire and are coerced to strings by the SDK.
- Prices carry the store’s currency, since a variant price doesn’t carry its own.
Only fixed prices are fully normalized
Price.model classifies the pricing model, but only fixed gets a normalized amount. For every other
model amount is null and the details live in raw:
model |
Normalized amount |
Where the detail lives |
|---|---|---|
fixed |
yes | amount + currency |
custom |
null |
raw (pay-what-you-want / custom unit amount) |
metered |
null |
raw (usage aggregation, meter configuration) |
seat_based |
null |
raw (per-seat configuration) |
tiered |
null |
raw (tier/graduated/volume/package tables) |
if (price.model === 'fixed' && price.amount !== null) {
render(formatMoney(price.amount, price.currency));
} else {
render('Contact us');
}
Monthly and yearly are separate purchasable units
Across all five providers, a monthly plan and a yearly plan are different purchasable units — a different Polar product, a different Lemon Squeezy variant, a different Stripe/Paddle price, a different Dodo product. There is no “interval” switch on a single reference.
That means a monthly/yearly toggle on your pricing page is a choice between two checkoutRef values,
and upgrading from monthly to yearly is a subscriptions.changePlan to the other checkoutRef:
const yearly = product.prices.find((price) => price.interval === 'year');
await client.subscriptions.changePlan({
id: subscription.id,
product: yearly!.checkoutRef,
prorationBehavior: 'prorate',
});
Reading a product
const product = await client.products.get({ id: 'PRODUCT_ID' });
products.get takes the provider’s product identifier (Product.id). On Lemon Squeezy that is the
variant ID; on Dodo Payments the product ID doubles as the checkoutRef.
Lists are cursor-paginated — see Pagination:
for await (const product of client.products.listAll({ limit: 100 })) {
console.log(product.id, product.name);
}