Shopify Price & Stock Monitor – Change Tracker avatar

Shopify Price & Stock Monitor – Change Tracker

Pricing

Pay per event

Go to Apify Store
Shopify Price & Stock Monitor – Change Tracker

Shopify Price & Stock Monitor – Change Tracker

Monitors public Shopify catalogs and outputs only what changed since the last run, per variant: price and compare-at drops/rises, back in stock, out of stock, new and removed variants, title and SKU changes. No full-catalog re-scrape needed.

Pricing

Pay per event

Rating

0.0

(0)

Developer

Changefeeds Tools

Changefeeds Tools

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Share

For merchants tracking their own store and for anyone watching competitor Shopify shops, this Actor watches public Shopify catalogs and tells you what changed since the last run — one row per variant change: price drops and rises, compare-at ("was") price changes, back in stock, out of stock, new variants, removed variants, and title or SKU changes. You get a changefeed, not a catalog dump.

Catalog scrapers return the whole catalog every time, and you have to diff it yourself. This Actor keeps the previous run's snapshot for you and returns only the differences. Schedule it daily or hourly and read the dataset, or send it to a webhook.

Use cases

Price-drop and restock alerts to Slack, without writing code. Create a task with the store list you want to watch, add an hourly or daily Schedule in Apify Console, then attach an Apify integration on the task's "run succeeded" event — Slack, Zapier, Make, or Google Sheets are all built in. Each run's dataset holds only the rows that changed since the previous run (price_changed, back_in_stock, etc.), so the integration only fires on real changes, not a full catalog every time. Point the same integration at multiple stores by running one task per store, or one task with all your stores in storeUrls and filter the dataset rows downstream by store. Prefer a direct push instead of polling the dataset? Set webhookUrl and skip the integration step — the run POSTs its own summary and up to 500 changes as soon as it finds any.

What it does

  1. For each store you list, it reads Shopify's public catalog endpoint, https://<store>/products.json, 250 products per request. If that returns 404 it tries /collections/all/products.json.
  2. It turns every product into variant rows: store, product id, handle, title, URL, variant id, variant title, SKU, price, compare-at price, availability, and updated_at.
  3. It compares those rows with the snapshot saved by the previous run of the same input, and outputs one row per change.
  4. It saves the new snapshot for next time.

The first run for a store has nothing to compare with. It outputs every variant with change_type: "baseline" and saves the snapshot. Changes start from the second run.

Change types

change_typeMeaning
price_changedprice differs; before, after, pct_change
compare_at_changedcompare_at_price differs, including set or cleared; pct_change when both sides have a value
back_in_stockavailable went from false to true
out_of_stockavailable went from true to false
new_varianta variant id not in the previous snapshot
removed_variantin the previous snapshot, absent from this run's complete fetch
title_changedproduct title (field: "product_title") or variant title (field: "variant_title") differs
sku_changedSKU differs
baselinefirst run for this store: current values, no comparison
unchangedonly with includeUnchanged: true
store_errorthis store could not be read (see error_code)

A variant with several changes in one run produces several rows, one per field.

Input

{
"storeUrls": ["https://www.allbirds.com", "https://www.tentree.com"],
"maxProductsPerStore": 5000,
"trackFields": ["price", "compare_at_price", "available", "title", "sku"],
"snapshotKey": "",
"webhookUrl": "",
"includeUnchanged": false
}
  • storeUrls (required): store base URLs. Any path is ignored. allbirds.com and https://allbirds.com/collections/x both mean the same store.
  • maxProductsPerStore (default 5,000, maximum 50,000): the most products read per store per run.
  • trackFields: which fields produce change rows. New and removed variants are always reported.
  • snapshotKey: which saved snapshot to compare against. If you leave it empty, it is derived from the sorted store list, so a schedule with the same input always compares with its own previous run. Set it when you want to add or remove stores without starting over. Stores that are new to the key get a baseline, and the others keep comparing.
  • webhookUrl: when a run finds changes, the run summary and the first 500 changes are POSTed here as JSON, with a 10-second timeout. A baseline-only run does not call it. If the POST fails, the failure is logged and recorded in the summary, and the run still succeeds.
  • includeUnchanged: also output a row for every variant that did not change.

If a run finds nothing new, the dataset holds one no_changes row (not charged), so a quiet run is never mistaken for a broken one.

Sample output

A change row (dataset item). The shape is exact; the values are illustrative:

{
"change_type": "price_changed",
"store": "www.tentree.com",
"product_id": "8360516550842",
"product_handle": "chambers-pocket-shirt-nightfall-midnight-blue",
"product_title": "Chambers Pocket Shirt",
"product_url": "https://www.tentree.com/products/chambers-pocket-shirt-nightfall-midnight-blue",
"variant_id": "45724486140090",
"variant_title": "NIGHTFALL MIDNIGHT BLUE / S",
"sku": "TCM6530-6325-S",
"price": 70.4,
"compare_at_price": 88,
"available": true,
"updated_at": "2026-09-28T20:55:54-07:00",
"field": "price",
"before": 88,
"after": 70.4,
"pct_change": -20,
"previous_run_at": "2026-09-28T03:56:00.489Z",
"run_at": "2026-09-29T03:56:00.489Z"
}

