You want every product from a Shopify store in a script or a spreadsheet: titles, variants, SKUs, prices, whether each size is in stock. You do not need a browser or a scraping framework. Nearly every storefront serves its public catalog as JSON at /products.json, the same data its own theme reads.
The endpoint
GET https://<store>/products.json?limit=250&page=1 returns {"products": [...]}. 250 is the maximum page size: I asked a large store for limit=300 and got 250 rows back. Each product has title, handle, vendor, product_type, tags, body_html, images, options and a variants list. Each variant has sku, price, compare_at_price, available and up to three option values. Prices are strings such as "15.00", not numbers.
Page through the whole catalog
import json, time, urllib.error, urllib.request STORE = "https://www.deathwishcoffee.com" HEADERS = {"User-Agent": "catalog-export-example/1.0 (personal script)"} def get(url): for attempt in range(3): try: with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS), timeout=30) as r: return json.load(r) except urllib.error.HTTPError as e: if e.code != 429 or attempt == 2: raise time.sleep(int(e.headers.get("Retry-After", 30))) # honour the back-off def all_products(store): for page in range(1, 101): # page 101 is refused (25,000 cap) batch = get(f"{store}/products.json?limit=250&page={page}")["products"] yield from batch if len(batch) < 250: # a short page is the last page return time.sleep(1) if __name__ == "__main__": products = list(all_products(STORE)) print(len(products), "products") p = products[0] print(p["title"], "|", p["vendor"], "|", p["updated_at"]) sale = [(p["title"], v["price"], v["compare_at_price"]) for p in products for v in p["variants"] if v["compare_at_price"]] print(len(sale), "variants on sale, e.g.", sale[0]) Output from one run:
168 products Valhalla Java Dark Chocolate Bark | Ethereal Confections | 2026-11-11T19:42:28-05:00 240 variants on sale, e.g. ('Power Surge Ground Bundle', '52.99', '57.99') A page shorter than 250 is the last page. compare_at_price is the "was" price: when it is set and higher than price, the variant is on sale. available is a boolean for the variant, not a stock count.
Past 25,000 products
The listing refuses any request where page * limit exceeds 25,000. Asking for page 101 at limit=250 returns HTTP 400 with {"errors":"Page * Limit exceeds the 25000 limit."}, and a smaller limit does not help because the cap is on the product. The since_id parameter that older guides suggest is ignored by this endpoint; it just returns page 1 again.
For a bigger store, the way I know of is the product sitemap (/sitemap.xml) plus one request per product to /products/<handle>.js. That is slow and, as noted below, the per-product endpoint is touchy.
Detect price and stock changes between two pulls
Key everything by variant id, save a snapshot, and compare on the next run. Save the first script as shopify_export.py; this one imports all_products from it.
import json from shopify_export import all_products, STORE def snapshot(products): return {str(v["id"]): {"name": f'{p["title"]} / {v["title"]}', "price": v["price"], "was": v["compare_at_price"], "available": v["available"]} for p in products for v in p["variants"]} new = snapshot(all_products(STORE)) try: old = json.load(open("last.json")) except FileNotFoundError: old = {} if old: for vid in new.keys() - old.keys(): print("new ", new[vid]["name"]) for vid in old.keys() - new.keys(): print("removed ", old[vid]["name"]) for vid in new.keys() & old.keys(): a, b = old[vid], new[vid] if (a["price"], a["was"]) != (b["price"], b["was"]): print("price ", b["name"], f'{a["price"]} (was {a["was"]}) -> {b["price"]} (was {b["was"]})') if a["available"] != b["available"]: print("stock ", b["name"], "back in stock" if b["available"] else "sold out") json.dump(new, open("last.json", "w")) print(len(new), "variants saved") I ran it once, edited last.json by hand to simulate a day passing (deleted two entries, changed a price, flipped a stock flag), and ran it again:
new Yule Cat Mug / Default Title removed Ghost / x stock Nine Lives Pet Bandana / Default Title back in stock price Valhalla Java Dark Chocolate Bark / 1 Pack 20.00 (was None) -> 15.00 (was None) 453 variants saved Compare variants, not products. A product counts as in stock if any variant is, so a sold-out size is invisible at product level. Do not use updated_at as a price-change signal either: it moves on many edits, stock changes included.
Limits and gotchas
- Rate limits are real. After a handful of requests within a couple of minutes, one store answered
/products.jsonwith HTTP 429. It cleared in under two minutes. The snippet honoursRetry-Afterand sleeps a second between pages. - The per-product
.jsendpoint is fragile. It carriesbarcodeand, on some stores,inventory_quantity, which/products.jsonlacks. During my test it returned429 local_rate_limitedwithretry-after: 60for handles that exist, on two different stores. Shopify uses that same response for handles that do not exist, so a 429 there is ambiguous. - No stock counts in the listing. You get
availabletrue or false. - Some stores return an empty page early, before the cap. Stop at the first short page and dedupe by product id, since the list is newest-first and can shift while you read.
- Not every store answers. Password-protected stores and headless storefronts that do not expose the endpoint will not work, and a store behind bot protection is a "no". Do not try to get around it.
Stick to public product data, keep the request rate low, and check the store's terms before you reuse its content.
If you don't want to maintain this yourself
Disclosure: I'm the developer of an Apify Actor, Shopify Store Scraper, published under Cedarfetch Labs. It reads the same public endpoints and adds three things this post leaves to you: change tracking between scheduled runs (new, price changed, back in stock, out of stock, removed), automatic continuation past 25,000 products through the sitemap, and a flat one-row-per-variant mode for spreadsheets.
It is pay per event. On the listing as of 2026-11-11 that is $0.0008 per product row, $0.0001 per variant row in variants mode, and $0.00005 per run start; check the Pricing tab for the current figures. Listing: https://apify.com/cedarfetch/shopify-store-scraper
Runnable Python, Node.js and cURL examples for the Actor: https://github.com/cedarfetch/shopify-store-scraper-examples
Not affiliated with Shopify.