# Shopify Catalog & Price Monitor - Snapshots, Diffs, Alerts (`yasaslive/shopify-monitor`) Actor

Export any public Shopify storefront's full catalog (products, variants, prices, stock) and get typed change alerts — price drops, new products, stock-outs — on every scheduled run.

- **URL**: https://apify.com/yasaslive/shopify-monitor.md
- **Developed by:** [Eonix Pvt Ltd](https://apify.com/yasaslive) (community)
- **Categories:** E-commerce, Lead generation, Integrations
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.00005 / actor start

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/platform/actors/running/actors-in-store#pay-per-event

## 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 Catalog & Price Monitor — Snapshots, Diffs, Alerts

Export any public Shopify storefront's **entire catalog** — every product, every variant, every price and stock flag — and get **typed change alerts** the moment something moves: price drops, price increases, stock-outs, restocks, new products, removed products.

Point it at a store once and you get a clean dataset. Put it on a schedule and you get a monitoring loop: each run compares the live catalog against the previous snapshot and emits only what actually changed, to your dataset, to Slack, and to your webhook.

No browser, no login, no scraping tricks — it reads the storefront's own public `/products.json` endpoint, the same JSON a shopper's browser can request.

***

### Who it's for

- **E-commerce & pricing teams** watching competitors' prices and assortment.
- **Brands and agencies** verifying their own catalog and MAP compliance across storefronts.
- **Deal, resale and drop communities** that need to know the second something restocks.
- **Analysts** building assortment, discount-depth and stock-availability datasets over time.

### Three concrete uses

1. **Competitive price tracking.** Add five competitor storefronts, schedule the actor hourly, set `watchFilters.minPctChange` to `5`, and pipe alerts into a `#pricing` Slack channel. You get a message only when a competitor moves a price by 5% or more — with the old price, the new price, the percentage, and a direct product link.
2. **Restock sniping for high-demand drops.** Watch one store, filter to `titleIncludes: ["Jordan"]`, and run every 10 minutes with `exportCatalog: false`. Each run costs a couple of cents and posts a `back_in_stock` alert the moment a variant flips available.
3. **Assortment and discount research.** Run the full export weekly across a dozen storefronts and load the dataset into a warehouse. Every row carries `price`, `compareAtPrice`, `tags`, `vendor` and `productType`, so discount depth, category mix and catalog churn all fall out of simple SQL.

***

### How it works

```
storeUrls ─▶ robots.txt check ─▶ GET /products.json?limit=250&page=1..n
                                          │
                                          ▼
                              normalise to one row per variant
                                    (prices in cents)
                                          │
              ┌───────────────────────────┼──────────────────────────┐
              ▼                           ▼                          ▼
      dataset: kind="variant"     named KV store:            diff vs previous
      (full catalog export)       snapshot per hostname       snapshot
                                                                    │
                                                                    ▼
                                              dataset: kind="change"  +
                                              CHANGES.json artifact    +
                                              Slack / webhook summary
```

**The first run for a store is a baseline** — it writes the snapshot and emits zero change events. Every run after that produces the diff. Keep `storeName` stable across runs; it is the memory that makes diffing possible.

***

### Quick start

**On Apify Console**

1. Paste one or more storefront URLs into **Shopify store URLs**.
2. Run it once to build the baseline.
3. Add a **Schedule** (hourly or daily) and paste a Slack incoming webhook into **Slack incoming webhook URL**.

**Locally**

```bash
npm install && npm run build && apify run
```

The repository ships a working `storage/key_value_stores/default/INPUT.json`, so `apify run` works out of the box against two real storefronts.

***

### Input

| Field                | Type             | Default                       | Description                                                                                                                                                                                                     |
| -------------------- | ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storeUrls`          | array of strings | — **required**                | Storefront URLs. Any URL on the store works; it is normalised to the https origin (`https://www.allbirds.com/collections/mens` → `https://www.allbirds.com`). Duplicated hostnames are de-duplicated. Max 100.  |
| `exportCatalog`      | boolean          | `true`                        | Push every variant to the dataset. Set to `false` for alert-only runs: snapshots and change events still happen, but no catalog rows are stored — far cheaper on a tight schedule.                              |
| `storeName`          | string           | `"shopify-monitor-snapshots"` | Named key-value store holding one snapshot record per hostname. **Keep it stable between runs** — this is what the diff compares against. Use a different name to run separate, independent monitoring streams. |
| `watchFilters`       | object           | `{}`                          | Narrows **change events only**, never the catalog export. See below.                                                                                                                                            |
| `slackWebhookUrl`    | string (secret)  | —                             | Slack incoming webhook. Receives a grouped summary per store.                                                                                                                                                   |
| `alertWebhookUrl`    | string (secret)  | —                             | Any HTTPS endpoint. Receives the same summary as JSON.                                                                                                                                                          |
| `proxyConfiguration` | object           | `{ "useApifyProxy": true }`   | Apify Proxy settings. Datacenter proxies are enough for `/products.json`; switch to residential if a storefront blocks datacenter ranges.                                                                       |

#### `watchFilters`

```json
{
    "titleIncludes": ["hoodie", "jordan"],
    "vendors": ["Nike"],
    "tags": ["sale"],
    "minPctChange": 5
}
```

| Key             | Matching                                    | Notes                                                                                                                                                     |
| --------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `titleIncludes` | case-insensitive **substring**, any match   | Matched against the event title (`Product — Variant`).                                                                                                    |
| `vendors`       | case-insensitive **exact** match, any match | `"Nike"` matches `Nike`, not `Nike Inc`.                                                                                                                  |
| `tags`          | case-insensitive **exact** match, any match | Matches if the product carries any listed tag.                                                                                                            |
| `minPctChange`  | number ≥ 0, default `0`                     | `0` reports every price move. Above `0`, keeps only moves whose absolute percentage change is **≥** the threshold. Never filters stock or product events. |

Different keys combine with **AND**; entries within one key combine with **OR**. An unrecognised key fails the run immediately rather than being silently ignored — a typo'd filter that quietly does nothing is worse than a clear error.

***

### Output

Three record kinds land in the default dataset, each tagged with `kind`.

#### 1. `kind: "variant"` — the catalog export

One row per variant. **Real output from a local run against `deathwishcoffee.com`:**

```json
{
    "kind": "variant",
    "store": "https://www.deathwishcoffee.com",
    "productId": 7940945025,
    "handle": "pumpkin-chai",
    "title": "Pumpkin Chai Coffee",
    "vendor": "Death Wish Coffee Company",
    "productType": "Coffee",
    "tags": ["Bagged", "LTO Coffee", "National Coffee Day", "Society of Strong Coffee"],
    "variantId": 41181425598519,
    "variantTitle": "1 bag",
    "sku": "LGPMPK09",
    "price": 13.99,
    "compareAtPrice": null,
    "available": true,
    "url": "https://www.deathwishcoffee.com/products/pumpkin-chai?variant=41181425598519",
    "imageSrc": "https://cdn.shopify.com/s/files/1/0271/7209/files/Pumpkin_Chai_9oz_Ground_Angle_Cathedral_DTC_A2_EA.jpg?v=1786632919",
    "collectedAt": "2026-08-15T18:46:36.668Z"
}
```

#### 2. `kind: "change"` — the change events

| `type`            | Emitted when                                                  | Extra fields                                                          |
| ----------------- | ------------------------------------------------------------- | --------------------------------------------------------------------- |
| `price_drop`      | a variant's price fell                                        | `oldPrice`, `newPrice`, `oldPriceCents`, `newPriceCents`, `pctChange` |
| `price_increase`  | a variant's price rose                                        | same as above                                                         |
| `back_in_stock`   | a variant flipped to available                                | —                                                                     |
| `out_of_stock`    | a variant flipped to sold out                                 | —                                                                     |
| `new_product`     | a product id appeared that the previous snapshot did not have | —                                                                     |
| `removed_product` | a product id from the previous snapshot is gone               | —                                                                     |

Price and stock events are variant-level and carry `variantId`; product events are product-level. Every event carries `productId`, `handle`, `title`, `url`, `vendor` and `tags`.

**Real output from a local run** (all six types, one of each):

```json
[
    {
        "type": "price_drop",
        "productId": 179754517,
        "handle": "gift-card",
        "title": "Death Wish Coffee Digital Gift Card — $25",
        "url": "https://www.deathwishcoffee.com/products/gift-card?variant=411724869",
        "vendor": "Death Wish Coffee Company",
        "tags": ["Gifting", "Merch", "Website Exclusive Sale"],
        "variantId": 411724869,
        "oldPrice": 31.25,
        "newPrice": 25,
        "oldPriceCents": 3125,
        "newPriceCents": 2500,
        "pctChange": -20,
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    },
    {
        "type": "price_increase",
        "productId": 272081685,
        "handle": "valhalla-java-odin-force-blend",
        "title": "Valhalla Java Odinforce Blend — Ground / 1 bag",
        "url": "https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625",
        "vendor": "Death Wish Coffee Company",
        "tags": [
            "Bagged",
            "National Coffee Day",
            "Subscription",
            "Valhalla Java",
            "Valhalla Java Odinforce Blend"
        ],
        "variantId": 638652625,
        "oldPrice": 12.79,
        "newPrice": 15.99,
        "oldPriceCents": 1279,
        "newPriceCents": 1599,
        "pctChange": 25.02,
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    },
    {
        "type": "back_in_stock",
        "productId": 171104582,
        "handle": "valhalla-java-single-serve-pods",
        "title": "Valhalla Java Single-Serve Pods — 10 count",
        "url": "https://www.deathwishcoffee.com/products/valhalla-java-single-serve-pods?variant=392715452",
        "vendor": "Death Wish Coffee Company",
        "tags": [
            "Intro Offer",
            "National Coffee Day",
            "Single Serve",
            "Subscription",
            "Valhalla Java",
            "Valhalla Java Odinforce Blend"
        ],
        "variantId": 392715452,
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    },
    {
        "type": "out_of_stock",
        "productId": 7502387511351,
        "handle": "espresso-roast-cold-brew-coffee-48oz",
        "title": "Espresso Roast, Cold Brew Coffee, Unsweetened",
        "url": "https://www.deathwishcoffee.com/products/espresso-roast-cold-brew-coffee-48oz?variant=42379014438967",
        "vendor": "Death Wish Coffee",
        "tags": ["Cold Brew", "Espresso Roast", "Multiserve"],
        "variantId": 42379014438967,
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    },
    {
        "type": "new_product",
        "productId": 4346529579063,
        "handle": "death-wish-instant-coffee-1",
        "title": "Dark Roast Instant Coffee",
        "url": "https://www.deathwishcoffee.com/products/death-wish-instant-coffee-1",
        "vendor": "Death Wish Coffee Company",
        "tags": ["Dark", "Dark Roast", "Gifting", "Instant", "Intro Offer", "Subscription"],
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    },
    {
        "type": "removed_product",
        "productId": 999000111,
        "handle": "discontinued-holiday-blend",
        "title": "Discontinued Holiday Blend",
        "url": "https://www.deathwishcoffee.com/products/discontinued-holiday-blend",
        "vendor": "Death Wish Coffee Company",
        "tags": ["Bagged", "Seasonal"],
        "kind": "change",
        "store": "https://www.deathwishcoffee.com",
        "detectedAt": "2026-08-15T18:46:36.777Z",
        "previousFetchedAt": "2026-08-15T18:46:31.673Z"
    }
]
```

> How this example was produced: the catalog values above are live data from a real run against `deathwishcoffee.com`. To show all six event types in one place without waiting for the storefront to change overnight, the *stored baseline* from the previous run was edited to represent yesterday's state, and the actor was then run normally against the live catalog. The events are the real diff engine's output; only the "yesterday" side was staged.

#### 3. `kind: "summary"` — one per run

The last record of every run. **Real output:**

```json
{
    "kind": "summary",
    "runId": "local-1786819597943",
    "startedAt": "2026-08-15T18:46:35.623Z",
    "finishedAt": "2026-08-15T18:46:37.944Z",
    "storesRequested": 2,
    "storesSnapshotted": 2,
    "storesFailed": 0,
    "totalVariants": 3242,
    "totalEvents": 6,
    "eventCounts": {
        "price_drop": 1,
        "price_increase": 1,
        "back_in_stock": 1,
        "out_of_stock": 1,
        "new_product": 1,
        "removed_product": 1
    },
    "changesArtifactKey": "CHANGES-local-1786819597943.json",
    "changesArtifactUrl": null,
    "stores": [
        {
            "store": "https://www.deathwishcoffee.com",
            "hostname": "www.deathwishcoffee.com",
            "status": "ok",
            "statusReason": null,
            "productCount": 146,
            "variantCount": 413,
            "pagesFetched": 2,
            "isBaseline": false,
            "eventsEmitted": 6,
            "eventCounts": {
                "price_drop": 1,
                "price_increase": 1,
                "back_in_stock": 1,
                "out_of_stock": 1,
                "new_product": 1,
                "removed_product": 1
            },
            "exportedVariants": 413,
            "fetchedAt": "2026-08-15T18:46:36.668Z",
            "previousFetchedAt": "2026-08-15T18:46:31.673Z",
            "durationMs": 1092
        },
        {
            "store": "https://www.allbirds.com",
            "hostname": "www.allbirds.com",
            "status": "ok",
            "statusReason": null,
            "productCount": 291,
            "variantCount": 2829,
            "pagesFetched": 3,
            "isBaseline": false,
            "eventsEmitted": 0,
            "eventCounts": {
                "price_drop": 0,
                "price_increase": 0,
                "back_in_stock": 0,
                "out_of_stock": 0,
                "new_product": 0,
                "removed_product": 0
            },
            "exportedVariants": 2829,
            "fetchedAt": "2026-08-15T18:46:37.390Z",
            "previousFetchedAt": "2026-08-15T18:46:32.347Z",
            "durationMs": 2254
        }
    ]
}
```

`changesArtifactUrl` is `null` on local runs and a public API URL on the platform.

#### Key-value store artifacts

| Store                | Key                    | Contents                                                                                                                           |
| -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| default (run-scoped) | `CHANGES-{runId}.json` | Every change event of the run plus the per-store results and the filters that were applied.                                        |
| default (run-scoped) | `CHANGES.json`         | The same report under a fixed key, which is what the **Output** tab links — a template cannot interpolate the run id.              |
| named (`storeName`)  | `{hostname}`           | The latest snapshot for that storefront: `{ version, store, fetchedAt, products, variants }`, with all money as **integer cents**. |

#### Output tab

`.actor/output_schema.json` declares what a finished run produced, so the Console shows it directly instead of making buyers hunt through storages:

| Output                    | Points at                                                |
| ------------------------- | -------------------------------------------------------- |
| Catalog and change events | The full dataset — variants, changes and the run summary |
| Change report (JSON)      | The `CHANGES.json` record                                |

The dataset's **Overview** view is shaped for the catalog. Change and summary rows share the dataset (Apify dataset views select columns but cannot filter rows), so they appear there with the catalog columns blank — read them from `kind`, or take the change report instead.

***

### Alerts

Alerts fire only when a run produced at least one change event — a monitor that posts "nothing happened" every fifteen minutes gets muted, and a muted monitor is useless.

**Slack** (`slackWebhookUrl`) receives a header, one section per store with the counts and the ten most significant price moves, and a context line linking the full report. Real payload:

```json
{
    "text": "Shopify monitor — 6 catalog changes across 1 store",
    "blocks": [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": "Shopify monitor — 6 catalog changes across 1 store",
                "emoji": false
            }
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "*https://www.deathwishcoffee.com* — 1 price drop, 1 price increase, 1 back in stock, 1 out of stock, 1 new product, 1 removed product\n• ↑ <https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625|Valhalla Java Odinforce Blend — Ground / 1 bag> — 12.79 → 15.99 (+25.02%)\n• ↓ <https://www.deathwishcoffee.com/products/gift-card?variant=411724869|Death Wish Coffee Digital Gift Card — $25> — 31.25 → 25 (-20%)"
            }
        },
        {
            "type": "context",
            "elements": [
                {
                    "type": "mrkdwn",
                    "text": "<https://api.apify.com/v2/key-value-stores/EXAMPLE/records/CHANGES-abc123.json|Full change report>  ·  Run `local-1786819597943`"
                }
            ]
        }
    ]
}
```

**Generic webhook** (`alertWebhookUrl`) receives the machine-readable version of the same summary:

```json
{
    "actor": "shopify-monitor",
    "runId": "local-1786819597943",
    "generatedAt": "2026-08-15T18:46:37.944Z",
    "totalEvents": 6,
    "eventCounts": {
        "price_drop": 1,
        "price_increase": 1,
        "back_in_stock": 1,
        "out_of_stock": 1,
        "new_product": 1,
        "removed_product": 1
    },
    "storesSnapshotted": 2,
    "storesFailed": 0,
    "changesArtifactUrl": "https://api.apify.com/v2/key-value-stores/EXAMPLE/records/CHANGES-abc123.json",
    "stores": [
        {
            "store": "https://www.deathwishcoffee.com",
            "status": "ok",
            "isBaseline": false,
            "variantCount": 413,
            "totalEvents": 6,
            "eventCounts": {
                "price_drop": 1,
                "price_increase": 1,
                "back_in_stock": 1,
                "out_of_stock": 1,
                "new_product": 1,
                "removed_product": 1
            },
            "topMoves": [
                {
                    "type": "price_increase",
                    "title": "Valhalla Java Odinforce Blend — Ground / 1 bag",
                    "url": "https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625",
                    "oldPrice": 12.79,
                    "newPrice": 15.99,
                    "pctChange": 25.02
                },
                {
                    "type": "price_drop",
                    "title": "Death Wish Coffee Digital Gift Card — $25",
                    "url": "https://www.deathwishcoffee.com/products/gift-card?variant=411724869",
                    "oldPrice": 31.25,
                    "newPrice": 25,
                    "pctChange": -20
                }
            ]
        }
    ]
}
```

A failed delivery is logged as an error and counted in the run's status message; it never fails the run, because the dataset and the artifact already hold every event.

***

### Pricing — pay per event

Every event is charged **singly** — one snapshot, one variant, one change. Nothing is bundled, so you pay exactly for what a run delivered.

| Event name         | Charged once per                                                         | Price       |
| ------------------ | ------------------------------------------------------------------------ | ----------- |
| `catalog-snapshot` | Store, **after** its snapshot is written                                 | **$0.05**   |
| `variant-exported` | Variant, **after** the row is pushed (only when `exportCatalog` is true) | **$0.0005** |
| `change-alert`     | Change event, **after** the events are pushed to the dataset             | **$0.002**  |

Create these three events verbatim in **Console ▸ Monetization ▸ Pay per event** — the exact titles and descriptions to paste in are in [`.actor/MONETIZATION.md`](.actor/MONETIZATION.md).

**Nothing is charged for work that failed.** A store that is blocked, has the endpoint disabled, is disallowed by robots.txt, or errors out costs nothing at all — every charge happens strictly after the corresponding records are persisted. A storefront whose catalog is empty is snapshotted but not charged, because it delivered nothing.

Worked example, from the real run above — 2 stores, 3,242 variants, 6 change events:

| Line               | Quantity | Unit price | Cost      |
| ------------------ | -------- | ---------- | --------- |
| `catalog-snapshot` | 2        | $0.05      | $0.10     |
| `variant-exported` | 3,242    | $0.0005    | $1.62     |
| `change-alert`     | 6        | $0.002     | $0.012    |
| **Total**          |          |            | **$1.73** |

The same run with `exportCatalog: false` costs **$0.11** — which is the point: build the catalog once, then run the cheap alert loop on a schedule.

Because variants are priced individually, a 40-variant storefront costs $0.02 to export rather than being rounded up to a bundle it did not fill.

***

### Scheduling

| Goal                   | Schedule        | Settings                                         |
| ---------------------- | --------------- | ------------------------------------------------ |
| Competitor price watch | hourly          | `exportCatalog: false`, `minPctChange: 3`        |
| Restock alerts         | every 10–15 min | `exportCatalog: false`, `titleIncludes` narrowed |
| Assortment dataset     | daily or weekly | `exportCatalog: true`                            |

All schedules must share the same `storeName` as the run that created the baseline, or every run will look like a first run.

***

### Per-store statuses

A run does not fail because one storefront misbehaved. Each store reports its own status in the summary record, and the run's status message names the failures.

| `status`               | Meaning                                                                                                            | Charged? |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ | -------- |
| `ok`                   | Catalog fetched, snapshot written, diff produced.                                                                  | Yes      |
| `endpoint_unavailable` | The store disabled `/products.json`, or answered with HTML, 403 or 404. Common — plenty of stores turn it off.     | No       |
| `robots_disallowed`    | robots.txt forbids the endpoint, or could not be read because the server returned 5xx.                             | No       |
| `catalog_too_large`    | More than 25,000 products. See the FAQ.                                                                            | No       |
| `empty_catalog_guard`  | The catalog came back empty while a non-empty snapshot exists. The old snapshot is kept and no events are emitted. | No       |
| `failed`               | Network failure after retries, a storefront that ignores the `page` parameter, or an unexpected error.             | No       |

***

### FAQ

**Is this legal / allowed?**
It reads a storefront's public `/products.json`, the endpoint Shopify serves to anyone — the same catalog data the store publishes to shoppers and to search engines. Public catalog data for competitive price research is a standard, legitimate use. The actor respects `robots.txt` (including `Crawl-delay`), waits 500 ms between pages, holds at most three connections per hostname, and reads no personal data of any kind. It does not log in, does not touch checkout, and does not place orders.

**Why did my first run produce no change events?**
That is by design. The first run for each storefront writes the baseline snapshot. From the second run on, every run produces the diff.

**Why did all my stores suddenly look like first runs?**
`storeName` changed, or a different named store was used. That value is the memory between runs — keep it identical across your schedule.

**What is the 25,000 product limit?**
Shopify refuses `/products.json?page=101` with HTTP 400, so the endpoint can hand out at most 100 pages × 250 products. A storefront bigger than that cannot be captured completely. Rather than snapshot a truncated catalog — which would fire a `removed_product` event for thousands of products on the next run — the store is reported as `catalog_too_large` and nothing is exported, snapshotted or charged. If you need a store that large, split it by collection (`/collections/{handle}/products.json` paginates the same way) or monitor a subset of the catalog.

**Where is the currency?**
`/products.json` does not include one. Prices are in the storefront's own default currency. If you monitor stores in different currencies, record that mapping on your side.

**Why is `compareAtPrice` sometimes `null` when the store shows a "compare at" value?**
Shopify serialises "no compare-at price" as either `null` or `"0.00"`; both are normalised to `null`, matching how the storefront itself treats them.

**Do prices drift over time?**
No. Money is parsed straight from the decimal string into integer cents and compared as integers, so `19.99` is always `1999` and never `1998.9999999999998`. A price that did not move can never produce an event.

**Can a variant emit two events at once?**
Yes — a variant that both changed price and sold out emits a price event *and* an `out_of_stock` event. That is intentional; both facts matter.

**What about a variant added to an existing product?**
It emits nothing: there is no previous price to compare against and it is not a new product. Only genuinely new *products* raise `new_product`.

**A store returned 403. What now?**
Enable Apify Proxy (on by default) and, if it persists, switch `proxyConfiguration` to residential. If the endpoint is genuinely disabled, no proxy will help — that is what `endpoint_unavailable` means.

**Does the run fail if a store fails?**
No. The run completes and reports per-store statuses; alert on the `summary` record's `storesFailed` rather than on the run status. A run only fails on unusable input or an unexpected internal error.

***

### Local development

```bash
npm install
npm run build          # tsc → dist/
npm test               # compiles tests, runs node --test (124 tests)
npm run lint           # eslint, type-aware
npm run format:check   # prettier
apify run              # full local run against storage/key_value_stores/default/INPUT.json
```

Requires Node.js 22+ and the [Apify CLI](https://docs.apify.com/cli) (`npm i -g apify-cli`).

| Path               | Purpose                                                                  |
| ------------------ | ------------------------------------------------------------------------ |
| `src/main.ts`      | Orchestration: input → per-store pipeline → artifact → alerts → charging |
| `src/catalog.ts`   | `/products.json` pagination and variant normalisation                    |
| `src/snapshot.ts`  | Snapshot build / read / write in the named key-value store               |
| `src/diff.ts`      | Pure diff engine: two snapshots in, typed events out                     |
| `src/filters.ts`   | `watchFilters` applied to events                                         |
| `src/alerts.ts`    | Grouped summary, Slack blocks, delivery                                  |
| `src/money.ts`     | Decimal-safe string → integer cents                                      |
| `src/http.ts`      | Backoff, `Retry-After`, per-host concurrency                             |
| `src/robots.ts`    | RFC 9309 robots.txt parsing and enforcement                              |
| `src/net-guard.ts` | SSRF guard on every user-supplied URL                                    |
| `src/charging.ts`  | `chargeSafely` — charging never breaks a run                             |

Store-listing assets live alongside the actor config:

| Asset                    | Purpose                                                              |
| ------------------------ | -------------------------------------------------------------------- |
| `.actor/logo.png`        | 1024×1024 Actor picture, full bleed — upload in Console ▸ Settings   |
| `.actor/logo.svg`        | Vector source for the logo                                           |
| `.actor/MONETIZATION.md` | Pay-per-event names, titles, descriptions and prices for the Console |

See [SECURITY.md](SECURITY.md) for the security model and [RUNBOOK.md](RUNBOOK.md) for operations.

***

### Changelog

#### 0.1.0 — 2026-08-15

- Initial release: full catalog export, snapshot + diff engine, six change event types, `watchFilters`, Slack and webhook alerts, `CHANGES.json` change report, an Output schema for the Console, and three pay-per-event charges billed one-for-one.

# Actor input Schema

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

Storefront URLs to monitor. Any URL on the store works — it is normalised to the https origin (for example https://www.allbirds.com/collections/mens becomes https://www.allbirds.com). Each store is fetched from its public /products.json endpoint.

## `exportCatalog` (type: `boolean`):

Push every variant to the dataset (kind: "variant"). Turn this off to run in alert-only mode: snapshots and change events are still produced, but the catalog rows are not stored — cheaper for high-frequency schedules.

## `storeName` (type: `string`):

Named key-value store that holds the previous run's snapshot per storefront (one record per hostname). Keep this stable across scheduled runs — it is what makes diffing possible. Use a different name to keep separate monitoring streams apart.

## `watchFilters` (type: `object`):

Optional filters applied to change events only — the catalog export is never filtered. All supplied conditions must match (AND); within a list, any entry matches (OR). titleIncludes/vendors/tags are case-insensitive; titleIncludes is a substring match, vendors and tags are exact matches. minPctChange (default 0 = report every price move) keeps only price moves whose absolute percentage change is greater than or equal to the threshold; it never filters stock or product events.

## `slackWebhookUrl` (type: `string`):

Optional. Slack incoming webhook (https://hooks.slack.com/services/...). Receives a grouped summary per store: event counts plus the ten most significant price moves. Nothing is sent when a run produces no change events.

## `alertWebhookUrl` (type: `string`):

Optional. Any HTTPS endpoint that accepts a POST with a JSON body. Receives the same grouped summary as a machine-readable payload. Must resolve to a public address.

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

Apify Proxy settings. Datacenter proxies are enough for products.json; switch to residential if a storefront blocks datacenter ranges.

## Actor input object example

```json
{
  "storeUrls": [
    "https://www.deathwishcoffee.com",
    "https://www.allbirds.com"
  ],
  "exportCatalog": true,
  "storeName": "shopify-monitor-snapshots",
  "watchFilters": {
    "titleIncludes": [],
    "vendors": [],
    "tags": [],
    "minPctChange": 0
  },
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per product variant (kind: "variant"), one row per detected change (kind: "change"), and a final run summary (kind: "summary").

## `changeReport` (type: `string`):

Every change event this run detected, with the per-store results and the filters that were applied. Empty events array when nothing moved.

# 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.deathwishcoffee.com",
        "https://www.allbirds.com"
    ],
    "watchFilters": {
        "titleIncludes": [],
        "vendors": [],
        "tags": [],
        "minPctChange": 0
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("yasaslive/shopify-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://www.deathwishcoffee.com",
        "https://www.allbirds.com",
    ],
    "watchFilters": {
        "titleIncludes": [],
        "vendors": [],
        "tags": [],
        "minPctChange": 0,
    },
}

# Run the Actor and wait for it to finish
run = client.actor("yasaslive/shopify-monitor").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.deathwishcoffee.com",
    "https://www.allbirds.com"
  ],
  "watchFilters": {
    "titleIncludes": [],
    "vendors": [],
    "tags": [],
    "minPctChange": 0
  }
}' |
apify call yasaslive/shopify-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yasaslive/shopify-monitor"
        }
    }
}

```

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/rHfRlBf0KmP0vi9DS/builds/phlkVGQPCO3orPP3J/openapi.json
