If you sell on Shopify, your competitors probably do too. And every Shopify storefront has a public, documented-by-accident API that most people never look at:
https://www.allbirds.com/products.json?limit=250&page=1
Open that in a browser. It is the store's entire catalog as JSON: every product, every variant, SKUs, prices, compare-at prices and whether each size is in stock. No login, no API key, no HTML parsing. Shopify serves it to every visitor (unless the store turns it off).
That makes Shopify price monitoring much simpler than scraping product pages. This post shows how to turn that feed into a clean export and a daily price-drop alert, and where its limits are.
What the feed gives you (and what it does not)
| Data | Endpoint | Limit |
|---|---|---|
| Products and variants | /products.json?limit=250&page=N |
250 per request; at most 100 pages, so 25,000 products per catalog or collection |
| One collection | /collections/<handle>/products.json |
same |
| Collections | /collections.json |
250 per request |
| Store facts | /meta.json |
name, myshopify domain, default currency, country, product count |
Three things it does not give you:
-
Currency. products.json has prices but no currency field. You have to read it from the store page or
meta.json, and stores using Shopify Markets convert prices by visitor country. -
Stock quantities. You get
available: true/falseper variant, never "3 left". - Anything on headless storefronts. Stores built on Hydrogen (fashionnova.com in my tests) answer 404.
One clean row per product
I wrapped all of this in an Apify Actor, Shopify Product Scraper & Shopify Price Monitor, so you can paste store URLs, domains, collection URLs or product URLs and get a flat table. It also checks robots.txt first and waits one second between requests per store by default.
{
"stores": ["https://www.allbirds.com", "gymshark.com", "https://kith.com/collections/mens-footwear"],
"maxProductsPerStore": 300,
"onlyOnSale": true
}
A real row (30 September 2026), shortened:
{
"storeName": "Allbirds",
"title": "Women's Allbirds Flip Flop - Dusty Pink",
"url": "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
"vendor": "Allbirds",
"productType": "Shoes",
"price": 25,
"priceMax": 25,
"compareAtPrice": 50,
"onSale": true,
"discountPercent": 50,
"currency": "USD",
"currencySource": "store page",
"available": true,
"variantsCount": 7,
"variantsAvailable": 1,
"skus": ["A12513W050", "A12513W060", "A12513W070", "A12513W080", "A12513W090", "A12513W100", "A12513W110"],
"options": [{ "name": "Size", "values": ["5", "6", "7", "8", "9", "10", "11"] }],
"publishedAt": "2026-09-25T23:58:13.000Z",
"charged": true
}
That single row already answers a competitive question: a new product, published five days earlier, at half price, with only one of seven sizes left. discountPercent and variantsAvailable are computed for you; the raw variants array (SKU, price, compare-at, availability, weight per size) is in the row too. Set rowPer to variant if you want one spreadsheet line per size or colour; you still pay once per product.
Filters run before charging: keywords, excluded words, vendors, product types, tags, price range, in stock only, on sale only, published since a date, or only certain collections.
The real test run
Seven inputs, 300 products per store: allbirds.com, gymshark.com, a Kith collection, colourpop.com, one Tentree product URL, fashionnova.com (headless, no feed) and example.com (not Shopify).
Result: 1,201 products from 5 stores in 12.6 seconds, plus 2 free error rows. Cost on the Free plan: 1,201 × $0.001 = $1.20.
The two failures come back as rows that explain themselves, and are not charged:
{ "input": "https://www.fashionnova.com", "success": false, "errorType": "not_found",
"error": "products.json could not be read: HTTP 404 (not found) (the store has turned off its public product feed, or it is a headless storefront) (not charged)", "charged": false }
Turn it into a daily price monitor
A one-off export is useful once. Price monitoring needs a baseline and a diff. Turn on onlyChanges and give the watchlist a name:
{ "stores": ["https://www.tentree.com", "https://colourpop.com"], "onlyChanges": true, "monitorName": "competitors" }
- Run it once. This first run returns the products and saves them as the baseline.
- In Apify Console go to Schedules → Create, pick this Actor and input, and run it daily.
- Each later run returns only new and changed products, with what changed.
A changed row looks like this (this is the shape from the unit tests; in my real test, a second Tentree run two minutes later found 0 changes and cost nothing):
{
"title": "Women's Allbirds Flip Flop - Dusty Pink",
"price": 20,
"changeType": "changed",
"changes": ["price_drop", "back_in_stock"],
"previousPrice": 25,
"variantChanges": [
{ "title": "5", "change": "price,available", "price": 20, "previousPrice": 25, "available": true, "previousAvailable": false }
]
}
Change types: price_drop, price_increase, back_in_stock, out_of_stock, compare_at_price, variant_added, variant_removed. Products that disappear get a free "removed" row (only when the whole catalog was read with no filters, so "not read this time" is never reported as "removed"). Connect the Slack, email or webhook integration on the Actor and the changes arrive as alerts.
Two guards that kept my test alerts honest:
- Unchanged products are free. A daily check on a quiet store costs nothing.
-
Mass changes are held back. If more than half of a store's catalog (stores with at least 10 products) changes in one run, or the store moves to a different myshopify shop or domain, every row is still output but marked
suspiciousChange: true, nothing is charged for that store, and the old baseline is kept until you accept the new one. A store-wide currency switch should not be billed as thousands of "price changes".
Bonus: which of these sites run on Shopify?
Set mode to detect and paste domains:
{ "stores": ["allbirds.com", "gymshark.com", "kith.com", "example.com", "wikipedia.org"], "mode": "detect" }
{ "store": "https://gymshark.com", "isShopify": true,
"detectedBy": ["meta.json", "powered-by header", "Shopify theme script", "products.json"],
"storeName": "Gymshark US", "myshopifyDomain": "gymsharkusa.myshopify.com",
"currency": "USD", "country": "US", "productsCount": 10088, "collectionsCount": 526 }
Sites that are not Shopify stores are free, which makes this a cheap first filter on a lead list.
What it costs
| Event | Free / Starter | Scale | Business and higher |
|---|---|---|---|
| Product (all variants included) | $1.00 / 1,000 | $0.90 | $0.80 |
| Collection (collections mode) | $0.30 / 1,000 | $0.27 | $0.24 |
| Shopify store detected | $1.00 / 1,000 | $0.90 | $0.80 |
No start fee. Not charged: filtered-out products, unchanged products in monitoring mode, removed-product rows, suspicious change sets, non-Shopify sites, password-protected stores and stores without a public feed. Max products per store (default 1,000) and Max products (default 10,000) cap the run, and it logs its worst case before starting.
Limits worth knowing
- 25,000 products per catalog or collection is Shopify's own cap. For bigger stores, read collection by collection.
-
Prices follow the requester's country. Runs come from the US, so Shopify Markets stores usually answer in USD.
currencysays what the numbers are;storeCurrencyshows the store's default when it differs. - Some stores block data-center traffic or disable the feed; you get a free row saying which.
Terms and responsible use
This reads only the public storefront endpoints Shopify serves to every visitor, checks robots.txt first, and returns product data only: no customer, order or personal contact data. Product texts and images belong to the stores, so use the data for monitoring and analysis, not to republish their catalog as your own.
Actor: https://apify.com/tidytools/shopify-store-products-scraper
Disclosure: I built this Actor and earn from its usage on Apify. The stores above are public examples from my test runs of 30 September 2026 and have no connection to me.
Top comments (0)