If your competitors sell on Shopify, their whole catalogue, with every variant's price, compare-at ("was") price and in-stock flag, is usually one URL away: https://<store>/products.json. Open it in a browser and you'll see JSON. That makes Shopify one of the easiest platforms to monitor, as long as you do it politely and handle the stores that turn the feed off.
This post covers how the feed works, how to track price changes over time, and two ready-made ways to run it daily from Python.
Disclosure: I'm Aurelix, and I built the two Shopify Actors used below. They're paid tools (prices in the Cost section).
What price tracking needs
Most Shopify stores publish their catalogue, with every variant's price, compare-at price and stock flag, which is what makes daily competitor tracking possible. A useful tracker then needs five things:
- Fetch the full catalogue of each competitor on a schedule (daily is typical).
- Key each variant by its variant ID, not by title. Titles change; IDs don't.
- Compare with the previous snapshot: price moved, compare-at price appeared (a sale started), all variants went unavailable (stock-out), or came back (restock).
- Match competitor products to yours so a price move becomes an actionable line ("they're now $5 under us on X").
- Alert on the changes only. Nobody reads a 10,000-row daily dump.
The two Actors do steps 1 to 3 for you: they read each store's catalogue (skipping stores that disable it, password-protect it or disallow it in robots.txt), key everything by variant ID and flag what changed since the last run. Step 4 depends on your catalogue, and there's code for it below.
Option A: The full catalogue with price-change flags
The Shopify Products Scraper Actor returns every product with all variants. Input:
-
storeUrls(required): domains or any URL on the store; only the domain is used. -
maxProductsPerStore(0 = whole catalogue). -
incremental: return only products that are new or changed (price, compare-at price, stock, SKU, variants, title or tags) since the previous run on the same store. -
includeCollections,maxCollections,includeDescription,stateKey.
Every run compares with the previous run on the same store, so the price-change flags are filled whether or not incremental is on. A product record (trimmed to two variants):
{ "product_key": "allbirds.com:7366895894608", "product_id": 7366895894608, "store_domain": "allbirds.com", "store_name": "Allbirds", "store_url": "https://allbirds.com", "title": "Women's Wool Runner - Natural Grey (Light Grey Sole)", "handle": "womens-wool-runners-natural-grey", "vendor": "Allbirds", "product_type": "Shoes", "currency": "USD", "price_min": 49, "price_max": 49, "compare_at_price_max": 110, "on_sale": true, "available": true, "variants_count": 7, "variants": [ { "variant_id": 42424464900176, "title": "5", "sku": "WR3WNCW050", "price": 49, "compare_at_price": 110, "available": true, "options": ["5"], "url": "https://allbirds.com/products/womens-wool-runners-natural-grey?variant=42424464900176", "previous_price": null }, { "variant_id": 42424464932944, "title": "6", "sku": "WR3WNCW060", "price": 49, "compare_at_price": 110, "available": false, "options": ["6"], "url": "https://allbirds.com/products/womens-wool-runners-natural-grey?variant=42424464932944", "previous_price": null } ], "product_url": "https://allbirds.com/products/womens-wool-runners-natural-grey", "collections": ["All", "All Products"], "change_type": "unchanged", "price_changed": false, "previous_price_min": null, "source_url": "https://allbirds.com/products.json", "scraped_at": "2026-11-04T06:24:01Z" } price_changed and each variant's previous_price are the fields to watch.
Option B: A change report
The Shopify Best Sellers Tracker Actor does the comparison and returns only what changed, one row per event, with a record_type of store_summary, best_seller, new_product, price_change, stock_out or restock. Input: storeUrls, maxBestSellers, newWithinDays, maxProductsPerStore, stateKey.
Price-change rows carry previous_price_min/previous_price_max, the current price_min/price_max, price_direction (up, down or mixed), price_change_pct and variant_changes. A best-seller row looks like this:
{ "record_type": "best_seller", "store_domain": "notebooktherapy.com", "store_name": "NotebookTherapy", "store_url": "https://notebooktherapy.com", "currency": "USD", "rank": 3, "product_key": "notebooktherapy.com:15474992775537", "product_id": 15474992775537, "title": "Tsuki ‘Matcha Bloom’ Travel Notebook Gift Set ☾", "handle": "tsuki-matcha-bloom-travel-notebook-gift-set", "vendor": "NotebookTherapy", "product_type": "Gift Sets", "product_url": "https://notebooktherapy.com/products/tsuki-matcha-bloom-travel-notebook-gift-set", "image_url": "https://cdn.shopify.com/s/files/1/2026/1775/files/01-matcha_bloom_set-carousel-2000.jpg?v=1774956451", "price_min": 74.78, "price_max": 82.13, "compare_at_price_max": 92.36, "available": true, "created_at": "2026-03-20T16:46:46Z", "published_at": "2026-03-31T12:02:35Z", "first_seen_at": "2026-11-04T04:50:32Z", "previous_price_min": null, "previous_price_max": null, "price_direction": null, "price_change_pct": null, "variant_changes": [], "best_sellers_source": "store's own Best Sellers collection: best-selling-products", "catalogue": null, "changes": null, "first_run": null, "partial": null, "note": null, "source_url": "https://notebooktherapy.com/products/tsuki-matcha-bloom-travel-notebook-gift-set", "scraped_at": "2026-11-04T11:20:24Z" } Use A when you want the whole catalogue in your own database (for matching and analysis). Use B when you want a short daily "what changed at my competitors" report.
The script: daily report plus matching to your catalogue
This runs the change report for a list of competitors and prints price moves, stock-outs and restocks. Then it pulls the full catalogue of each competitor and compares prices against a small mapping file of your own products.
matches.csv is something you maintain by hand or semi-automatically:
my_sku,my_price,competitor_domain,competitor_handle WOOL-RUN-GREY,59.00,allbirds.com,womens-wool-runners-natural-grey pip install apify-client import csv import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) COMPETITORS = ["allbirds.com", "notebooktherapy.com"] def run_actor(actor_id, run_input): run = client.actor(actor_id).call(run_input=run_input) return list(client.dataset(run["defaultDatasetId"]).iterate_items()) def change_report(): rows = run_actor( "aurelix/shopify-best-sellers-tracker", {"storeUrls": COMPETITORS, "maxBestSellers": 10, "stateKey": "competitors-daily"}, ) for r in rows: kind = r.get("record_type") if kind == "price_change": print(f"[price {r.get('price_direction')}] {r['store_domain']}: {r['title']} " f"{r.get('previous_price_min')} -> {r.get('price_min')} {r.get('currency')} " f"({r.get('price_change_pct')}%)") elif kind in ("stock_out", "restock", "new_product"): print(f"[{kind}] {r['store_domain']}: {r['title']} {r.get('product_url')}") return rows def load_matches(path="matches.csv"): with open(path, newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def compare_to_mine(matches): domains = sorted({m["competitor_domain"] for m in matches}) products = run_actor( "aurelix/shopify-products-scraper", { "storeUrls": domains, "maxProductsPerStore": 0, "includeCollections": False, "includeDescription": False, "stateKey": "price-match", }, ) index = {(p["store_domain"], p["handle"]): p for p in products} with open("price_comparison.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["my_sku", "my_price", "competitor", "their_price", "their_was", "on_sale", "in_stock", "gap", "price_changed", "url"]) for m in matches: p = index.get((m["competitor_domain"], m["competitor_handle"])) if not p: writer.writerow([m["my_sku"], m["my_price"], m["competitor_domain"], "", "", "", "", "", "", "not found"]) continue my_price = float(m["my_price"]) their = p.get("price_min") gap = round(their - my_price, 2) if their is not None else "" writer.writerow([ m["my_sku"], my_price, p["store_domain"], their, p.get("compare_at_price_max"), p.get("on_sale"), p.get("available"), gap, p.get("price_changed"), p.get("product_url"), ]) if their is not None and their < my_price: print(f"[undercut] {m['my_sku']}: {p['store_domain']} at {their} vs ours {my_price}") if __name__ == "__main__": change_report() compare_to_mine(load_matches()) Notes:
- Match on handle, keep IDs in mind. The handle is the product's URL slug, which is easy to copy from a browser, so it's the friendliest key for a hand-made mapping. If a competitor renames a product, its handle can change; the "not found" row tells you to update the mapping.
-
price_minvs per-variant prices. For products whose sizes or colours are priced differently, compare variant by variant usingvariants[].skuorvariants[].titleinstead ofprice_min. - Separate
stateKeys. The spy run and the catalogue run each keep their own "previous run" memory, so testing one doesn't reset the other. - Currency. Both sides should be in the same currency. Prices come in the store's default currency, which may not be yours.
Limits
- Only stores that leave the public feed on. Stores that disable
products.json, password-protect it, block automated visitors or disallow it in robots.txt are skipped and named in the run status (and cost nothing per product). - Stock is a flag, not a quantity. Shopify's public feed doesn't give inventory counts, and the tools never guess them.
- Currency comes from the store's public metadata; stores that hide it return
currency: null. - Changes are measured between your runs. A price that changed and changed back between two runs isn't seen, and the first run has nothing to compare with. For the change report, price changes, stock-outs and restocks start from the second run.
- Best-sellers depend on what the store makes public. Most stores disallow the best-selling sort in robots.txt, so the store's own best-seller collection is used, and some stores have none.
rankis the store's own order, not sales figures. - Large catalogues. The change report reads up to Shopify's public limit of 25,000 products per store and marks the summary
partialwhen it hits that cap. Collection tags in the catalogue Actor cover the first 20 collections by default. - Removed products aren't reported by the change report.
- Rate limits. Some stores answer busy addresses with HTTP 429. The Actors wait and retry; if a store keeps refusing, what was read is kept and the run is marked partial. An optional Apify Proxy setting is available for stubborn stores.
Why these tools
- Shopify Products Scraper: $0.90 per 1,000 products ($0.63 paid), cheaper than the leading tools.
- Shopify Products Scraper: Barcodes (GTIN) and public stock counts included at no extra charge; price-change flags and a new-or-changed feed. It doesn't cover SEO fields and metafields.
- Shopify Best Sellers Tracker: No platform usage on top; product rows at $3.00 per 1,000.
- Shopify Best Sellers Tracker: Reports stock-outs, restocks and per-variant old and new prices between runs. It doesn't cover tech stack, contacts or traffic estimates.
Cost
At the time of writing, from each README's pricing section:
- Shopify Products Scraper: $0.90 per 1,000 products on Apify's free plan, $0.63 on paid plans (Bronze, Silver, Gold), plus $0.001 per run.
- Shopify Best Sellers Tracker: $3.00 per 1,000 rows on the free plan, $2.10 on paid plans, plus $0.015 per store checked (its summary row, same on every plan) and $0.0005 per run.
Stores that block or hide their feed cost nothing beyond the start fee. Watching 5 competitors daily with the change report costs $0.075 a day in store summaries plus the rows returned; with 10 best-sellers per store and a quiet day, that's about 50 rows, or $0.15. Pulling a 2,000-product catalogue costs $1.80 per run on the free plan, so for the matching step a weekly run is usually enough.
