# Shopify Price Monitor - Competitor Price & Stock Tracker (`kda_roll/shopify-price-monitor`) Actor

Track competitor prices across any number of Shopify stores. Get a daily report of price drops, price increases, new products and out-of-stock items - only what actually changed. The first run saves a baseline; the second run onward reports changes.

- **URL**: https://apify.com/kda\_roll/shopify-price-monitor.md
- **Developed by:** [Kashis Rolleston](https://apify.com/kda_roll) (community)
- **Categories:** E-commerce
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Shopify Multi-Store Price Monitor

Watch as many Shopify stores as you like and get back **only what changed** since
the last run — price drops, price rises, new products, sold-out variants, sales
starting and ending.

Point it at a list of competitor stores, put it on a daily schedule, and read a
one-page report each morning instead of clicking through 50 catalogs.

***

### Set it up in three steps

**1. Run it once.** Paste in the store URLs you want to watch and hit Start.
This first run reads each catalog and saves it. **It reports no changes, because
there is nothing yet to compare against** — that is expected, not a fault.

**2. Put it on a schedule.** Open the **Schedules** tab and set it to run daily.
This is the step that matters: a monitor that only ever runs once can never
report anything. From the second run onward, every run reports what moved since
the previous one.

**3. Have it come to you.** In the **Integrations** tab, connect Slack or a
webhook so the report arrives on its own instead of waiting for you to check.

That's it. After that it runs itself, and you only hear from it when something
changes.

#### Where to find things afterwards

Apify's own labels are aimed at developers. Here's the translation:

| You'll see | It means |
|---|---|
| **Runs** | Every time it checked your stores. Click one to see that day's findings. |
| **Storage → Key-value store → `report`** | **The report.** This is the page you actually want to read — click the eye icon to open it. |
| **Storage → Dataset** | The same changes as raw rows, for exporting to Excel or Google Sheets. |
| **Output** | A quick table of what changed, without leaving the run. |
| **Log** | The blow-by-blow of it working. Useful if a store failed. |
| **Schedules** | When it runs automatically. |
| **Integrations** | Where to send the report — Slack, email, your own system. |

If you only remember one: **the report lives under Storage → Key-value store.**

***

### What it reports

| Event | Fires when |
|---|---|
| `PRICE_DROP` / `PRICE_INCREASE` | a variant's price moved |
| `SALE_STARTED` / `SALE_ENDED` / `SALE_DEEPENED` | the compare-at price appeared, went away, or the discount got bigger |
| `BACK_IN_STOCK` / `OUT_OF_STOCK` | a variant's availability flipped |
| `NEW_PRODUCT` / `REMOVED_PRODUCT` | a product was added to or pulled from the catalog |
| `NEW_VARIANT` / `REMOVED_VARIANT` | a size/colour was added or dropped from an existing product |
| `TITLE_CHANGED` | a product was renamed or repositioned |

Changes are tracked **per variant**, because that is where price and stock
actually live. A store putting one size on sale is a real event that
product-level monitoring misses entirely.

### Output

- **Dataset** — one row per change, ready for Sheets, Excel, or a webhook.
- **`report` (HTML)** — a formatted, shareable page with thumbnails and links.
- **`changes.csv`** — the same data as a spreadsheet.
- **`OUTPUT`** — run summary: stores checked, totals by event type, warnings.

### Getting the report delivered to you

By default the report waits in the run's storage for you to open. To have it
come to you instead, schedule the Actor to run daily, then add a delivery in the
Actor's **Integrations** tab:

| Where you want it | How |
|---|---|
| **Slack** | Built in. Authorise the workspace, pick a channel, trigger on *run succeeded*. Easiest option by a distance. |
| **Your own system** | Add a **webhook** — Apify POSTs the run and dataset details to any URL when the run finishes. |
| **Email** | Connect **Zapier** or **Make** to Gmail and trigger on a finished run. |
| **A spreadsheet** | Export the dataset to Google Sheets on a schedule, or pull it from the dataset API. |

Delivery deliberately lives at the platform layer rather than inside this Actor.
Building email in would mean handing over SMTP credentials to a scraper, and the
integrations above already do it better.

### Input

| Field | Default | Notes |
|---|---|---|
| `storeUrls` | — | Any form works: `shop.com`, `https://www.shop.com/collections/all`, `shop.myshopify.com` |
| `mode` | `monitor` | `monitor` = changes only. `snapshot` = full catalog export every run. |
| `trackedEvents` | most | Which event types to report |
| `minPriceChangePct` / `minPriceChangeAbs` | `0` | Suppress rounding noise and FX jitter |
| `keywordFilter` / `excludeKeywords` | — | Watch only part of a catalog |
| `includeUnavailable` | `true` | Set false to track only buyable stock |
| `concurrency` | `5` | Stores fetched at once |
| `maxPagesPerStore` | `40` | 40 pages × 250 = 10,000 products |
| `autoTune` | `true` | See below |
| `resetBaseline` | `false` | Throw away stored snapshots and start over |

#### Tip: filtering out a store's own test products

Plenty of shops leave internal test or sample products published in their feed —
titles like "Test Classic Percale Duvet Cover" show up in real catalogs. Those
are genuinely in the store's data, but they are noise in a change report. Drop
them with:

```json
"excludeKeywords": ["test", "sample", "do not order"]
```

The same field is the way to ignore any product line you don't compete on.

#### The first run reports nothing

That is correct behaviour, not a bug. The first run has nothing to compare
against, so it saves a baseline. **The second run is the one that reports
changes.** Schedule it daily and you get a useful report from day two.

### Auto-tuning

The actor keeps a small learned profile per store and reuses it next run:

- **page size** — some stores/CDNs reject `limit=250`; once a working size is
  found it is reused instead of rediscovered every run.
- **catalog depth** — a 300-product store needs 2 pages, not 40. Remembering the
  real depth removes a wasted request per store per run.
- **pacing** — a store that throttled us gets a delay next time, and that delay
  decays away once it stops throttling, so one bad afternoon doesn't slow the
  store down permanently.
- **currency** — detected once, then cached.

Turn `autoTune` off to fetch every store with stock settings.

### The partial-fetch guard

The one failure mode that quietly ruins a monitor is a **partial fetch**. If a
store times out halfway through pagination and the half-catalog is saved as the
new baseline, the next run reports thousands of phantom `REMOVED_PRODUCT`
events, and the run after that reports them all as new.

So when a previously healthy catalog appears to lose more than half its variants,
the run is treated as suspect: **the old baseline is kept, no changes are
reported for that store, and a warning goes in the report.** A real clearance
event shows up on the following run once the catalog reading is stable.

### Rate limiting — read this

Shopify's `/products.json` is a public platform endpoint, but many larger brands
put a CDN in front of it that rate-limits per IP. In testing, roughly two thirds
of large DTC brands returned `429` from a single residential IP, while smaller
stores returned `200` at every page size. The limit is on **IP reputation and
request frequency, not page size** — a blocked store stays blocked even at
`limit=5`.

Verified on the platform:

| Proxy | Result |
|---|---|
| No proxy / single IP | Small stores fine; most large brands `429` |
| **Datacenter** (default) | Small and mid-size stores fine — e.g. 5,839 variants pulled from a large linens brand |
| **Residential** | Gets through Cloudflare-protected brands — e.g. 2,509 variants from a major shoe brand that `429`s on datacenter |

**You do not have to manage this.** With `escalateBlockedStores` on (the
default), a store that gets blocked is retried once through a residential IP
automatically, and the tuner remembers — next run it goes straight there instead
of wasting the doomed cheap attempt. Only the stores that actually need it cost
residential bandwidth, and if a store later recovers on the cheap tier it drops
back down.

Verified end to end: a run of three stores where two were blocked went from
**1 of 3 succeeding to 3 of 3**, with no input changes.

Two details that make it work:

- **Each store gets its own proxy session**, so one store's rate limit can't
  poison the rest of the run.
- **Sessions are salted per run.** Session stickiness is wanted *within* a run
  (every page of one catalog from one IP looks like a normal shopper) but not
  *between* runs — keying on the domain alone pins a store to one IP forever, so
  a single burned IP would block that store permanently.

If many stores throttle at once, lower `concurrency` — the actor suggests a
value in the run warnings when it detects this.

A `429` aborts that store immediately rather than retrying into a wall, so one
blocked store costs seconds, not minutes, and never blocks the others.

### Why this stays working

`/products.json` is a Shopify **platform** endpoint, not something each merchant
designs. One implementation covers every store on the platform, and a shop
redesigning its theme cannot break it. That is what makes this cheap to run and
cheap to maintain compared to a scraper that parses HTML.

### Running it locally

```bash
pip install -r requirements.txt
apify run
```

Or on Apify:

```bash
apify push
```

State lives in a **named** key-value store (`shopify-monitor-state`) so baselines
survive between runs. The default store is wiped each run — a monitor keeping
its baseline there would have no memory and report every product as new, forever.

# Actor input Schema

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

Shopify store domains to watch. Anything works: 'shop.com', 'https://www.shop.com/collections/all', or 'shop.myshopify.com'.

## `mode` (type: `string`):

monitor = only output what changed since the last run (needs at least one prior run to build a baseline). snapshot = output the full catalog every time.

## `trackedEvents` (type: `array`):

Which kinds of change to include in the output.

## `minPriceChangePct` (type: `integer`):

Ignore price moves smaller than this percentage. Filters out currency-rounding noise. 0 = report everything.

## `minPriceChangeAbs` (type: `integer`):

Ignore price moves smaller than this amount in the store's own currency. 0 = report everything.

## `keywordFilter` (type: `array`):

Only report products whose title, SKU, vendor, type or tags contain one of these words. Leave empty to watch the whole catalog.

## `excludeKeywords` (type: `array`):

Skip products matching any of these words. Applied after the keyword filter.

## `includeUnavailable` (type: `boolean`):

Track variants that are currently sold out. Turn off to watch only buyable stock.

## `generateReport` (type: `boolean`):

Save a formatted, shareable report to the run's key-value store alongside the raw dataset.

## `alwaysOutputSummary` (type: `boolean`):

When nothing changed, return a single summary row saying what was checked instead of an empty dataset. Turn off if you only ever want change rows.

## `escalateBlockedStores` (type: `boolean`):

Some larger brands block datacenter proxies. When one does, retry it once through a residential IP, and remember to go straight there next run. Uses more proxy bandwidth for those stores only. Turn off to keep costs strictly minimal and have blocked stores reported as failures.

## `resetBaseline` (type: `boolean`):

Discard stored snapshots and start fresh. This run reports no changes; the next one compares against it.

## `autoTune` (type: `boolean`):

Learn each store's page size, pagination depth and safe request pacing, and reuse it on later runs. Makes repeat runs faster and less likely to be rate-limited.

## `concurrency` (type: `integer`):

How many stores to fetch at once.

## `maxPagesPerStore` (type: `integer`):

Safety cap on pagination. At 250 products per page, 40 pages is 10,000 products.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for a single page of a store's product feed before giving up on it.

## `maxRetries` (type: `integer`):

How many times to retry a timed-out or server-errored request before treating the store as failed.

## `proxyConfiguration` (type: `object`):

Recommended. Many larger brands rate-limit (429) the product feed per IP, so a rotating proxy is what keeps them readable. If a store still returns 429, switch the proxy group to RESIDENTIAL.

## Actor input object example

```json
{
  "storeUrls": [
    "https://brooklinen.com"
  ],
  "mode": "monitor",
  "trackedEvents": [
    "PRICE_DROP",
    "PRICE_INCREASE",
    "NEW_PRODUCT",
    "REMOVED_PRODUCT",
    "BACK_IN_STOCK",
    "OUT_OF_STOCK",
    "SALE_STARTED",
    "SALE_ENDED"
  ],
  "minPriceChangePct": 0,
  "minPriceChangeAbs": 0,
  "keywordFilter": [],
  "excludeKeywords": [],
  "includeUnavailable": true,
  "generateReport": true,
  "alwaysOutputSummary": true,
  "escalateBlockedStores": true,
  "resetBaseline": false,
  "autoTune": true,
  "concurrency": 5,
  "maxPagesPerStore": 40,
  "requestTimeoutSecs": 30,
  "maxRetries": 3,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# 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://brooklinen.com",
        "https://greatjonesgoods.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("kda_roll/shopify-price-monitor").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://brooklinen.com",
        "https://greatjonesgoods.com",
    ] }

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

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).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://brooklinen.com",
    "https://greatjonesgoods.com"
  ]
}' |
apify call kda_roll/shopify-price-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=kda_roll/shopify-price-monitor",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

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