Shopify International Markets vs One Price and a Merchant of Record
Eight Shopify Markets settings audited against a digital storefront: five are empty, VAT belongs to the MoR, and one currency fallback was printing a dollar sign that lied.
A Shopify international market is a named group of countries you configure once, after which Shopify localises currency, pricing, language, duties and domain for buyers in it. It is the right tool when you ship physical goods to several tax jurisdictions. For a digital storefront the whole matrix can collapse to one price, one currency column, and a Merchant of Record — which is what this one does.
This post is the comparison with the code on the other side of it, because the honest version of "do you need Shopify Markets?" depends entirely on whether anything you sell crosses a border physically.
What a market configures, and who does it here
| Shopify Markets setting | What it decides | Who does it on this storefront |
|---|---|---|
| Market currency | Price display and settlement currency | One USD list price; the order's actual currency is copied off the payment webhook |
| Market pricing | Per-country price adjustments | Nobody — one price worldwide |
| Duties and import taxes | Collected at checkout | Not applicable; nothing ships |
| VAT / sales tax | Registration, collection, remittance | LemonSqueezy, as Merchant of Record |
| Language | Localised storefront copy | Nobody — the storefront is English-only |
| Market domains / subfolders | /en-de, example.de | Nobody — one domain, one URL per product |
| Payment methods per market | Local wallets and methods | The checkout provider's own list |
| Shipping zones per market | Rates and carriers | Not applicable |
Five of those eight rows are empty here, and that is the finding rather than an omission. A template is a file; it has no weight, no customs code and no shipping zone. Delete the rows a download cannot have, and Shopify Markets' remaining job is tax compliance and currency presentation.
The tax half is a vendor choice, not a feature you build
Selling a digital product into the EU means VAT at the buyer's rate, with registration thresholds that start at zero for non-resident sellers. Nobody should build that. The two ways out are Shopify Tax-style automation on a platform you pay for, or a Merchant of Record who becomes the legal seller.
This storefront took the second one, and it is stated in the terms rather than buried:
All purchases are processed by LemonSqueezy, our Merchant of Record… applicable VAT or sales tax based on your location, and issues your receipt.
That single sentence is what replaces a market matrix. The MoR registers in each jurisdiction, charges the right rate, remits it, and issues the invoice. What reaches our database is the outcome, not the calculation — which is why the order row has no tax logic in it at all:
create table public.orders (
id uuid primary key default gen_random_uuid(),
ls_order_id text not null unique,
email extensions.citext not null,
tier text not null check (tier in ('t1','t2','t3')),
total_cents int,
currency text,
status text not null default 'paid' check (status in ('paid','refunded')),
...
);
total_cents int and currency text, both nullable, and nothing else about money. Note citext on the email column: buyer addresses arrive with whatever capitalisation the buyer typed, and an international buyer list makes that more likely, not less. Case-insensitive at the column means the entitlement lookup cannot miss a match on Ana@Example.de versus ana@example.de.
The currency rule: copy, never convert
The webhook that records a paid order does exactly one thing with money — it copies it:
const orderInput: Omit<NewOrderInput, "userId"> = {
lsOrderId,
email,
tier,
totalCents: typeof attributes.total === "number" ? attributes.total : null,
currency:
typeof attributes.currency === "string" ? attributes.currency : null,
raw: rawPayload,
};
No conversion, no rate lookup, no normalisation to USD. The amount and the currency code travel together as a pair, and the full payload is kept in raw jsonb so a disputed charge can be reconciled against what the provider actually said. Converting on write would destroy the only authoritative record of the transaction — the rate you used at write time is not the rate anyone can verify later.
It also means the display layer receives currency codes it did not choose, which is where this storefront shipped a real bug.
The defect: a $ in front of a number that was not dollars
Formatting an amount whose currency comes off a webhook is not the same problem as formatting a price you hard-coded. Intl.NumberFormat throws a RangeError on a currency code it does not recognise, and the original fallback here swallowed that by printing a dollar sign regardless:
function formatAmount(
cents: number | null,
currency: string | null,
): string | null {
if (cents === null) return null;
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency ?? "USD",
}).format(cents / 100);
} catch {
// Intl throws on a currency code it doesn't recognise. The old fallback
// printed a `$` regardless, which turned an unrecognised code into a
// confident lie about the currency; the ISO code is the honest answer and
// matches what the refund email already sends.
return `${(cents / 100).toFixed(2)} ${currency ?? "USD"}`;
}
}
The fix is four characters of behaviour change and one of judgement: when you cannot format a currency, print the ISO code beside the number instead of guessing a symbol. A buyer who paid in a currency the runtime does not know should see 49.00 XYZ, not $49.00. It also now matches the refund email, which was already honest — a mismatch between two surfaces describing the same order is its own support ticket.
Two details in the same function are international-specific. The locale is pinned to "en-US" deliberately: a Server Component formats on the server, where the runtime locale is the container's, not the buyer's, so an unpinned format is a silent inconsistency. And not every currency has two decimal places — JPY has zero — which is why the stored unit is named total_cents and the digit count is left to Intl rather than to a / 100 in the template. Currency formatting in Next.js covers that mechanism on its own.
The guard that matters once more than one store exists
Expanding internationally often means a second store — a regional entity, a different payment provider account. The moment that is true, a webhook endpoint that trusts any signed payload is a cross-tenant hole, so the store identity is checked by exact string equality before anything is written:
if (String(attributes.store_id) !== deps.storeId) {
return { status: 202, body: "ignored" };
}
if (deps.isProduction && attributes.test_mode === true) {
return { status: 202, body: "ignored" };
}
202 rather than 400 is deliberate: the sender is told the message was accepted and must not be retried, without being told why. There is a test fixture dedicated to this case — a valid, correctly signed order_created from store_id: 999 — so the guard cannot regress silently. The test_mode line beside it is the same class of mistake: a test-mode order granting a real entitlement in production.
Where Shopify Markets still wins
Four situations where none of the above is an argument:
- You ship physical goods. Duties, customs codes and per-market shipping rates are a real build, and Shopify has done it.
- You need per-market pricing. Charging less in one region is a commercial decision a single USD price cannot express. Our tiers are the same everywhere.
- You need a localised storefront. Shopify's translation tooling is a product; here it would be a greenfield i18n project across 746 prerendered pages.
- You are registered for tax yourself. If you already hold VAT registrations, an MoR is paying someone to do what you have already built.
The build-versus-buy decision underneath all four is the same one in Shopify vs a Next.js storefront, and the inventory of what the platform absorbs is Shopify's advantages, priced in code.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Amounts render with $ in the wrong currency | A fallback that assumes USD when Intl throws | Print the ISO code beside the number |
RangeError: Invalid currency code | A code from a webhook payload reached Intl unchecked | Wrap the format in try/catch, as above |
| Totals off by 100× for JPY orders | Dividing a zero-decimal currency by 100 | Store minor units, let Intl decide the digits |
| Amounts differ between dashboard and receipt | The app converted on write | Store the provider's amount and code verbatim |
| Entitlement not found for a returning buyer | Case-sensitive email match | citext, or normalise on both sides |
| Orders from a second store appear in the first | No store-identity check on the webhook | Compare store_id as a string before any write |
Frequently asked questions
Do I need Shopify Markets to sell internationally? Only if the localisation is part of the product. For digital goods the compliance half can be handed to a Merchant of Record and the presentation half is one currency column, which is what this storefront does.
Does a Merchant of Record remove my tax obligations entirely? It makes the MoR the seller for the transaction, so they handle VAT and sales tax on it. You still owe income tax on what they pay you, and the arrangement needs to be stated in your terms. It is a different obligation, not none.
Should I convert every order to one currency in the database? No. Store the amount and the ISO code exactly as the provider sent them, plus the raw payload. Convert only at report time, with a rate you can name. Converting on write throws away the record you would need to settle a dispute.
How many markets should I launch with? On this model, one: a single price and an MoR who already covers every country they operate in. Add a market when you have a reason specific to that country — local pricing, a local entity, or a language — not because the dashboard has a button for it.
Templates that sell across borders without a market matrix
The storefront pattern above — one price, one currency column, a Merchant of Record and a prerendered product page — is what the Next.js landing page templates and Tailwind landing page templates are built for.
Templates in this post
ASoc Coin, ASoc Compound and ASoc Cortex are Next.js + Tailwind landing page templates with the pricing and checkout surfaces a one-price international storefront needs.
