EUR/USD 1.11980 ▲ +0.35% GBP/USD 1.32563 ▲ +0.36% USD/JPY 158.291 ▼ -0.15% USD/CHF 0.83202 ▼ -0.24% AUD/USD 0.69789 ▲ +0.34% USD/CAD 1.42142 ▼ -0.46% NZD/USD 0.56073 ▲ +0.39% BTC/USD 83,379.90 ▲ +2.02% ETH/USD 2,508.19 ▲ +2.65% · EUR/USD 1.11980 ▲ +0.35% GBP/USD 1.32563 ▲ +0.36% USD/JPY 158.291 ▼ -0.15% USD/CHF 0.83202 ▼ -0.24% AUD/USD 0.69789 ▲ +0.34% USD/CAD 1.42142 ▼ -0.46% NZD/USD 0.56073 ▲ +0.39% BTC/USD 83,379.90 ▲ +2.02% ETH/USD 2,508.19 ▲ +2.65% ·
7 min read multi currency pricing saas

Multi-Currency Pricing for SaaS: Display vs Settlement

How to convert, round, cache, and settle prices without surprising customers at checkout.

Multi-Currency Pricing for SaaS: Display vs Settlement

SaaS teams often treat FX as a last-mile formatting problem. It is not. Multi-currency pricing is a product, billing, and risk system: you choose what the customer sees, what they pay, and who eats the spread when those two numbers diverge.

This post is a practical architecture for multi currency pricing SaaS and currency conversion API SaaS products. It covers display prices versus settlement, how often to refresh stored rates, psychological rounding after conversion, and how to avoid mid-market surprise spreads at checkout. We close with a rates-service + cache + nightly reprice job outline and a short JSON sample. Live endpoints: /rates/all and /checkout.

Display price vs settlement: two numbers, two jobs

Keep these contracts separate. Mixing them is how you get “the page said $29, Stripe charged €31.47.”

  • Display price is a marketing and UX number. It is what appears on pricing pages, invoices-in-progress, emails, and in-app upgrade screens. It can be rounded, localized, and slightly stale.
  • Settlement price is the amount you actually charge, in the currency your PSP or bank settles. It must be deterministic at the moment of capture, tied to a rate snapshot, and reconcilable later.

A healthy model stores both:

  1. A list price in a base currency (usually USD or EUR) that finance owns.
  2. A display quote in the shopper’s currency, produced from a named rate snapshot.
  3. A settlement instruction: charge in display currency, or convert at capture into the settlement currency, with the FX source recorded.

Never convert “live” at the PSP using an unnamed mid-market rate if the customer already committed to a displayed amount. Either you guarantee the display amount (you take FX risk) or you disclose that the charged amount is indicative until checkout lock.

How often to refresh stored rates

Refresh frequency is a product decision, not a market-data flex. Match it to how long a quoted price is allowed to live.

Surface Typical freshness Why
Public pricing page Hourly to daily Stability beats tick-level accuracy. Daily is fine for most SaaS list prices.
In-app upgrade / cart 5–15 minutes, cached Close enough to checkout that a jump looks like a bug.
Checkout lock Snapshot at quote, TTL 15–60 min The number on the pay button must match the charge.
Invoices & dunning Rate frozen on invoice issue Do not reprice an open invoice every hour.
Subscriptions / renewals Nightly reprice or period-start freeze Pick one policy and put it in the contract.
Treasury / P&L ECB / WM close, or your PSP’s daily rate Accounting needs a named source, not a widget tick.

Practical rule: store the rate, not just the converted amount. Persist rate_id, source, bid/ask or mid + markup, timestamp, and the pair. When a customer disputes a charge six months later, you need the snapshot, not a reconstructed mid from a free API.

For stored catalog prices, a nightly reprice job is usually enough. Intra-day FX moves of 0.3–0.8% are noise relative to SaaS packaging. Reprice intra-day only if you sell in high-volatility pairs, quote large annual contracts, or display unrounded “exact” amounts that make a 40-cent jump visible.

Psychological price rounding after conversion

Raw conversion produces ugly numbers: $29.00 × 0.9214 = €26.72. That is a quote, not a price. After conversion, apply a rounding policy on the display amount, then freeze that amount for the quote TTL.

Common policies:

  • Charm pricing — land on .99 / .95 / .00 in the local currency. €26.72 becomes €26.99 or €27.
  • Banded catalogs — map converted amounts into a small set of local SKUs (€9 / €19 / €29 / €49) so the pricing page never looks FX-generated.
  • Asymmetric round — round display prices up (protect margin) and never round settlement down without a floor markup.
  • Minor-unit respect — JPY and KRW have no decimals; KWD has three. Rounding must be currency-aware, not “always 2 dp.”

Do the rounding in your pricing service, not in the client. If the frontend rounds and the backend charges the unrounded conversion, you have created a mid-checkout delta. The JSON quote should already contain the rounded display amount, the pre-round converted amount, and the rounding rule id.

