Stripe checkout without a product catalogue
Creating a Stripe product and copying its price ID into an environment variable is the documented path and it is the wrong default for most sites. Define the charge at redirect time instead, from one source of truth in your code.
In short
- price_data lets you define a charge inline, so a price never needs to exist in the dashboard before you can sell it.
- One pricing module read by both the display and the charge means the number a visitor sees is provably the number Stripe takes.
- Set tax_behavior explicitly. The default is not what you want in the EU.
- The dashboard catalogue is still right when finance owns pricing. It is wrong when engineering does.
The standard Stripe integration goes: create a Product in the dashboard, create a Price under it, copy the price ID into an environment variable, deploy. We did that, and then we hit the failure it guarantees.
The failure it guarantees
Our checkout stopped working. Not with an error anyone would notice in development, because in development the environment variables were set. In production they were not, because someone had rotated keys and recreated the products, and the new price IDs never made it into the deploy.
This is not a mistake so much as a structural property. The price ID couples a deployment to a row in a database owned by a third party, in a different console, with no type checking and no test that fails when it drifts. It works until it silently does not.
Defining the charge at redirect time
Stripe Checkout accepts price_data in place of a price ID. You describe the charge in the request and Stripe creates whatever it needs behind the scenes. No dashboard product, no environment variable, no drift:
export async function createCheckoutSession({ planId, requestedCurrency }) {
const { currency, unitAmount, name } = getPlanCharge(planId, requestedCurrency)
return getStripeClient().checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price_data: {
currency: currency.toLowerCase(),
unit_amount: unitAmount,
tax_behavior: 'exclusive',
recurring: { interval: 'month' },
product_data: { name },
},
quantity: 1,
},
],
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
metadata: { planId, currency },
})
}One source of truth
The point of the change is not that it saves a trip to the dashboard. It is that the price becomes a value in your codebase, which means exactly one module can own it and everything else can read it:
// Amounts in the smallest currency unit, tax-exclusive.
export const planPricing = {
mainichi: {
name: 'Mainichi Plan',
unitAmount: { EUR: 160200, USD: 174700 },
},
pro: {
name: 'PRO Plan',
unitAmount: { EUR: 272500, USD: 297100 },
},
}
export function formatPlanPrice(planId, currency) {
return (planPricing[planId].unitAmount[currency] / 100).toLocaleString('en-US')
}Four things to get right
1. Amounts are integers in the smallest unit
160200 is 1,602.00 euro. Never store prices as floats and never let a currency conversion happen implicitly. Keep the integer, format it for display, and only ever divide at the last moment.
2. Set tax_behavior explicitly
With automatic_tax enabled, Stripe needs to know whether your amount already includes tax. Leaving it unset means you are relying on an account default that whoever set up the account may not have thought about. For a European business selling B2B, exclusive is almost always what you mean, and being explicit costs one line.
3. Never take the amount from the client
This is the security consequence of the pattern and it is the one people get wrong. Inline pricing means the amount is decided in the request, so the request must be built on the server from an identifier, never from a number the browser supplied.
export function getPlanCharge(planId, requestedCurrency) {
const currency = requestedCurrency === 'EUR' ? 'EUR' : 'USD'
const plan = planPricing[planId]
const unitAmount = plan?.unitAmount[currency]
if (!unitAmount) {
throw new Error('The selected subscription plan is not configured')
}
return { currency, unitAmount, name: plan.name }
}4. Put the identifier in metadata
Without a product catalogue you lose the ability to group revenue by product in the dashboard. Writing the plan id and currency into session metadata gets that back, and it is what your webhook handler will want anyway when it has to work out what somebody actually bought.
When the catalogue is still right
This is a default, not a rule. Dashboard products are the better choice when non-engineers own pricing and need to change it without a deploy, when you have a genuinely large or dynamic catalogue, when you are running experiments through Stripe's own tooling, or when you need the reporting that hangs off real Product objects.
For a handful of plans that change a few times a year and are rendered by the same codebase that charges for them, the catalogue is a second source of truth you have to keep synchronised by hand. That is a cost with no matching benefit, and it fails quietly, which is the worst way for anything touching money to fail.