Most articles about Stripe multi-currency pricing stop at “enable it in the Dashboard and you are done”. That works until your first JPY invoice is off by one unit, a Kuwaiti customer gets a rejected amount, or your EU prices suddenly display VAT-exclusive while your marketing site promises tax included.
This walkthrough covers the part that usually gets skipped: how to model prices in Postgres, how to detect the buyer’s region, how to cache FX rates, and how to avoid the rounding and tax traps that break checkout in production. All examples use Node.js with the official stripe SDK. See finexer.com for their take.
Quick answer: how Stripe multi-currency pricing actually works
Stripe gives you three distinct mechanisms, and choosing the wrong one is the root cause of most pricing bugs:
- Automatic conversion (presentment in your settlement currency): you charge 29 USD, the card network converts for the cardholder. You control nothing about the displayed amount.
- Multi-currency Prices via
currency_options: one Price object holds a fixed amount per currency (29 USD, 29 EUR, 4500 JPY). You control every digit. - Adaptive Pricing in Checkout: Stripe auto-localizes your amount using its own FX rate. Fast to ship, but you do not control price psychology or round numbers.
| Approach | Who sets the amount | Best for | Main drawback |
|---|---|---|---|
| Single currency + network conversion | Card network | MVPs, very low international volume | Ugly displayed amounts, higher cart abandonment abroad |
currency_options on a Price |
You | SaaS with published pricing pages | You own FX drift and price maintenance |
| Adaptive Pricing in Checkout | Stripe | One-off purchases, long tail markets | Displayed price will not match your pricing page |
For a subscription SaaS with a public pricing page, option 2 is almost always correct. Option 3 is a good fallback for the 100+ currencies you do not want to maintain by hand.

