How to monitor Shopify competitor prices (without installing a thing on their store)
Every Shopify storefront exposes a public /products.json. Here's how to read it, how to handle variants + geo-currency, and what breaks when a competitor turns it off.
Here's the thing nobody writing about Shopify competitor monitoring says out loud: you don't need to install anything on their store. You don't need API access, a plugin, a partnership, or even a scraping proxy for most of them. Every public Shopify storefront exposes a JSON endpoint that returns the full product catalog, built for the exact purpose we're about to use it for.
This post walks through what that endpoint is, how to read it in ~20 lines of Node, the three edge cases that will bite you (variants, geo-currency, and rate limits), and what to do when a competitor turns it off.
The endpoint
Add /products.json to any Shopify storefront URL:
curl 'https://outerknown.com/products.json?limit=250' | jq '.products[0]'
You get back a product object that looks like:
{
"title": "The Northwood Puffer",
"handle": "the-northwood-puffer",
"variants": [
{ "price": "348.00", "sku": "...", "available": true }
],
"images": [...],
"created_at": "...", "updated_at": "..."
}This endpoint is built into every Shopify store, served without authentication, and documented in Shopify's own developer docs under “Public Store API”. It's there so search engines, affiliate networks, and comparison shopping engines can read product catalogs. Shopify doesn't consider reading it “scraping” — it's the officially sanctioned path.
Reading it reliably
The minimum useful loop in Node looks like this:
const res = await fetch(`${storeUrl}/products.json?limit=250`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) return null
const { products } = await res.json()
for (const p of products) {
const price = Math.min(...p.variants.map(v => Number(v.price)))
console.log(p.title, '→', price)
}Three things to notice. First, limit=250 is the max per page; large stores need pagination via page=N. Second, we take the min variant price, because the surfaced price shoppers see is almost always the cheapest available option. Third, bound the request withAbortSignal.timeout() — some stores sit behind Cloudflare and hang on bot requests.
Three gotchas that will cost you a day each if you don't know them
1. Variants aren't products
A single “product” in Shopify can have 15 variants with different prices (small/medium/large, or colors with different pricing tiers). If you treat each variant as a tracked item, you're paying to monitor the same product fifteen times. The right unit of tracking is the product, with the price we report being the minimum variant price. The customer sees the cheapest option; that's what matters when a competitor drops.
2. Prices come back in whatever currency Shopify geolocates you to
This one burned us. We ran the scanner from a server in India and got prices like "34000.00" for a $348 product. Shopify Markets geo-detects the request IP, converts to the shopper's local currency, and returns the converted number — no indication in the response that this happened.
The fix is ?country=US on the URL:
fetch(`${storeUrl}/products.json?limit=250&country=US`, {
headers: {
accept: 'application/json',
'accept-language': 'en-US',
},
})That forces Shopify Markets into the US pricing table regardless of the request origin. If your tenants are international, you'll want this parameterized per tenant eventually — but for a US/EU SaaS, hard-coding USD gives you consistent comparability.
3. Rate limits are real but friendly
Shopify will throttle at roughly 2 requests per second per IP for the public storefront API, with a short burst allowance. That's plenty for a scanner that visits each competitor once every 8 hours. The pattern that gets you in trouble is scanning all of a tenant's competitors in parallel from a single IP — serialize instead of fanning out and you'll stay under the limit easily.
When a competitor turns it off
Shopify stores on the Plus plan can disable the public /products.json endpoint. In 18 months of running Undercut we've seen it once. When it happens, your scanner falls back to the next-best data source: the storefront's sitemap.xml, which is required to exist for SEO and lists every product URL.
From there, each product page carries a Schema.org Product JSON-LD block in its <head> — the exact same data Google reads to show rich snippets. Parse that and you have the price, title, and availability per product without the /products.json endpoint. This is the same fallback Undercut uses for WooCommerce, BigCommerce, and Squarespace stores, where there's no Shopify-equivalent API at all.
What we built on top of this
All of the above is what Paravey Undercut runs on. Every 8 or 12 hours (depending on tier), we poll /products.json for your store and your tracked competitors, write a price point per product per source, and compare the latest reading to the previous one. If the delta exceeds 1% and matches your alert-direction preference (drops-only or drops-and-rises), you get an email digest and a Slack ping with the old price, new price, delta percentage, and a one-click link to the competitor page.
The scanner is the main product; the free Price Gap Snapshot runs a one-time version of it so you can see what the report looks like before you even sign up.