By the end of this tutorial, you will have a short Python script that watches Shopify stores and returns only what changed since your last run: price moves, restocks, sold-out variants, new and removed products, and sales starting or ending.
The Actor reads a store's public catalogue feed, /products.json. On 2026-10-12, allbirds.com, kith.com and colourpop.com all answered that feed with JSON, while www.gymshark.com answered HTTP 403 with a "Request blocked" page. Reading the feed is easy; diffing it against yesterday per variant is the part that takes code.
We use Python and the Apify Actor Shopify Store Monitor (reventadata/shopify-scraper): a first baseline run, then runs that return only changes. The sample row below is a sample from a capture dated 2026-10-12, parsed with the Actor's own code.
⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.
Prerequisites
To follow this tutorial, you will need:
- Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- An Apify account and its API token, which sits on the API & Integrations page in Apify Console.
Install the client:
pip install "apify-client~=3.0" The code reads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here" What you'll achieve
You will get a change feed for a list of Shopify stores. The first run per store saves a snapshot and outputs one baseline row per product, with every variant (size, colour) nested inside it: variantId, sku, price, compareAtPrice, available and a direct cartUrl. Every later run compares the store with that snapshot and outputs one row per changed variant, plus one summary row per store.
Note: The Actor is unofficial and not affiliated with Shopify Inc. It reads the same public catalogue data a browser does; no login, no customer data.
Example use case: Suppose you sell running shoes and want to know when a competitor marks a model down or a size comes back in stock. Put their store in storeUrls, run the Actor once a day, and each run's dataset holds only the price_drop, back_in_stock and other change rows since the day before.
Step 1: Access Shopify Store Monitor
Go to the Shopify Store Monitor page on Apify Store. The README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.
Step 2: Configure the stores
The one required input is storeUrls: a list of store addresses such as https://allbirds.com. A bare domain works too; the Actor adds https://, and www.allbirds.com and allbirds.com share one snapshot. There is no keyword search: the Actor watches whole stores, or one collection with collection.
maxProductsPerStore (default 2000) caps how many products each store's read covers. Keep it low while you test.
Here is the setup. It watches allbirds.com and reads at most 20 products, the README's example input:
import os from apify_client import ApifyClient # 1. Initialize ApifyClient with the token from the environment client = ApifyClient(os.environ["APIFY_API_TOKEN"]) # 2. Define the watch-list: one store, at most 20 products run_input = { "storeUrls": ["https://allbirds.com"], "maxProductsPerStore": 20, } 💡 Tip: a capped read still reports price and stock changes, but it cannot report removed products. The Actor only reports removals after reading the whole catalogue.
Step 3: Add optional settings
| Parameter | Type | What it does |
|---|---|---|
collection | string | Watch one collection, e.g. new-arrivals from /collections/new-arrivals. It keeps its own snapshot. |
minPriceChangePercent | number | Hide price-only moves smaller than this percent. Stock changes are never hidden. |
includeUnchanged | boolean | Also output variants that did not change (changeType: "unchanged"). |
emitBaseline | boolean | Off: the first run saves the snapshot without outputting product rows. |
includeInventory | boolean | Add inventoryQuantity and barcode from the detail page of each product in the output. |
maxInventoryLookups | integer | Cap on those detail lookups per store (default 200). |
stateStoreName | string | The named key-value store holding the snapshots. A different name = a separate watch-list. |
resetState | boolean | Ignore the saved snapshot and start over with a fresh baseline. |
Three combinations that work:
- Markdown watch:
minPriceChangePercent: 5to skip small price moves. - New arrivals only:
collection: "new-arrivals". - Two separate watch-lists: the same stores with
stateStoreName: "competitors"in one task and"suppliers"in another.
⚠️
includeInventoryonly fills numbers a store publishes. On a product page captured 2026-10-12, allbirds.com published quantities per size; kith.com published a barcode but no quantity, so that field isnull.
Step 4: Run the Actor
Now start the run with client.actor("reventadata/shopify-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:
# 3. Start the Actor run and wait for it to finish print("Checking stores for changes...") run = client.actor("reventadata/shopify-scraper").call(run_input=run_input) if run is None or run.status != "SUCCEEDED": status = run.status if run else "unknown" raise SystemExit(f"Run did not succeed (status: {status}).") # 4. Fetch the rows: baselines, changes, summaries and errors items = client.dataset(run.default_dataset_id).list_items().items print(f"Retrieved {len(items)} rows.\n") Behind the scenes:
- The Actor reads the store's currency from
/cart.js, falling back to/meta.json. - It walks
/products.jsonpage by page and flattens each product into variant rows. If page 1 is refused or missing, it falls back to the sitemap and each product's own JSON. - It loads the store's snapshot, diffs, saves the rows and a summary, and saves the new snapshot.
Run the same script again and the second run is a diff, not a baseline.
Step 5: View and download results
Every row has a changeType, so one loop handles all of them:
for item in items: kind = item["changeType"] if kind == "summary": print(f"{item['storeDomain']}: {item['mode']}, {item['productsScanned']} products, " f"complete={item['scanComplete']}, changes={item['changeCounts']}") elif kind == "error": print(f"{item['storeDomain']}: ERROR {item['error']}") elif kind == "baseline": in_stock = sum(1 for v in item["variants"] if v["available"]) print(f"{item['title']}: {in_stock} of {len(item['variants'])} variants in stock") else: print(f"{kind}: {item['title']} / {item.get('variantTitle')}: " f"{item.get('previousPrice')} -> {item.get('price')} {item.get('currency') or ''}") Here is one baseline row, a sample from a capture dated 2026-10-12 (allbirds.com's /products.json) parsed with the Actor's own code. It is shortened to 3 of its 16 tags and 2 of its 7 variants, and currency is what the store's /meta.json reported that day. A saved row also carries scrapedAt, added at save time:
{ "changeType": "baseline", "changes": [], "storeDomain": "allbirds.com", "currency": "USD", "productId": 7340901859408, "handle": "womens-allbirds-flip-flop-dusty-pink", "title": "Women's Allbirds Flip Flop - Dusty Pink", "vendor": "Allbirds", "productType": "Shoes", "tags": [ "allbirds::carbon-score => undefined", "allbirds::cfId => color-a4d5d0f6620dc304306f503bf67bc5f9", "allbirds::complete => true" ], "url": "https://allbirds.com/products/womens-allbirds-flip-flop-dusty-pink", "imageUrl": "https://cdn.shopify.com/s/files/1/1104/4168/files/A12513_26Q2_Allbirds-Flip-Flop-Dusty-Pink_PDP_LEFT.png?v=1774646345", "productUpdatedAt": "2026-10-12T00:03:45-07:00", "variants": [ { "variantId": 42146889171024, "variantTitle": "9", "sku": "A12513W090", "price": 25.0, "compareAtPrice": 50.0, "available": false, "cartUrl": "https://allbirds.com/cart/add?id=42146889171024" }, { "variantId": 42146889203792, "variantTitle": "10", "sku": "A12513W100", "price": 25.0, "compareAtPrice": 50.0, "available": true, "cartUrl": "https://allbirds.com/cart/add?id=42146889203792" } ] } Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Understanding your results
The fields that matter most:
-
changeTypeandchanges: one ofbaseline,new_product,new_variant,removed_product,removed_variant,price_drop,price_increase,back_in_stock,sold_out,sale_changed,unchanged,summaryorerror. A variant that dropped in price and came back in stock lists both inchanges;changeTypeholds the first. -
price,previousPrice,priceChange,priceChangePct: on change rows, numbers in the store's currency, never converted. -
compareAtPrice: the store's compare-at price; abovepricemeans on sale.sale_changedfires when a sale starts or ends, or the compare-at price moves while the price stays. In the allbirds capture, 7 of 72 variants had one, all sizes of the flip flop above (25 against 50). -
available: in stock or not, per variant. The catalogue feed has no quantities. In the same capture, 24 of 72 variants were in stock. -
scanCompleteon thesummaryrow:falsewhen the read was capped, so no removals were reported. -
currency: as the store presented it to the run. Kith's/cart.jsansweredEURto a request on 2026-10-12 while its/meta.jsonsaidUSD.
Pro tips
- Baseline once, then schedule. The first run per store outputs the catalogue; later runs only the changes. Keep
stateStoreNamethe same between runs, because the snapshot lives there, in a named key-value store, which Apify retains indefinitely. - Filter noise, keep stock.
minPriceChangePercenthides small price-only moves, but a variant that sold out still shows up. - Read whole catalogues when removals matter. Raise
maxProductsPerStoreabove the store's size; only a complete read (scanComplete: true) reportsremoved_product. - Batch stores in one run. Each store in
storeUrlskeeps its own snapshot; a store that fails gives anerrorrow and the others still complete. Read thesummaryrows first:changeCountstells you per store which change types arrived, before you look at a single variant. - Turn on inventory only when you need it.
includeInventorymakes one extra request per product in the output (every product on a baseline run), capped bymaxInventoryLookups.
Troubleshooting
- An
errorrow with HTTP 403: the store refuses automated requests on every path. Proxy is off by default; whether Apify Proxy gets past such a store has not been tested. - Run FAILED: every store in the list failed; read the
errorrows. A store whose feed returns no variants also gives anerrorrow. - Run stops at once:
storeUrlswas empty, thecollectionhandle had characters other than letters, digits,-and_, orstateStoreNamenames a key-value store already in your account that the Actor is not allowed to open; pick another name. - No removals ever: check
scanCompleteon the summary row; a capped read never reports them. - Password-protected store: not supported.
For help, use the Actor's Store page.
Pricing and next steps
For current pricing and details, see the Store page.
To repeat the check, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId, so your handler can read the change rows.
Which stores are you planning to watch? Drop a comment! 💬