This chapter follows Customers in Stripe.
Checkout Sessions and subscriptions reference Price ids (price_...), not free-form amounts in the client. That keeps your catalog auditable and stops tampered browsers from changing what someone pays.
Product versus Price
A Product is the thing you sell—name, description, images, tax category. A Price attaches billing terms: unit amount, currency, and whether billing is one-time or recurring.
One Product can have many Prices (monthly vs annual, USD vs EUR, legacy vs current rate).
import { stripe } from '../stripe-client.js';
const product = await stripe.products.create({ name: 'Pro plan', description: 'Full access to the app',});
const monthly = await stripe.prices.create({ product: product.id, unit_amount: 2000, currency: 'usd', recurring: { interval: 'month' }, lookup_key: 'pro_monthly_usd',});
const lifetime = await stripe.prices.create({ product: product.id, unit_amount: 19900, currency: 'usd', lookup_key: 'pro_lifetime_usd',});
console.log({ productId: product.id, monthly: monthly.id, lifetime: lifetime.id });Run seed scripts against test mode first. Production catalog changes usually go through review and Dashboard or infrastructure-as-code workflows.
One-time and recurring prices
| Field | One-time | Recurring |
|---|---|---|
recurring |
Omitted | { interval: 'month' | 'year' | ... } |
Used in Checkout mode |
payment |
subscription |
| Invoices | Created per Checkout payment | Generated each cycle on Subscription |
Trials and usage-based components attach at subscription or Price level; see Subscriptions in Stripe.
Why prices are immutable
You cannot change unit_amount or currency on an existing Price. Stripe treats a Price as a historical record—active subscriptions must keep billing the terms they started on.
To raise prices:
- Create a new Price on the same (or new) Product.
- Point new Checkout Sessions at the new Price id.
- Migrate existing subscribers with proration rules (subscription update APIs or Customer Portal).
Deactivate old Prices with active: false so they no longer appear in Dashboard pickers but existing subscriptions keep working.
Lookup keys and catalog resolution
lookup_key is a stable string you choose (for example pro_monthly_usd). At runtime, resolve it to the current Price id so deploys do not hard-code price_... strings:
const prices = await stripe.prices.list({ lookup_keys: ['pro_monthly_usd'], active: true, limit: 1,});const priceId = prices.data[0]?.id;Keep lookup keys unique per mode. Document which key each environment uses in config, not in the client.
Dashboard, API, and tax behavior
Many teams create Products in the Dashboard during early development, then move to seed scripts or Stripe’s product export/import for staging parity.
Tax behavior on a Price (exclusive, inclusive, or unspecified) interacts with Stripe Tax. Enabling automatic tax without registrations collects nothing while looking enabled—a common misconfiguration. Configure registrations before turning on automatic_tax in Checkout.
Archiving and displaying catalog in your app
List active Products with expanded default prices for a pricing page:
const products = await stripe.products.list({ active: true, limit: 20 });const prices = await stripe.prices.list({ active: true, product: products.data[0].id });Your marketing site can cache this JSON; payment flows should still verify Price ids on the server when creating Checkout Sessions.
Check your understanding
Question 1 of 6
0 of 6 checked