Watch margin: charm rounding can give away 1–3% on low-ticket monthly plans. Either bake that into the FX markup or use banded local prices reviewed quarterly instead of mechanically rounding every pair.

Avoiding mid-market surprise spreads

Mid-market (the rate on Google, XE, or a widget) is not a rate you can settle at. Card networks, PSPs, and banks deal on bid/ask plus a wholesale markup. If you advertise “real-time mid” and then Stripe or Adyen converts at mid + 2%, the customer feels baited.

Design rules that prevent that surprise:

  1. Never show mid as the payable rate. Show a customer rate: mid plus your published markup, or a PSP-indicative rate you actually intend to use.
  2. Prefer charging in the display currency. If the customer sees €29, charge €29. Let the PSP settle you in USD/EUR. Your FX risk moves to treasury, which is the right place for it.
  3. If you must convert at capture, lock a rate with a TTL and a worst-case ceiling (“this quote is valid for 30 minutes; we will not charge more than €29.40”).
  4. Disclose the spread. A 150–250 bps markup over mid is normal for SaaS checkout. Hide it and you will get chargebacks and Twitter threads. Show it and it becomes a feature of a transparent rates page.
  5. One source of truth per quote. Do not mix ECB mid for the pricing page, a free API for the cart, and the PSP’s rate at capture.

A simple customer-rate formula that finance can explain:

customer_rate = mid_rate * (1 + markup_bps / 10_000)
payable        = round_local(base_amount * customer_rate)

Store markup_bps on the quote. When mid moves, the payable only changes if you issue a new quote. Checkout uses the stored payable, not a live mid.

Architecture: rates service, cache, nightly reprice

Keep FX out of the monolith’s request path. A small rates service plus a cache plus a job is enough for most SaaS catalogs.

[FX providers] --pull--> rates-service --write--> Redis / CDN cache
                                |
                                +--read--> pricing-api (display quotes)
                                |
                                +--read--> nightly-reprice job --> catalog / price_book
                                |
                                +--snapshot--> checkout quote store --> PSP capture

Rates service. Ingests one or more providers (ECB daily, commercial feed, PSP indicative). Normalizes pairs, timestamps, and bid/mid/ask. Exposes GET /rates/all for the latest book and GET /rates/{pair}?at= for historical snapshots. See /rates/all.

Cache. Hot path is a cache with a 60–300s TTL for live widgets and a longer TTL (or explicit version) for catalog display. Cache keys include source + pair + markup policy so you never serve a mid rate into a customer-rate slot. On provider failure, serve last-good-rate with a stale flag rather than failing checkout.

Nightly reprice job. Walks every local SKU, converts from base list price with the current customer rate, applies the rounding policy, and writes a new price book version. Only publish if the move exceeds a threshold (e.g. 1%) or it is month-end. This stops the pricing page from flickering every morning.

Checkout quote. When the buyer hits pay, mint a quote: amounts, currencies, rate_id, markup, rounding rule, expires_at. /checkout charges that quote. If the quote is expired, re-quote explicitly — do not silently re-convert.

Quote JSON sample

{
  "quote_id": "qte_8f2c1a",
  "sku": "pro_monthly",
  "base": { "amount": "29.00", "currency": "USD" },
  "display": {
    "amount": "26.99",
    "currency": "EUR",
    "pre_round_amount": "26.72",
    "rounding_rule": "charm_99"
  },
  "rate": {
    "id": "rt_2026-10-12T06:00:00Z_ECB",
    "pair": "USD/EUR",
    "mid": "0.9214",
    "markup_bps": 150,
    "customer_rate": "0.9352",
    "source": "ecb+markup"
  },
  "settlement": {
    "charge_currency": "EUR",
    "charge_amount": "26.99",
    "payout_currency": "USD"
  },
  "expires_at": "2026-10-12T07:00:00Z"
}

That object is the contract between pricing, checkout, and finance. The customer is charged settlement.charge_amount in charge_currency. Display rounding is visible. The spread over mid is auditable. Nothing at capture re-hits a live mid-market API.

Implementation checklist

  • Base list prices in one currency; local amounts are derived, versioned, and rounded in the pricing service.
  • Every customer-facing number is backed by a rate_id and a quote TTL.
  • Checkout charges the quote, not a live conversion. Start at /checkout.
  • Nightly job reprices the catalog; intra-day only for carts approaching capture.
  • Customer rate = mid + explicit markup. Never present raw mid as payable.
  • Currency-aware rounding; store pre-round and post-round amounts.
  • Last-good-rate fallback on provider outage; never fail a renewal because FX is down.
  • Expose the book at /rates/all so support and customers can see the same numbers you charged.

Multi-currency SaaS pricing is not a toFixed(2) helper. It is a quoted, rounded, cached, and settled amount with a named rate. Get display versus settlement right, refresh on a schedule that matches quote lifetime, round like a retailer, and never let a mid-market widget set the charge.

Real-time forex rates for your app

Live bid/ask for the pairs you need, updated every second, with a simple JSON & XML API. Try it free for 7 days.