Prices are numbers in the store's own currency, exactly as products.json reports them. products.json does not say which currency that is, so the Actor does not guess.

A store that cannot be read gives one error row, and the other stores still run:

{
"change_type": "store_error",
"store": "www.example-store.com",
"error_code": "blocked",
"error_message": "HTTP 403: the store refused the public catalog request",
"partial": false,
"run_at": "2026-09-29T03:56:00.489Z"
}

error_code is one of not_shopify, password_protected, blocked, rate_limited, http_error, network_error. partial: true means some pages were read before the error. Changes from those pages are still reported, but removals are not.

The run summary is saved to the default key-value store as OUTPUT:

{
"run_at": "…",
"finished_at": "…",
"snapshot_key": "stores-d196c1e1e34ba01e6de5ede9cc4c094b",
"first_run": false,
"products_checked": 600,
"variants_checked": 4536,
"changes_total": 12,
"changes_by_type": { "price_changed": 3, "compare_at_changed": 1, "back_in_stock": 2, "out_of_stock": 4,
"new_variant": 2, "removed_variant": 0, "title_changed": 0, "sku_changed": 0 },
"baseline_rows": 0,
"errors": [{ "store": "…", "code": "blocked", "message": "…" }],
"truncated_stores": [{ "store": "…", "reason": "max_products" }],
"charge_limit_reached": false,
"requests_total": 5,
"stores": [{ "store": "…", "status": "complete", "first_run": false, "products_fetched": 300,
"products_checked": 300, "variants_checked": 1200, "changes": 12, "requests": 2, "removals_checked": true, "…": "…" }],
"webhook": null
}

What it costs

$1.50 per 1,000 products checked ($0.0015 per product), plus Apify's standard actor-start fee ($0.00005 per run, scaled by memory — negligible next to the per-product charge).

  • A product is charged once per run when its variants are compared, however many variants it has.
  • The first (baseline) run is charged the same way. Unchanged products count too, because checking them is the work.
  • Changes are included and not charged extra. Neither are store errors or the webhook.
  • A product that appears twice in one run is charged once.

Worked examples:

  • First (baseline) run, 1 store, 800 products: 800 products × $0.0015 = $1.20, plus the start fee. Every product comes back as change_type: "baseline"; there's nothing to compare yet.
  • Steady-state daily run, 3 stores at 2,000 products each: 6,000 products checked a run × $0.0015 = $9.00/run. Scheduled once a day, that's ~$270/month (30 runs), whatever the mix of price drops, restocks or quiet days — you're paying to check, not per change found.
  • Hourly monitoring, 1 store, 500 products: 500 × $0.0015 = $0.75/run × 24 runs/day × 30 days = ~$540/month. Drop to a 4×-daily schedule for the same coverage window at ~$90/month.

Read products_checked in the run summary for your actual per-run count.

Set a maximum cost per run in Apify, and the Actor will respect it. When the budget is used up, it stops fetching and saves what it has. It marks the affected stores as truncated (reason: "charge_limit") and sets charge_limit_reached: true in the summary. It never charges past your limit.

Scheduling

Changes only mean something when the same input runs repeatedly:

  1. Create a saved task for this Actor with your store list (Actor page → Create task).
  2. In Apify Console, open Schedules → Create new, pick a cron such as 0 7 * * * (daily at 07:00 UTC) or 0 * * * * (hourly), and add your task.
  3. Each scheduled run compares with the previous one. Read the run's dataset, set webhookUrl, or use Apify integrations (Slack, Zapier, Make, email) on the "run succeeded" event.

Keep the store list unchanged, or set a fixed snapshotKey, so runs keep comparing with the same snapshot.

Limits

  • Public catalog only. The Actor reads only products.json, the catalog that Shopify storefronts publish to everyone. It never logs in, never touches cart or checkout, and collects no personal data.
  • Some stores hide products.json, block it (HTTP 403), or are password protected. Those stores give a store_error row. There is no workaround in this Actor, by design.
  • Only products published to the Online Store appear in products.json. Hidden or channel-only products are not visible.
  • Removal detection needs a complete fetch. removed_variant is reported only when the whole catalog was read in this run. If a store stops at maxProductsPerStore, at your cost limit, or on an error, removals are not reported for that store, and the summary says so. Changes among the variants that were read are still reported, and the snapshot keeps the unseen variants for the next complete run.
  • Stores larger than maxProductsPerStore are read in catalog order, and only the first N products are compared. If products move in or out of the first N between runs, they can appear as new_variant. Raise the limit to cover the whole catalog if that matters to you.
  • It is polite, which makes it slower on big catalogs. Each store is read one request at a time, at least 1 second apart, and HTTP 429 Retry-After is honored. That is about 250 products per second per store at best. Up to three stores are read in parallel.
  • Currency is whatever the store's default is. products.json does not include it.
  • Inventory counts are not in the public catalog. Only in stock or out of stock is available.

FAQ

Is this allowed? The Actor reads the same public JSON catalog that Shopify serves to every visitor, at about one request per second, and identifies itself in its User-Agent. You are responsible for your own use of the data and for respecting each store's terms.

Why did my first run return every variant? That is the baseline. Changes start from the second run of the same input.

I edited my store list and everything is baseline again. The default snapshot key comes from the store list. Set snapshotKey to a fixed name to keep history when you edit the list.