Regional Pricing on Stripe
The headline decision: currency_options on the Price object, not coupons. Coupons
are a checkout-time discount mechanism — there's no API to preview a coupon-plus-FX-converted
price before checkout, so they can't back a pricing-page display. currency_options stores
a fixed amount per currency directly on the Price and is readable with a plain GET, which is
exactly what a pre-checkout display needs.
The second decision, easy to get backwards: tiers are per currency, not per country.
When to use this
Trigger if: the ask is to show lower prices to lower-income markets, "PPP pricing", "regional discounts", localized pricing pages, or anything that discounts by country/region on a Stripe-billed product.
Skip if: the discount only needs to exist at checkout (promo codes, time-limited sales, partner discounts) with no pre-checkout price display requirement — plain Stripe coupons are simpler and correct for that case. Regional PPP pricing is specifically a display problem first, a checkout problem second.
1. Mechanism: currency_options, not coupons
Stripe Prices support a currency_options map: one fixed unit_amount (or amount set) per
ISO currency code, set on the Price object.
- Write: update the Price with a
currency_optionsmap (one call, all currencies for that price). - Read: retrieve the Price with
expand: ['currency_options']to see what's currently set, before and after any write.
Why not coupons: a coupon only resolves at checkout-session-creation time. To show "$8/mo" on
a pricing page for a visitor in a lower-PPP country before they start checkout, you need a
number you can read back right now — currency_options gives you that directly; a coupon
gives you nothing to display.
Adjacent gotcha, note it and move on: if the account has Stripe Adaptive Pricing enabled
(automatic neutral-FX conversion per currency), setting currency_options for a given currency
disables Adaptive Pricing for that currency specifically. The two are mutually exclusive
per currency by design — you're replacing Stripe's neutral FX conversion with your own
PPP-adjusted number for every currency you touch. That's usually the point, but say it out loud
before the rollout so nobody's surprised the "automatic" conversion silently stopped applying
to just the currencies you customized.
2. Granularity: tiers by currency, not by country
The natural first instinct — build a country → discount-tier table, "Germany: tier 2, Latvia:
tier 4" — breaks the moment you try to apply it, because currency_options only lets you set
one amount per currency, and a single currency can span countries at meaningfully different
purchasing-power levels. Eurozone is the clean example: Germany is near income-parity with the
reference market, the Baltic states are meaningfully cheaper — but they share EUR, so one Price
literally cannot hold two different EUR amounts for the two of them.
Fix: build the tier table at the currency level from the start. Every discount decision is
"what does this currency get," never "what does this country get." The tradeoff is explicit and
worth stating to whoever signs off: countries sharing a currency get identical pricing regardless
of their individual PPP, because the mechanism has no country-level dial. If country-level
granularity is a hard requirement, currency_options is the wrong mechanism entirely (you'd need
IP/billing-country gating in front of checkout instead) — flag that up front rather than
discovering it mid-build.
For a currency zone spanning several countries, pick one representative country for that zone's tier (population-weighted, or the most economically dominant member) and write down which country and why — that choice is a real editorial call, not a rounding detail, and someone will ask about it later.
3. Source the tier percentages — don't invent them
Discount percentages are a factual claim about relative purchasing power, not a design choice. Blend three sources rather than picking one:
- Primary, raw data: World Bank PPP conversion factors (or IMF World Economic Outlook data as a substitute where World Bank coverage is thin). This is the actual purchasing-power ratio between the reference market and the target market/currency zone.
- Lightweight sanity check: the Big Mac Index, for major markets only — it's a rough, widely-cited cross-check, not a primary source, but a discount that contradicts it by a wide margin for a big market is worth a second look.
- Reasonableness cross-check against practice: published SaaS PPP tier guides (e.g. Paddle's PPP pricing guide, ParityDeals' public tables, Gumroad-style public discount tables). These tell you what discount level the market has already normalized around for digital products — useful for catching a raw-PPP number that's technically correct but so aggressive it reads as a pricing bug.
When raw PPP and a published guide diverge significantly for the same market, flag the divergence explicitly and ask which to weight — don't quietly average them. Averaging hides a real disagreement between "what the data says" and "what the market has already accepted"; a human should see both numbers and choose, or choose a middle ground on purpose.
4. Safety workflow — sandbox first, sign-off twice
This is a money-facing, irreversible-in-effect change (customers see and buy at these prices), so the write sequence matters more than the pricing math.
- Verify the sandbox/test account is actually separate from production before any write —
don't assume it from the account name or an info endpoint. Some read-only "account info"
calls don't surface a
livemodefield at all. Use a call that returns it explicitly (a balance read is a reliable one) and confirmlivemode: falsebefore touching anything. This is a one-line check; skipping it is how a "test" write lands in production. - Compute the full proposed price table and present it for explicit sign-off before any write. Per currency: the tier/discount percentage, the resulting amount in that currency's smallest unit (cents, not dollars — Stripe amounts are always in the minor unit), and the FX source and date used to derive it. Sign-off happens on this table, not on a description of the plan.
- Execute against the test/sandbox account first. Write, then read back via
expand: ['currency_options']to confirm what's actually stored matches the approved table — don't trust the write response alone. - Treat "approved for staging" and "approved for production" as two separate approvals. Getting sign-off on the sandbox table does not imply sign-off to repeat it in production — ask again, explicitly, before the production write, even if nothing in the table changed between the two runs. The two environments have different blast radii; the approval should reflect that.
5. currency_options amounts are immutable per currency — and most apps can't hot-swap price IDs
Once a currency has a unit_amount set in currency_options, Stripe will not let you update
it — the write returns an "immutable field" error. This mirrors how base Price amounts work:
you don't edit a Price's amount, you create a new Price. The same rule applies per-currency
inside currency_options.
That's a bigger deal than it sounds, because most apps don't resolve which Price to charge
dynamically — they bake a specific Price ID into an env var at build/deploy time (e.g.
STRIPE_PRICE_PRO_MONTHLY, compiled into a generated secrets file by a build script).
Correcting a wrong currency amount later means creating a brand-new Price object, which means
a new Price ID, which means an engineer has to update that env var and ship a deploy — not a
quick follow-up API call. Get the numbers right before the first write. If a currency's
data is shaky (stale FX, an unconfirmed source), leave that currency out of the first write
rather than setting a placeholder you'll want to fix later — the fix costs a deploy, the
omission costs nothing.
If the app can be changed to resolve prices by lookup_key instead of a hardcoded ID, raise
that as a prerequisite before the rollout, especially if some currencies are expected to need
revision later (stale FX, a floor value pending confirmation). A lookup_key can be reassigned
to a new Price with transfer_lookup_key, so a future correction becomes a Stripe-side change
again instead of a deploy.
Also: never mint a new Price object as a scratch/throwaway way to test the mechanism in a sandbox that happens to lack the exact Price you need. If the sandbox's product doesn't have a Price at the amount you want to verify against, that mismatch is real information — find out which Price ID the app's env var actually points to (grep the codebase for the env var name, or ask), and test against that one. A fixture Price that nothing in the app's config points to doesn't validate anything, and it's one more object someone has to notice and clean up later.
Related but separate gotcha: tax_behavior is one-way
While in the Price object, note tax_behavior (inclusive / exclusive / unspecified):
once it's set away from unspecified, it cannot be reverted or flipped later. It's a
permanent decision on that Price. Because a currency_options rollout often touches the same
Price objects, there's a temptation to "fix tax_behavior while we're in there" — don't bundle it.
Track and approve any tax_behavior change as its own separate decision, on its own timeline,
never folded into a currency-pricing change where it could get nodded through as a side effect.
Checklist (the playbook)
- Confirm the ask is a pre-checkout display problem →
currency_options, not coupons. - Build the tier table per currency; pick and document a representative country for any multi-country currency zone.
- Derive percentages from World Bank/IMF PPP data, sanity-checked against the Big Mac Index and published SaaS PPP guides; surface divergences instead of averaging them away.
- Verify the test account's
livemodeexplicitly via a call that actually returns the field. - Confirm which exact Price ID the app's env var/config actually resolves at runtime — never test against a fixture Price you minted yourself if it isn't what the app points to.
- Present the full per-currency price table (tier %, minor-unit amount, FX source + date) for sign-off before any write. Drop any currency whose data isn't solid yet rather than writing a placeholder — a wrong amount can't be corrected without a new Price ID and a deploy.
- Write to test/sandbox, read back with
expand: ['currency_options']to confirm. - Get a separate explicit go-ahead, then repeat against production; read back again.
- Leave
tax_behavioralone unless it was asked for and approved as its own, separate change.
Security — writes to a live payment gateway
This skill writes Stripe Price / currency_options objects. It never moves funds, but it changes what customers are charged. Run every change against the Stripe test/sandbox account first, verify, then require explicit human confirmation before repeating against live (livemode: true). The Stripe secret key stays in an env var — never hardcode, commit, log, or echo it.
Related
pricing-page— decide the value metric, the tiers and the presentation before deciding how they vary by market.saas-billing-stripe— Checkout, subscriptions, webhooks and dunning. This skill only covers how the price changes by currency.