How to Build a Multi-Currency Checkout for Shopify
The hardest thing about multi-currency on Shopify is not the code — it is deciding how much code you actually need. Most stores reach for a third-party app and end up paying for features they will never use; others write a custom proxy and discover three months later that Shopify Payments already does most of it.
This guide walks through the two real options, the trade-offs that decide between them, and the implementation details for each. By the end you should know which path your store should take and how to ship it without breaking accounting.
What you will build: A Shopify storefront that detects the visitor's currency, shows localized prices on product pages and the cart, lets the customer pay in their own currency, and writes the locked-in exchange rate to the order so refunds and reporting stay clean.
The two real options
| Shopify Markets (built-in) | Custom rates with your own API | |
|---|---|---|
| Setup time | Under 30 minutes | Half a day to a week |
| Currencies | 130+ supported by Shopify Payments | Any currency you can fetch a rate for |
| Default FX rates | Shopify-provided (with retail markup baked in) | Mid-market rates from your chosen API |
| Settlement | Auto-converted to store currency | You handle settlement (usually via Shopify Payments still) |
| Best for | Most stores | Stores that need transparent rates, custom markets, or one rate source across systems |
If you do not have a strong reason to go custom, start with Shopify Markets. The rest of this article covers both, and the second half is what you actually need when "the built-in option" is not enough.
Option 1: Shopify Markets (the easy path)
Shopify Markets is the modern multi-currency primitive on Shopify. It replaces the older "International Pricing" UI and gives you per-market currency settings, prices, domains, and shipping rules.
Step 1: Enable the markets you ship to
- Open Settings > Markets in your Shopify admin.
- Either turn on the default "International" market (covers everywhere not handled by another market) or create a specific market per region: EU, UK, North America, etc.
- For each market, choose a currency. Shopify Payments must support that currency for it to work at checkout.
Step 2: Pick a rate source per market
For each market's currency, you have three rate options:
- Auto (Shopify rates). Shopify converts using its own rate, which typically includes a small retail markup over mid-market.
- Manual. You set a fixed exchange rate. Useful for legal-tender countries where you want stable pricing, but you have to remember to update it.
- Per-product price. You set the price directly in the target currency — bypassing FX entirely. This is the cleanest option for round-priced storefronts (e.g. €19 in EU markets, £15 in UK).
Step 3: Show the right prices in the storefront
Modern Shopify themes (Dawn, Sense, etc.) handle this automatically when Markets is enabled — the price in Liquid resolves to the customer's market currency. If your theme is older, you may need to swap {{ product.price | money }} for {{ product.price | money_with_currency }} so the visible currency code matches what the customer will be charged.
<!-- product-price.liquid -->
<span class="price">
{{ product.price | money_with_currency }}
</span> Add a currency selector if you want customers to be able to override the auto-detected market:
{% form 'localization' %}
<select name="currency_code">
{% for currency in localization.available_countries %}
<option value="{{ currency.currency.iso_code }}"
{% if currency.currency.iso_code == localization.country.currency.iso_code %}selected{% endif %}>
{{ currency.currency.iso_code }} {{ currency.currency.symbol }}
</option>
{% endfor %}
</select>
<button type="submit">Update</button>
{% endform %} What you get out of the box
- Checkout in the customer's currency.
- Order records with both store currency and presentment (customer) currency.
- Auto-conversion to your store currency for payouts.
- Locked-in conversion rate stored on the order, so refunds use the same rate.
For most stores doing < $5M/year, this is the whole job. Stop here.
Option 2: Custom rates with your own exchange rate API
The reasons to go custom are specific:
- Mid-market rates. Shopify's auto rate has a retail markup. If you want customers to see and pay the actual mid-market rate — or a transparent, fixed markup — you need your own rate source.
- One rate source across systems. If your accounting (Xero, NetSuite), invoicing, and store all need to agree on the rate at order time, having a single API as the source of truth simplifies reconciliation.
- Markets Shopify does not cover. Some currencies are not supported by Shopify Payments at checkout. You can still display them and convert to a settlement currency at checkout time.
- Hybrid pricing logic. "EUR base price plus 1.5%, except in markets where we have psychological pricing" is hard in Markets and trivial when you control the math.
Architecture
The shape of a custom flow has three pieces:
- A rate sync. A scheduled job (Cloudflare Worker, Vercel Cron, AWS Lambda, anything cheap) hits the AllRatesToday API every few minutes and writes the rates somewhere your storefront can read fast — KV, Redis, or just a JSON blob behind a CDN.
- A storefront overlay. A small script on the theme reads the cached rates, picks a target currency for the visitor, and rewrites prices in the DOM (or via a Section Rendering API call). It writes the chosen currency to a cookie so it persists.
- A checkout adapter. Either a Shopify Markets manual rate that you push into the admin via the GraphQL API on a schedule, or a custom checkout extension that reads the rate at checkout time and writes the source/target amounts onto the order metadata.
Step 1: Sync rates from AllRatesToday
The simplest sync is a Cloudflare Worker on a 5-minute cron that fetches a basket of currencies and writes them to KV. KV reads are sub-millisecond from the edge, so the storefront pays no latency for them.
// worker.ts
const BASE = 'USD'; // your store currency
const TARGETS = ['EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'INR', 'BRL', 'MXN'];
export default {
async scheduled(_event: ScheduledEvent, env: Env) {
const rates: Record<string, number> = {};
for (const target of TARGETS) {
const res = await fetch(
`https://allratestoday.com/api/v1/rates?source=${BASE}&target=${target}`,
{ headers: { Authorization: `Bearer ${env.ALLRATESTODAY_KEY}` } }
);
if (!res.ok) continue;
const data = await res.json();
rates[target] = data.rate;
}
rates[BASE] = 1;
await env.RATES.put('latest', JSON.stringify({
base: BASE,
rates,
fetchedAt: new Date().toISOString(),
}));
},
async fetch(_req: Request, env: Env) {
const cached = await env.RATES.get('latest');
return new Response(cached, {
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=60',
'access-control-allow-origin': '*',
},
});
},
}; Schedule the cron in wrangler.toml, deploy, and you have a public endpoint your storefront can hit on every page load with effectively no cost.
Step 2: Display converted prices in the theme
In your theme's theme.liquid or a dedicated section, render the base prices and let a small script localize them. Shopify already exposes the cents amount on each product element, so use that as the source of truth.
<script>
(async function () {
const FX_ENDPOINT = 'https://your-worker.workers.dev/';
const stored = localStorage.getItem('display_currency');
const target = stored ?? new Intl.NumberFormat().resolvedOptions().currency ?? 'USD';
const res = await fetch(FX_ENDPOINT);
const { base, rates } = await res.json();
const rate = rates[target] ?? 1;
document.querySelectorAll('[data-price-cents]').forEach(el => {
const cents = Number(el.dataset.priceCents);
const converted = (cents / 100) * rate;
el.textContent = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: target,
}).format(converted);
});
})();
</script> In your product template, render prices with the cents attribute:
<span data-price-cents="{{ product.price }}">
{{ product.price | money_with_currency }}
</span> The Liquid output is the fallback (no JS, search-engine indexable, server-rendered). The script swaps it client-side once rates are loaded.
Step 3: Get the right currency to checkout
This is where it gets opinionated. You have two options:
Option A: Push rates into Shopify Markets as manual rates. Use the Shopify GraphQL Admin API marketCurrencySettingsUpdate mutation to set a manual rate for each market on a schedule. Customers still check out through Shopify Payments in their currency; you have just replaced Shopify's auto rate with your own. This is the simplest path and keeps Shopify happy with PCI, refunds, and reconciliation.
mutation UpdateMarketRate($id: ID!, $rate: Decimal!) {
marketCurrencySettingsUpdate(
marketId: $id,
input: {
manualRate: $rate
}
) {
market { id }
userErrors { field message }
}
} Option B: A checkout extension that calculates totals from your rate. Use Shopify Functions / Cart Transform to apply the conversion as a discount or a custom line item. This is more work and you must be careful with rounding, but it gives you full control over the math. Use it only when Option A truly cannot express what you need.
Step 4: Lock the rate on the order
Whichever option you pick, the rate at checkout time must end up on the order so refunds and accounting stay coherent.
- With Markets, Shopify already does this:
order.presentment_currency,order.subtotal_price_set.shop_money, andorder.subtotal_price_set.presentment_moneytogether encode the rate. Read those fields, never refetch. - With a custom flow, write the rate, source amount, and target amount to
order.metafieldsin a webhook onorders/create.
// orders/create webhook
async function onOrderCreated(order: ShopifyOrder, env: Env) {
const cached = await env.RATES.get('latest');
const { rates } = JSON.parse(cached);
const target = order.presentment_currency;
const rate = rates[target];
await shopify.metafields.set({
ownerId: order.id,
namespace: 'fx',
key: 'rate_at_order',
type: 'json',
value: JSON.stringify({
source: 'USD',
target,
rate,
lockedAt: new Date().toISOString(),
}),
});
} Tax, rounding, and the parts that bite later
Display rounding
Mid-market FX gives you ugly numbers like €19.4732. Round to two decimals for display (or zero for JPY/KRW), but keep the unrounded number for the math. Round once at the very end.
Psychological pricing
If your USD price is $19.99, the converted EUR will not be €19.99. Either accept this and round to nearest cent, or override per-currency in Shopify Markets so EUR shoppers see €19.99 too. Most stores eventually choose the latter for round-numbered tiers.
Tax
Tax is calculated on the presentment price by Shopify. If you change the rate, the tax adjusts automatically. Do not roll your own tax math — let Shopify handle it.
Currency symbol collisions
Hard-coding $ means CAD shoppers see USD-looking prices. Use money_with_currency in Liquid (which renders $19.99 CAD) or Intl.NumberFormat with the ISO code in JS. Symbol-only formatting belongs only in domains where the currency is unambiguous.
Common pitfalls
Do not refetch the rate at refund time. If a customer paid €18.40 in March at a rate of 1.084 and you refund in April when the rate is 1.092, you must use 1.084 (the locked rate from the order). Otherwise you owe the customer cents you cannot reconcile, and your accounting will not balance.
- Caching the rate too long. A 24-hour cache is fine for marketing pages; on a checkout page it is a liability. Refresh at least every 15 minutes during business hours, and always re-read at checkout time on the server.
- One rate for everyone. If you ship to JPY and INR customers using the same EUR-base rate via two hops, your math will drift. Fetch direct pairs (USD→JPY, USD→INR) rather than chaining.
- Forgetting to update the manual rates. If you use Shopify Markets manual rates, schedule the update job and alert if the cron stops running. Stale manual rates are worse than no manual rate at all.
- Marketing pages out of sync with checkout. If a homepage CTA shows €18 but checkout charges €18.40, customers will bounce. Either show "From €18" or recompute the homepage price from the same rate source.
Frequently Asked Questions
Does Shopify support multi-currency checkout out of the box?
Yes — Shopify Markets enables checkout in 130+ currencies on Basic plans and up. Customers see localized prices, pay in their currency, and Shopify converts to your store currency for payouts. The default rates come from Shopify; you can override them with manual rates set per market.
When should I bring my own exchange rate API?
When you need transparent mid-market rates rather than the retail markup baked into Shopify's auto rate, when you want a single rate source across Shopify, your accounting system, and your invoices, or when Shopify Markets does not support a market you sell into.
How do I display converted prices on Shopify product pages?
For the native multi-currency setup, the money_with_currency Liquid filter handles it. For a custom flow, render the base price with a data-price-cents attribute and let a small script swap it client-side using rates from your API.
Can I lock the FX rate when an order is placed?
Yes. With Shopify Markets, the rate is automatically written to the order via the presentment fields. With a custom flow, capture the rate in an orders/create webhook and write it to order.metafields. Refunds and reporting must then read from those fields, not refetch a fresh rate.
What is the cheapest way to run the rate sync?
A Cloudflare Worker with a cron trigger. The Worker invocations and KV writes for a 5-minute cron sit comfortably inside the free tier, and edge-cached reads from the storefront cost nothing. Your only cost is the rate API itself, which AllRatesToday provides with a free tier for development and small production volumes.
Power Your Shopify Multi-Currency Setup
Real-time mid-market rates for 160+ currencies, refreshed every 60 seconds. Free API key, no credit card required.
Get Your Free API Key