Step 1: Decide your presentment currencies before writing code
Do not localize into 80 currencies because a tool can generate them. Every presentment currency adds a price to maintain, a rounding rule, a tax behavior and a support ticket surface.
A sane starting set for a B2B SaaS:
- USD as your anchor and fallback for everything unmapped
- EUR and GBP for Europe
- CAD, AUD for English-speaking markets
- JPY only if you actually have Japanese demand (zero-decimal rules apply)
Then let Adaptive Pricing or plain USD cover the rest. Fewer currencies, fewer bugs.
Step 2: The Postgres schema (minor units only, never floats)
The single most common defect in multi-currency billing is storing amounts as numeric or, worse, float, then multiplying by 100 at checkout. Store integer minor units, exactly as Stripe expects them, and never compute a chargeable amount at request time.
-- Catalog
create table plans (
id bigserial primary key,
code text not null unique, -- 'pro_monthly'
stripe_product text not null, -- 'prod_...'
billing_period text not null check (billing_period in ('month','year')),
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Reference data: how many minor units in one major unit, and the
-- smallest increment Stripe will accept for this currency.
create table currencies (
code char(3) primary key, -- 'usd', 'jpy', 'kwd'
exponent smallint not null, -- 2, 0, 3
min_increment integer not null default 1, -- 10 for 3-decimal currencies
symbol text not null,
display_position text not null default 'before'
);
-- One row per (plan, currency). This is your source of truth,
-- not the Stripe Dashboard.
create table plan_prices (
id bigserial primary key,
plan_id bigint not null references plans(id),
currency char(3) not null references currencies(code),
unit_amount_minor bigint not null check (unit_amount_minor >= 0),
tax_behavior text not null check (tax_behavior in ('inclusive','exclusive')),
stripe_price_id text, -- 'price_...'
fx_rate_used numeric(18,8), -- audit trail
fx_captured_at timestamptz,
effective_from timestamptz not null default now(),
effective_to timestamptz,
unique (plan_id, currency, effective_from)
);
create index plan_prices_live_idx
on plan_prices (plan_id, currency)
where effective_to is null;
-- FX cache. Rates are an input to price generation, never to checkout.
create table fx_rates (
base char(3) not null,
quote char(3) not null,
rate numeric(18,8) not null,
source text not null, -- 'ecb', 'openexchange'
fetched_at timestamptz not null default now(),
expires_at timestamptz not null,
primary key (base, quote, fetched_at)
);
create index fx_rates_lookup_idx on fx_rates (base, quote, fetched_at desc);
Why effective_from / effective_to matter
Stripe Price objects are immutable in their amount. When you reprice EUR from 29.00 to 31.00, you create a new Price and archive the old one. Your database must mirror that history, otherwise you cannot answer “what was this customer promised when they signed up?” during a dispute or a revenue recognition audit.

Step 3: Detect the user’s region without guessing wrong
Region detection should be a cascade, not a single signal. Order matters, from most authoritative to least:
- Explicit user choice stored on the account (a currency switcher in the UI). Always wins.
- Existing Stripe Customer currency. Once a Customer has an invoice or subscription, their currency is locked. Never show them a different one.
- Billing country already on file (from a previous address or tax ID).
- Edge geo header:
CF-IPCountryon Cloudflare,x-vercel-ip-countryon Vercel, or your CDN equivalent. Cheap and accurate enough. - Accept-Language as a weak hint only. A French speaker in Montreal should see CAD, not EUR.
- USD fallback.
const COUNTRY_TO_CURRENCY = {
US: 'usd', CA: 'cad', GB: 'gbp', AU: 'aud', NZ: 'aud', JP: 'jpy',
DE: 'eur', FR: 'eur', ES: 'eur', IT: 'eur', NL: 'eur', BE: 'eur',
IE: 'eur', PT: 'eur', AT: 'eur', FI: 'eur'
};
const SUPPORTED = new Set(['usd','eur','gbp','cad','aud','jpy']);
function resolveCurrency({ userPreference, stripeCustomerCurrency, billingCountry, req }) {
if (userPreference && SUPPORTED.has(userPreference)) return userPreference;
if (stripeCustomerCurrency) return stripeCustomerCurrency; // locked by Stripe
const country =
billingCountry ||
req.headers['cf-ipcountry'] ||
req.headers['x-vercel-ip-country'] ||
null;
const mapped = country ? COUNTRY_TO_CURRENCY[country.toUpperCase()] : null;
return mapped && SUPPORTED.has(mapped) ? mapped : 'usd';
}
Critical rule: the currency used for the pricing page is a display decision. The currency used at checkout must be re-resolved server side right before creating the Checkout Session, because the billing address collected by Stripe may contradict the IP. If the two disagree, the address wins for tax, and the currency stays as presented to avoid a bait-and-switch.
Step 4: Generate amounts with integer-safe rounding
Here is where tutorials hand-wave. Converting 29.00 USD at 0.9183 gives 26.6307 EUR. Shipping “26.63 EUR” on a pricing page looks amateurish and signals to buyers that you are converting on the fly. Round to a price ladder instead.
The decimal rules you must encode
| Currency group | Examples | Exponent | Stripe amount for 29 units | Constraint |
|---|---|---|---|---|
| Standard | USD, EUR, GBP, CAD, AUD | 2 | 2900 | Any integer |
| Zero-decimal | JPY, KRW, VND, CLP, XAF, XOF | 0 | 29 | Do not multiply by 100 |
| Three-decimal | BHD, JOD, KWD, OMR, TND | 3 | 29000 | Must be a multiple of 10 |
A JPY amount sent as 2900 instead of 29 is a 100x overcharge. A KWD amount of 29005 is simply rejected by the API. Encode both rules once, in a single helper, and never inline the conversion again.
// All math in integers. rate is a string to avoid float drift.
import Big from 'big.js';
const CURRENCY_META = {
usd: { exponent: 2, increment: 1, ladder: [900, 500, 0] }, // .99 / .95 / .00
eur: { exponent: 2, increment: 1, ladder: [900, 500, 0] },
gbp: { exponent: 2, increment: 1, ladder: [900, 500, 0] },
jpy: { exponent: 0, increment: 1, ladder: [80, 0] }, // x,x80 / round hundred
kwd: { exponent: 3, increment: 10, ladder: [0] }
};
function convertMinor(amountMinorBase, baseCode, quoteCode, rate) {
const base = CURRENCY_META[baseCode];
const quote = CURRENCY_META[quoteCode];
// Normalize to major units, convert, then re-expand to quote minor units.
const major = new Big(amountMinorBase).div(new Big(10).pow(base.exponent));
const converted = major.times(new Big(rate));
const minor = converted.times(new Big(10).pow(quote.exponent));
return BigInt(minor.round(0, Big.roundHalfUp).toString());
}
// Snap to a human-looking price that is still at or above the converted value.
function applyLadder(minorAmount, currencyCode) {
const meta = CURRENCY_META[currencyCode];
const unit = BigInt(10 ** meta.exponent);
const wholeUnits = minorAmount / unit;
const candidates = meta.ladder.map(tail => wholeUnits * unit + BigInt(tail));
const next = candidates.filter(c => c >= minorAmount).sort((a, b) => (a < b ? -1 : 1));
let chosen = next.length ? next[0] : (wholeUnits + 1n) * unit + BigInt(meta.ladder[0]);
// Enforce Stripe's increment constraint (10 for three-decimal currencies).
const inc = BigInt(meta.increment);
if (chosen % inc !== 0n) chosen = ((chosen / inc) + 1n) * inc;
return chosen;
}
Rounding up to the ladder, never down, protects your margin against the FX spread and Stripe’s conversion fee. Over a year of rate movement that single decision is worth more than most pricing experiments.
Step 5: Cache FX rates, and separate “rate” from “price”
Rates change every second. Your prices must not. The correct architecture:
- A nightly job fetches rates from a single authoritative source and writes to
fx_rateswith a 24 hourexpires_at. - A repricing job runs on a slow cadence (monthly or quarterly) and only proposes a new price when drift crosses a threshold, for example 5 percent.
- Repricing proposals go to a human or to a feature flag. Never auto-publish a new public price.
- Checkout reads
plan_pricesonly. It never touchesfx_rates.
async function getRate(db, base, quote) {
if (base === quote) return '1';
const { rows } = await db.query(
`select rate from fx_rates
where base = $1 and quote = $2 and expires_at > now()
order by fetched_at desc limit 1`,
[base, quote]
);
if (rows.length) return rows[0].rate;
const fresh = await fetchRateFromProvider(base, quote);
await db.query(
`insert into fx_rates (base, quote, rate, source, expires_at)
values ($1, $2, $3, $4, now() + interval '24 hours')`,
[base, quote, fresh.rate, fresh.source]
);
return fresh.rate;
}
async function shouldReprice(db, planId, currency, baseCurrency) {
const { rows: [live] } = await db.query(
`select unit_amount_minor, fx_rate_used from plan_prices
where plan_id = $1 and currency = $2 and effective_to is null`,
[planId, currency]
);
if (!live) return true;
const current = Number(await getRate(db, baseCurrency, currency));
const drift = Math.abs(current - Number(live.fx_rate_used)) / Number(live.fx_rate_used);
return drift > 0.05; // 5 percent threshold
}
Add a buffer. If your anchor currency is USD and you publish EUR prices quarterly, build in 2 to 4 percent of headroom. Between FX drift and Stripe’s conversion fee of roughly 1 to 2 percent on cross-currency settlement, a price converted at spot is already underwater on day one.

Step 6: Create the Stripe Price with currency_options
One Product, one Price per billing interval, multiple currencies inside it. This keeps your Checkout and Subscription code currency-agnostic. The piece 多币种结算| Stripe 文档 makes a good next read.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function publishPrice(db, planId) {
const { rows } = await db.query(
`select pp.currency, pp.unit_amount_minor, pp.tax_behavior, p.stripe_product, p.billing_period
from plan_prices pp join plans p on p.id = pp.plan_id
where pp.plan_id = $1 and pp.effective_to is null`,
[planId]
);
const base = rows.find(r => r.currency === 'usd');
const others = rows.filter(r => r.currency !== 'usd');
const currency_options = {};
for (const r of others) {
currency_options[r.currency] = {
unit_amount: Number(r.unit_amount_minor),
tax_behavior: r.tax_behavior
};
}
const price = await stripe.prices.create({
product: base.stripe_product,
currency: 'usd',
unit_amount: Number(base.unit_amount_minor),
tax_behavior: base.tax_behavior,
recurring: { interval: rows[0].billing_period },
currency_options,
metadata: { plan_id: String(planId), managed_by: 'billing-service' }
}, { idempotencyKey: `price:${planId}:${Date.now()}` });
await db.query(
`update plan_prices set stripe_price_id = $1
where plan_id = $2 and effective_to is null`,
[price.id, planId]
);
return price;
}
Two gotchas with currency_options
- It is not expanded by default when you retrieve a Price. You must pass
expand: ['currency_options']or the field comes back undefined, which leads to “my EUR price disappeared” bug reports. This is a well known friction point in the Stripe SDKs. - Every currency you list must be enabled on your account and supported by your settlement setup. Listing a currency you cannot settle means the charge converts at Stripe’s rate with the conversion fee applied.
const price = await stripe.prices.retrieve(priceId, {
expand: ['currency_options']
});
Step 7: Tax behavior is the pitfall nobody warns you about
Tax behavior tells Stripe whether unit_amount already contains tax. It is immutable once set to inclusive or exclusive. You get exactly one chance per Price.
The practical consequence for multi-currency SaaS:
| Market | Customer expectation | tax_behavior | Note |
|---|---|---|---|
| United States | Tax added at checkout | exclusive |
Sales tax varies by state and ZIP |
| EU / UK consumer (B2C) | Displayed price includes VAT | inclusive |
Legally required for consumers |
| EU B2B with valid VAT ID | Net price, reverse charge | exclusive |
Collect and validate the VAT ID |
| Japan | Consumption tax usually shown included | inclusive |
Combine with zero-decimal rounding |
Because tax_behavior can be set per entry inside currency_options, you can run an inclusive EUR price and an exclusive USD price from the same Price object. That is the single most useful and least documented feature in Stripe multi-currency pricing.
With inclusive pricing, a 29.00 EUR headline price at 21 percent VAT nets you 23.97 EUR, while at 19 percent it nets 24.37 EUR. Your revenue per customer now varies by country even though the sticker price is identical. Model this in your financial plan before you commit to inclusive pricing across the EU.

Step 8: Creating the Checkout Session
With a multi-currency Price, you do not pick a different price ID per currency. You pass the currency on the session.
async function createCheckout({ db, user, planId, req }) {
const currency = resolveCurrency({
userPreference: user.preferred_currency,
stripeCustomerCurrency: user.stripe_customer_currency,
billingCountry: user.billing_country,
req
});
const { rows: [row] } = await db.query(
`select stripe_price_id from plan_prices
where plan_id = $1 and currency = $2 and effective_to is null`,
[planId, currency]
);
return stripe.checkout.sessions.create({
mode: 'subscription',
customer: user.stripe_customer_id,
currency, // selects the currency_options entry
line_items: [{ price: row.stripe_price_id, quantity: 1 }],
automatic_tax: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
success_url: 'https://app.example.com/billing/done?s={CHECKOUT_SESSION_ID}',
cancel_url: 'https://app.example.com/pricing'
});
}
Customer currency locking
Once a Customer has an invoice, a subscription or a saved payment intent in a given currency, Stripe locks that Customer to that currency. Passing a different one returns an error. Design for it:
- Store the locked currency on your user record as soon as the first invoice is created, via the
invoice.createdorcustomer.subscription.createdwebhook. - Hide the currency switcher for customers with an active subscription.
- If a customer genuinely relocates, the correct migration is: cancel, create a new Customer object, resubscribe, and link both records in your database.
Step 9: Settlement, fees and what lands in the bank
Presenting in EUR does not mean receiving EUR. Stripe converts non-settlement currencies into your account’s default currency, with a conversion fee of roughly 1 to 2 percent depending on your region, on top of standard processing fees. Cross-border card fees stack on that.
Two mitigations worth the engineering time once you cross meaningful volume:
- Add settlement bank accounts in your main currencies so EUR charges pay out as EUR and skip conversion entirely.
- Record the settled amount, not the presented amount, in your revenue tables. Stripe Revenue Recognition uses the settled figure: if you collect 10 EUR and it settles as 12 USD, the recognized transaction amount is 12 USD. If your internal reporting uses 10 EUR converted at your own rate, your books will never reconcile.
-- Store both sides from the balance_transaction on every charge
alter table payments
add column presented_currency char(3) not null,
add column presented_amount_minor bigint not null,
add column settled_currency char(3) not null,
add column settled_amount_minor bigint not null,
add column stripe_fee_minor bigint not null,
add column exchange_rate numeric(18,8);
Pre-launch checklist
- All amounts stored as
bigintminor units, zero floats anywhere in the billing path - Zero-decimal currencies verified with a real test charge (JPY 1000 should bill 1000 yen, not 10 yen)
- Three-decimal amounts are multiples of 10
tax_behaviorset explicitly for every currency option, never left unspecifiedexpand: ['currency_options']used everywhere you read a Price- Idempotency keys on every price and session creation call
- Currency locking handled in the UI for existing subscribers
- FX cache TTL and repricing threshold documented and monitored
- Webhook handler persists settled amount, fee and exchange rate
- Pricing page and Checkout show the identical number, to the cent, in every supported currency
FAQ
Does Stripe support multi-currency pricing?
Yes. Stripe supports payments in 130+ currencies and lets you define fixed amounts per currency on a single Price object using currency_options. Availability of specific presentment and settlement currencies depends on your account country and your connected bank accounts.
What are Stripe fees for international payments?
Expect your standard processing rate, plus a cross-border fee for cards issued outside your account country, plus a currency conversion fee of roughly 1 to 2 percent when the charge currency differs from your settlement currency. Exact rates vary by country, so confirm against your own Stripe pricing page before modeling margins.
How do I change the currency in Stripe?
You can change your account’s default settlement currency in Dashboard settings if your country supports it. You cannot change the currency of an existing Price object or of a Customer who already has invoices in another currency. The solution is to create a new Price, or a new Customer, and migrate.
Should I use Adaptive Pricing or currency_options?
Use currency_options for your core markets where the published price matters and you want clean round numbers. Enable Adaptive Pricing as a fallback for long tail countries you do not want to maintain manually. The two can coexist.
How often should I update converted prices?
Quarterly is a good default for most SaaS, with an exception trigger when drift exceeds about 5 percent. Updating more often confuses prospects and invalidates cached marketing pages. Updating less often erodes margin in volatile currencies.
What are the downsides of multi-currency pricing on Stripe?
Three real costs: conversion and cross-border fees, the operational overhead of maintaining a price matrix across currencies and tax behaviors, and the complexity of reconciling presented versus settled amounts in your accounting. For a product with meaningful international revenue, localized pricing still converts well enough to justify all three.
Need help shipping this?
We build and audit billing systems on Stripe for SaaS teams, including price modeling, tax configuration, webhook reliability and reconciliation. If your multi-currency rollout is stuck somewhere between a pricing page and a reconciliation spreadsheet, get in touch.
