# Shopify Price & Stock Monitor – Change Tracker (`changefeeds/shopify-price-stock-change-tracker`) Actor

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.

- **URL**: https://apify.com/changefeeds/shopify-price-stock-change-tracker.md
- **Developed by:** [Changefeeds Tools](https://apify.com/changefeeds) (community)
- **Categories:** E-commerce, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Shopify Price & Stock Monitor – Change Tracker

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](https://docs.apify.com/platform/integrations) 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_type`        | Meaning                                                                      |
| -------------------- | ---------------------------------------------------------------------------- |
| `price_changed`      | `price` differs; `before`, `after`, `pct_change`                             |
| `compare_at_changed` | `compare_at_price` differs, including set or cleared; `pct_change` when both sides have a value |
| `back_in_stock`      | `available` went from `false` to `true`                                      |
| `out_of_stock`       | `available` went from `true` to `false`                                      |
| `new_variant`        | a variant id not in the previous snapshot                                    |
| `removed_variant`    | in the previous snapshot, absent from this run's **complete** fetch          |
| `title_changed`      | product title (`field: "product_title"`) or variant title (`field: "variant_title"`) differs |
| `sku_changed`        | SKU differs                                                                  |
| `baseline`           | first run for this store: current values, no comparison                     |
| `unchanged`          | only with `includeUnchanged: true`                                           |
| `store_error`        | this store could not be read (see `error_code`)                              |

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

### Input

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`:

```json
{
  "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.

# Actor input Schema

## `storeUrls` (type: `array`):

Store base URLs, e.g. https://www.allbirds.com. Paths are ignored; only the public /products.json catalog is read.

## `maxProductsPerStore` (type: `integer`):

Stop after this many products per store (250 per request). If a store has more, that store's run is marked truncated and removed variants are not reported for it.

## `trackFields` (type: `array`):

Which variant fields produce change rows. New and removed variants are always reported. Default: all.

## `snapshotKey` (type: `string`):

Optional name for the saved snapshot this run compares against. Leave empty to derive it from the store list, so the same list always compares with its own previous run. Set it to keep history when you edit the list.

## `webhookUrl` (type: `string`):

Optional. When a run finds changes, POST the run summary and up to the first 500 changes as JSON here (10 s timeout; a failure is logged, the run still succeeds). Baseline runs do not call it.

## `includeUnchanged` (type: `boolean`):

Also output one row per variant that did not change (change\_type "unchanged").

## Actor input object example

```json
{
  "storeUrls": [
    "https://www.allbirds.com"
  ],
  "maxProductsPerStore": 200,
  "trackFields": [
    "price",
    "compare_at_price",
    "available",
    "title",
    "sku"
  ],
  "includeUnchanged": false
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "storeUrls": [
        "https://www.allbirds.com"
    ],
    "maxProductsPerStore": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("changefeeds/shopify-price-stock-change-tracker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "storeUrls": ["https://www.allbirds.com"],
    "maxProductsPerStore": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("changefeeds/shopify-price-stock-change-tracker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "storeUrls": [
    "https://www.allbirds.com"
  ],
  "maxProductsPerStore": 200
}' |
apify call changefeeds/shopify-price-stock-change-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,changefeeds/shopify-price-stock-change-tracker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/vBTmt6hXkVWedFLGE/builds/D3oUifTPoGjDHAfi4/openapi.json
