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:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. 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: 5 to 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.

⚠️ includeInventory only 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 is null.

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.json page 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:

  • changeType and changes: one of baseline, new_product, new_variant, removed_product, removed_variant, price_drop, price_increase, back_in_stock, sold_out, sale_changed, unchanged, summary or error. A variant that dropped in price and came back in stock lists both in changes; changeType holds the first.
  • price, previousPrice, priceChange, priceChangePct: on change rows, numbers in the store's currency, never converted.
  • compareAtPrice: the store's compare-at price; above price means on sale. sale_changed fires 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.
  • scanComplete on the summary row: false when the read was capped, so no removals were reported.
  • currency: as the store presented it to the run. Kith's /cart.js answered EUR to a request on 2026-10-12 while its /meta.json said USD.

Pro tips

  1. Baseline once, then schedule. The first run per store outputs the catalogue; later runs only the changes. Keep stateStoreName the same between runs, because the snapshot lives there, in a named key-value store, which Apify retains indefinitely.
  2. Filter noise, keep stock. minPriceChangePercent hides small price-only moves, but a variant that sold out still shows up.
  3. Read whole catalogues when removals matter. Raise maxProductsPerStore above the store's size; only a complete read (scanComplete: true) reports removed_product.
  4. Batch stores in one run. Each store in storeUrls keeps its own snapshot; a store that fails gives an error row and the others still complete. Read the summary rows first: changeCounts tells you per store which change types arrived, before you look at a single variant.
  5. Turn on inventory only when you need it. includeInventory makes one extra request per product in the output (every product on a baseline run), capped by maxInventoryLookups.

Troubleshooting

  • An error row 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 error rows. A store whose feed returns no variants also gives an error row.
  • Run stops at once: storeUrls was empty, the collection handle had characters other than letters, digits, - and _, or stateStoreName names a key-value store already in your account that the Actor is not allowed to open; pick another name.
  • No removals ever: check scanComplete on 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! 💬