If you have ever written a scraper for a Shopify store, you probably parsed the HTML. You did not have to.
Every Shopify storefront serves its own catalogue as JSON at /products.json. It is not a private API and not a leak — it is the storefront's own public endpoint, the same data the shop renders for any visitor:
https://www.allbirds.com/products.json?limit=250&page=1
https://www.allbirds.com/collections/mens/products.json?limit=250&page=1
https://www.allbirds.com/meta.json → store name, currency, country
250 products per page, page=N until you get an empty list. Collections work the same way, so you can scope a run to one part of a shop.
What a product actually contains
The interesting part is not the title — it is variants. Each variant is a real SKU with its own price, its own crossed-out price and its own stock flag:
{
"id": 7205191974992,
"title": "Daily Grind Women's Corduroy Workwear Jacket",
"vendor": "Death Wish Coffee",
"variants": [
{ "sku": "DWC-JKT-S", "price": "65.00", "compare_at_price": "90.00", "available": true, "option1": "S" },
{ "sku": "DWC-JKT-M", "price": "65.00", "compare_at_price": "90.00", "available": false, "option1": "M" }
]
}
Two things follow from that shape, and both are easy to get wrong.
A discount only means something inside one variant. If you take the lowest price in the product and compare it with the highest compare_at_price in the product, a shop that sells a $10 accessory and a $100 jacket marked down from $120 will report a 92% discount. It is not on sale by 92%; those are two different things being subtracted from each other. Compute the discount per variant, then take the best one:
const discounts = variants
.filter((v) => v.on_sale && v.price !== null && v.compare_at_price)
.map((v) => Math.round((1 - v.price / v.compare_at_price) * 100));
const bestDiscount = discounts.length ? Math.max(...discounts) : null;
Stock is per variant too. "In stock" for a product usually means some size is in stock. If you are monitoring availability, one row per variant is the only honest shape.
Prices are strings, and the currency is somewhere else
price arrives as "65.00", a string. /products.json never tells you the currency — that lives in /meta.json alongside the shop's name and country. Fetch it once per store and attach it to every row, or your dataset silently mixes USD, EUR and GBP in one column.
A real run
Two stores (Allbirds and Death Wish Coffee), one run on 2026-09-17: 439 products, 3,282 variants, 9 seconds, 5 requests. 23 products were on sale, 246 had at least one variant in stock, and every row carried the store's currency. The deepest genuine discount was a pair of socks at $4 against a $16 compare-at price — computed inside one variant, which is why it can be trusted.
What the endpoint will not give you
Some shops turn /products.json off or put it behind a bot check; Gymshark, for one, returns 403. Treat that as a normal outcome, log it and move on — it is a per-store decision by the merchant, not a puzzle to defeat. And a site that is not on Shopify simply 404s.
Use it
If you would rather not maintain the pagination, the variant maths and the per-store currency, it is on Apify Store as Shopify Products Scraper & API — paste store or collection URLs, choose one row per product or per variant, filter to in-stock or on-sale only, $1 per 1,000 rows:
https://apify.com/dododata/shopify-products-scraper
The id field is stable across runs (shopify:store:product:variant), so a daily run plus a diff is a price and stock monitor in about one line of code. If something changes, it is fixed within 48 hours — that promise is on the listing.
Top comments (0)