# Shopify Products Scraper, Price & Stock Monitor (`automa-flow/shopify-price-stock-monitor`) Actor

Shopify products scraper API for complete public catalogues, including variants, prices, SKUs, images and availability. Completes variant pagination instead of silently stopping at 100. Export snapshots or monitor verified price, sale, stock and catalogue changes. No API key or browser.

- **URL**: https://apify.com/automa-flow/shopify-price-stock-monitor.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** E-commerce, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 completed store checks

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?

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 Products Scraper, Price & Stock Monitor

Scrape complete public Shopify product catalogues from a store URL, including
variants, prices, SKUs, images and availability. Or monitor stores for price
changes, sales, stockouts, restocks, new products and variant changes. After the
first baseline, scheduled runs can return only what changed. No Shopify API key,
login or browser required.

**Pricing:** $0.02 per successful store check, $0.0005 per change event and
$0.30 per 1,000 full product rows. Failed, partial, blocked and skipped stores
are not charged for results.

### What this is for

A catalogue export answers what a store sells now. Pricing and competitor
intelligence teams need the harder answer every morning:

> **What changed since yesterday?**

Answering it from two catalogue dumps means diffing tens of thousands of rows by
hand, and getting it wrong in the ways that cost money: a pagination hiccup looks
like 800 discontinued products, a currency switch looks like a catalogue-wide
price cut, and the first run buries you in "new product" alerts for a catalogue
that has been there for years.

This Actor handles both jobs. Use `snapshot` as a Shopify products scraper, or
give `monitor` a watchlist: it retrieves the current public catalogue, compares
it with the last **successful** check, and emits deterministic change events:

```text
PRICE_CHANGED     Wool Runner          $110.00 -> $ 98.00
SALE_STARTED      Tree Lounger         $ 79.00 -> $ 55.30   (compare-at $79.00)
STOCK_OUT         Trino Tee / Large    available -> sold out
RESTOCKED         Wool Piper / 10      sold out  -> available
NEW_PRODUCT       Wool Runner Go       $135.00
PRODUCT_REMOVED   Trino Tubers
```

#### Why choose this Actor

| What you need | What this Actor does |
| --- | --- |
| A useful alert feed | Emits `PRICE_CHANGED`, `SALE_STARTED`, `STOCK_OUT`, `RESTOCKED`, product and variant events with compact before/after values |
| No first-run alert storm | The first successful monitor run is a `BASELINE`, never a catalogue full of `NEW_PRODUCT` events |
| Safe removals | Verifies complete pagination and suspicious catalogue drops before emitting `PRODUCT_REMOVED` or `VARIANT_REMOVED` |
| Complete variant data | Follows Shopify's variant connection past the first 100 variants, up to Shopify's 2,048-variant product limit, instead of silently truncating a product |
| Variant-level intelligence | Tracks stable Shopify variant IDs, SKUs, options, prices, compare-at prices and public availability |
| A watchlist that survives failures | Returns one explicit status per store and never overwrites good state after a failed or partial check |
| Broad public-store coverage | Prefers Shopify's Storefront GraphQL product API and falls back to public JSON where needed; no dependence on `/products.json` alone |
| Predictable recurring cost | Quiet monitor runs charge for successful store checks, not for re-exporting every unchanged product |

Use it for competitor price tracking, sale monitoring, restock alerts,
assortment changes, product-feed exports and daily ecommerce warehouse feeds.
The implementation has been smoke-tested on a 50-store mixed watchlist,
including international stores, multi-variant products and 25,000-product
catalogues.

### Choose a workflow

| Goal | Mode | Product output | State |
| --- | --- | --- | --- |
| Monitor competitors on a schedule | `monitor` | `changesOnly` | Reads and updates the last successful baseline |
| Export a current catalogue | `snapshot` | `all` | Does not read or write monitoring state |
| Validate a store watchlist | `snapshot` | `changesOnly` | Returns store status rows only; no product rows or monitoring state |

### How monitoring works

1. The first successful `monitor` run stores a `BASELINE` and emits no change
   events. Existing products are not mislabeled as new.
2. Run the same configuration again manually or on a schedule.
3. The Actor compares against the last complete successful state and returns
   only changed products, individual alert-ready events and one status row per
   store.

That second run is the product: a quiet daily check stays small, while a busy
day tells you exactly which price, sale, product or variant moved.

### Quick start

#### Snapshot a single store

```json
{
  "storeUrls": ["https://www.allbirds.com"],
  "mode": "snapshot",
  "outputMode": "all"
}
```

Returns every product with its variants, prices, compare-at prices and
availability. Reads and writes no monitoring state.

Products with more than 100 variants are not accepted as complete after the
first page. The Actor follows the variant connection until Shopify says there is
no next page. Shopify currently allows up to 2,048 variants per product; the
Actor's bounded pagination covers that limit and fails the store explicitly if
the connection stalls, repeats a cursor or exceeds the guardrail.

#### Watch a competitor list

```json
{
  "storeUrls": [
    "https://www.allbirds.com",
    "https://kith.com",
    "example-store.myshopify.com"
  ],
  "mode": "monitor",
  "outputMode": "changesOnly"
}
```

Need your own IDs in the output? Use the advanced `stores` input instead and
add an `externalId` to each URL. The ID is echoed onto every row so results join
straight to your watchlist, CRM or account ID; it is never sent to the store.

#### Run it daily

Save the input above as a task, then schedule it:

- Apify Console: **Actor -> Schedules -> Create schedule**, cron `0 6 * * *`.
- API: `POST https://api.apify.com/v2/schedules` with
  `{"cronExpression": "0 6 * * *", "actions": [{"type": "RUN_ACTOR", "actorId": "<actor-id>"}]}`

State lives in a named key-value store, so consecutive scheduled runs of the
same Actor continue the same monitoring history automatically.

#### Push changes to your own system

Add a webhook on **Run succeeded**. Its payload gives your endpoint the run's
`resource.defaultDatasetId`; fetch the change-oriented columns and retain rows
whose `record_type` is `change_event`:

```text
https://api.apify.com/v2/datasets/{{resource.defaultDatasetId}}/items
    ?view=changes&clean=true&format=json
```

Dataset views select and order columns; they are not server-side predicates.
Filter `record_type == "change_event"` in your webhook consumer. This explicit
step prevents product observations and the per-store status row from being
mistaken for alerts.

Every change event is a complete alert payload on its own - it carries the store,
your `externalId`, the product, the variant, and both the before and after
values. Nothing has to be looked up to act on it.

### Output

Three related record types share one dataset, distinguished by `record_type`.
The dataset ships with **Change event columns**, **Product observation columns**
and **Store check columns** views. Views keep each use case readable in the
Console; API clients should filter on `record_type` when they need one feed.

#### `change_event` - the alert feed

```json
{
  "record_type": "change_event",
  "event_type": "PRICE_CHANGED",
  "source_id": "shopify:11044168:1878194389061",
  "source_url": "https://www.allbirds.com/products/mens-wool-runners-natural-white",
  "detected_at": "2026-09-01T06:00:04+00:00",
  "store": "www.allbirds.com",
  "store_id": "11044168",
  "external_id": "competitor-a",
  "product_id": "1878194389061",
  "variant_id": null,
  "handle": "mens-wool-runners-natural-white",
  "title": "Men's Wool Runner - Natural White",
  "currency": "USD",
  "market_country": "US",
  "before": { "price_min": "110.00", "price_max": "110.00" },
  "after":  { "price_min": "98.00",  "price_max": "110.00" },
  "changed_fields": ["price_min"],
  "schema_version": 1,
  "fingerprint": "b0f4…"
}
```

`RESTOCKED`, when a product becomes sellable again:

```json
{
  "record_type": "change_event",
  "event_type": "RESTOCKED",
  "source_id": "shopify:11044168:1878194389061",
  "product_id": "1878194389061",
  "variant_id": null,
  "before": { "available": false },
  "after":  { "available": true },
  "changed_fields": ["available"]
}
```

If one variant caused the restock, the run also emits a separate
`VARIANT_AVAILABILITY_CHANGED` event with that stable `variant_id`.

`NEW_PRODUCT`:

```json
{
  "record_type": "change_event",
  "event_type": "NEW_PRODUCT",
  "source_id": "shopify:11044168:9912345678901",
  "handle": "wool-runner-go",
  "title": "Wool Runner Go",
  "before": {},
  "after": {
    "title": "Wool Runner Go",
    "handle": "wool-runner-go",
    "available": true,
    "on_sale": false,
    "price_min": "135.00",
    "price_max": "135.00",
    "variant_count": 12
  }
}
```

#### `product_observation` - the state row

One per product, with `change_type` set to the most significant event on that
product and `event_types` listing all of them. In `changesOnly` mode only
changed products appear; in `all` mode every product does.

```json
{
  "record_type": "product_observation",
  "source_id": "shopify:11044168:1878194389061",
  "source_url": "https://www.allbirds.com/products/mens-wool-runners-natural-white",
  "scraped_at": "2026-09-01T06:00:04+00:00",
  "schema_version": 1,
  "fingerprint": "b0f4…",
  "store": "www.allbirds.com",
  "store_id": "11044168",
  "store_name": "Allbirds",
  "external_id": "competitor-a",
  "status": "SUCCESS",
  "change_type": "PRICE_CHANGED",
  "event_types": ["PRICE_CHANGED", "VARIANT_PRICE_CHANGED"],
  "changed_fields": ["price_min", "price"],
  "product_id": "1878194389061",
  "handle": "mens-wool-runners-natural-white",
  "title": "Men's Wool Runner - Natural White",
  "vendor": "Allbirds",
  "product_type": "Shoes",
  "currency": "USD",
  "market_country": "US",
  "market_language": "EN",
  "price_min": "98.00",
  "price_max": "110.00",
  "compare_at_price_min": null,
  "compare_at_price_max": null,
  "available": true,
  "on_sale": false,
  "variant_count": 12,
  "variants": [
    {
      "variant_id": "31338069262480",
      "title": "Men's 10",
      "sku": "ST1UNNT101",
      "selected_options": [{ "name": "Size", "value": "Men's 10" }],
      "price": "98.00",
      "compare_at_price": null,
      "currency": "USD",
      "available": true,
      "on_sale": false,
      "image": "https://cdn.shopify.com/…"
    }
  ],
  "created_at": "2019-02-14T18:07:52Z",
  "published_at": "2019-02-15T09:00:00Z",
  "updated_at": "2026-08-31T20:23:38Z",
  "images": [{ "url": "https://cdn.shopify.com/…", "alt_text": null }],
  "description": null,
  "previous": { "price_min": "110.00", "price_max": "110.00", "available": true },
  "error": null
}
```

#### `store_check` - one row per store, always

```json
{
  "record_type": "store_check",
  "source_id": "shopify:11044168",
  "store": "www.allbirds.com",
  "store_id": "11044168",
  "store_name": "Allbirds",
  "external_id": "competitor-a",
  "status": "SUCCESS",
  "strategy": "storefront_graphql",
  "catalogue_complete": true,
  "incomplete_reason": null,
  "currency": "USD",
  "market_country": "US",
  "pages_fetched": 2,
  "products_observed": 293,
  "previous_products": 291,
  "product_delta": 2,
  "is_baseline": false,
  "new_products": 2,
  "removed_products": 0,
  "price_changes": 7,
  "sale_started": 3,
  "sale_ended": 1,
  "stock_outs": 4,
  "restocks": 9,
  "variant_changes": 21,
  "change_events": 47,
  "error": null
}
```

### Change events

| Event | Fires when |
| --- | --- |
| `BASELINE` | first successful check of a store; **no change events are emitted** |
| `UNCHANGED` | nothing moved (only visible with `outputMode: all`) |
| `NEW_PRODUCT` | a product id not present in the last successful check |
| `PRODUCT_REMOVED` | a product missing from a **verified complete** catalogue |
| `PRICE_CHANGED` | the normalized selling price range moved |
| `SALE_STARTED` | no variant had a discount before; at least one does now |
| `SALE_ENDED` | the reverse |
| `STOCK_OUT` | the product had a sellable variant and now has none |
| `RESTOCKED` | the reverse |
| `VARIANT_ADDED` | a variant id appeared |
| `VARIANT_REMOVED` | a variant id disappeared from a verified complete catalogue |
| `VARIANT_PRICE_CHANGED` | one variant's price or compare-at price moved |
| `VARIANT_AVAILABILITY_CHANGED` | one variant's availability flipped |
| `TITLE_CHANGED` | the product title changed |
| `DESCRIPTION_CHANGED` | only when `includeDescriptions` is on |

A single product can emit several at once; each is its own row, and the
product's observation lists all of them in `event_types`.

#### What "on sale" means here

A sale is a compare-at price **strictly above** the selling price. Shopify
expresses "not on sale" three different ways - `null`, `0.0`, and a compare-at
equal to the price - and real stores use all three. All three normalize to
absent, so a merchant setting `compare_at_price = price` does not show up as a
discount or as a price change.

#### What the first run does

The first successful check of a store writes a `BASELINE`: every product is
reported once with `change_type: "BASELINE"` and **zero change events**. An
existing 5,000-product catalogue is not 5,000 new products.

A store also re-baselines - deliberately, and with a log line saying so - when
the market its prices are quoted in changes, or when you change
`includeDescriptions` or `country`. Both would otherwise turn a configuration
change into a catalogue of phantom price movements.

### Correctness guarantees

These are the behaviours the Actor is built around, each covered by tests.

**A failed check is never an empty store.** `NO_RESULTS` and `SOURCE_FAILED` are
different outcomes. A store that genuinely has zero products is `SUCCESS` with
`products_observed: 0`. A 403, a rate limit, a CDN challenge page, malformed
JSON, a GraphQL protocol error or a pagination failure is `FAILED`, `PARTIAL` or
`ACCESS_RESTRICTED` - never an empty catalogue and never a set of removals.

**Removals need a verified complete catalogue.** `PRODUCT_REMOVED` and
`VARIANT_REMOVED` are emitted only after every page succeeded, the cursors
advanced without repeating and no product appeared twice. If a store went from
840 products to 17 because pagination broke, you get a `PARTIAL` row explaining
why - not 823 removal events.

**A large drop is re-verified before it is believed.** If a catalogue shrinks to
half or less (and by at least 10 products), or empties entirely, the whole
catalogue is fetched a second time. Removals are emitted only if the two fetches
agree. If they disagree, or the second fetch fails, the store is `PARTIAL` and
its stored state is left exactly as it was.

**A failure never corrupts state.** State advances only after a store returned a
complete catalogue *and* its rows reached the dataset. `PARTIAL`, `FAILED`,
`ACCESS_RESTRICTED`, skipped and password-protected stores leave the previous
good snapshot untouched, so the next successful run diffs against real history
rather than against a hole.

**One broken store cannot break the batch.** 200 stores in, 200 `store_check`
rows out, each with its own status. The run fails as a whole only on invalid
global configuration.

**Availability is never inferred from a quantity.** Shopify does not expose
inventory quantities to unauthenticated callers - it denies the fields outright.
So the output carries `available: true|false` and nothing that pretends to be a
stock level.

### Per-store statuses

| Status | Meaning |
| --- | --- |
| `SUCCESS` | complete verified catalogue; the only status that is billed |
| `PARTIAL` | real products retrieved but completeness could not be proven; no removals, state kept |
| `FAILED` | the source could not be read; no products, state kept |
| `ACCESS_RESTRICTED` | a Shopify store that blocked us (CDN challenge, 403, sustained 429); counted with failures in the run summary and broken out as `storesAccessRestricted` |
| `SKIPPED_ROBOTS` | the store's `robots.txt` disallows the paths we would use |
| `SKIPPED_PASSWORD_PROTECTED` | the store's Online Store channel is locked; out of scope |
| `NOT_SHOPIFY` | no Shopify storefront and no Shopify markers on the site |

A Shopify store that blocks the Actor is `ACCESS_RESTRICTED`, never
`NOT_SHOPIFY`. Before calling anything "not Shopify" the Actor checks the
storefront itself for independent evidence (the `powered-by` header, Shopify CDN
assets, a `myshopify.com` reference).

### How the data is retrieved

Plain HTTP only. No browser, no CAPTCHA solving, no login, no Shopify account,
no merchant API credentials.

1. **Shopify Storefront GraphQL API, tokenless.** Shopify's own documented
   public product surface. This is the primary path, and it is the reason
   coverage is better than a `/products.json` scraper: it works on Hydrogen and
   headless storefronts where that legacy route returns 404.
2. **`/products.json`.** Used only where a host serves it but no Storefront API.
   In live testing this rescued a real store whose catalogue is reachable no
   other way.

Product identity uses Shopify's own stable ids: `shopify:{shop_id}:{product_id}`
and `shopify:{shop_id}:{product_id}:{variant_id}`. `shop_id` is identical whether
you enter a custom domain or the `.myshopify.com` domain, so a store that changes
domains keeps its monitoring history. Never a row number, a page number, a title
or a price.

On the fallback path the JSON route carries no shop id, so `/meta.json` is asked
once for it. Where that answers, identity and currency match the primary path
exactly; where it does not, identity falls back to the host and `currency` is
reported as `null` rather than guessed.

### Limitations

- **Password-protected stores are out of scope.** They are detected, reported as
  `SKIPPED_PASSWORD_PROTECTED`, never charged, and never worked around.
- **Catalogues above 25,000 products cannot be reported complete.** That is
  Shopify's own ceiling - its Storefront API refuses to paginate further. Such a
  store returns its first 25,000 products as `PARTIAL` with an explicit reason,
  and never generates removals. Two of the fifty stores tested hit this.
- **No inventory quantities.** Availability only. See above.
- **A store reached only through `/products.json` may report no currency.** That
  route carries none, and if the store serves no `/meta.json` either, prices are
  returned with `currency: null` rather than an assumed one. Check `strategy` on
  the `store_check` row to see which path a store used.
- **No customer, cart, checkout, order, account or admin data.** Public catalogue
  information exclusively.
- **Descriptions are off by default.** They are the largest field in a catalogue
  and change for editorial reasons. Turn on `includeDescriptions` if you want
  `DESCRIPTION_CHANGED`.
- **Image URLs are reported but never diffed.** Shopify's CDN rewrites their
  version string without the picture changing, so diffing them would be pure
  noise.
- **A store's default market depends on where the run originates.** Set
  `country` to pin it. The market actually applied is on every record as
  `market_country` / `market_language` / `currency`.

### robots.txt

Every entry in a watchlist is a different merchant's domain, so `robots.txt` is
fetched once per host per run and honoured with correct user-agent grouping. If
the paths the Actor would use are disallowed, the store is reported
`SKIPPED_ROBOTS` and nothing is fetched. `Crawl-delay` is applied when the
matching group sets one. This is not hypothetical: one of the fifty stores tested
disallows `/api/` for all crawlers, and is skipped.

Requests identify themselves as `ShopifyPriceStockMonitor`.

### Proxy

**Off by default, and a proxy will probably not help.** Every store that
responded at all during testing responded over direct HTTP, and the ones that
did not were re-tested through Apify's residential proxy: **all four came back
`ACCESS_RESTRICTED` again, with the same errors.**

That result is not surprising once you look at why those stores fail. Most are
headless storefronts that simply do not serve the Storefront API at the domain
you typed, so there is no route for a different IP to reach. The rest answer
with a bot challenge rather than an IP-reputation block. Neither is something a
proxy fixes.

`proxyConfiguration` is still exposed, because a proxy is occasionally the right
tool for a specific store or a specific network, and because some users are
required to route egress through one. But treat `ACCESS_RESTRICTED` as "this
host does not serve us", not as "try harder with a better IP".

The Actor parallelises across stores (five at a time) and stays strictly
sequential within a store, with a minimum interval per host. It never bursts a
single merchant.

### Performance and cost

Two watchlists, run end to end with the shipped defaults - direct HTTP, no
proxy, five stores at a time:

| Watchlist | Stores | Products | Requests | Traffic | Wall clock |
| --- | --- | --- | --- | --- | --- |
| 10 mid-size stores | 10 | 4,674 | 36 | 24 MB | 13 s |
| 50-store mixed fleet | 50 | 84,551 | 454 | 438 MB | 9 min |

The fleet number is worth unpacking, because two outliers dominate it. Two
25,000-product stores took 1,050 of the run's 1,454 store-seconds, and because
the run is only as fast as its slowest member, they set the whole 9-minute wall
clock. **The other 48 stores - 34,551 products between them - took 405
store-seconds, about 80 seconds of wall clock at five-way concurrency.**
Catalogue sizes are heavily skewed: across the fleet the median store had 293
products and the 90th percentile 2,624, so a watchlist normally costs far less
than its largest member suggests.

Per-unit figures, measured over 74,871 products in 35 stores:

| Metric | Value |
| --- | --- |
| Requests per 1,000 products | 4.2 (250 products per request, the API maximum) |
| Traffic per 1,000 products | 0.9 - 8.3 MB, median 2.6 MB |
| Time per 1,000 products (one store, sequential) | ~10 s |
| Retries / sustained throttling across the fleet | 3 retries, none |

**Traffic scales with variants, not products.** A 1,000-product store with one
variant each costs 0.9 MB; one with 24 variants each costs 8.3 MB. Product count
alone will mislead you when sizing a run. Two settings move it further:

| Setting | Traffic per 1,000 products |
| --- | --- |
| `includeImages: false` | ~2.0 MB |
| default (images on) | ~4.4 MB |
| images + `includeDescriptions: true` | ~5.0 MB |

Memory tracks the largest catalogues **in flight**, not the length of the
watchlist: each store's rows are written out and released as it finishes, so a
200-store run holds no more than a 5-store one. Measured peaks were 77 MB for
the 10-store watchlist and 1.4 GB for the fleet, the latter set by two
25,000-product catalogues being processed at the same time. The default 2 GB
covers that worst case; 1 GB is comfortable for watchlists without
extreme-catalogue stores.

A monitoring run costs the same to *fetch* whether anything changed or not -
Shopify offers no "changed since" filter on the public API - but far less to
*deliver*, because `changesOnly` emits nothing for products that did not move. A
second live pass over five stores minutes after the first returned five store
rows, zero product rows and zero events.

### Pricing

Pay per event, priced on what was actually delivered:

| Event | Price | Charged for |
| --- | --- | --- |
| `store-check` | $0.02 | one per store that returned a complete, verified catalogue |
| `product-change` | $0.0005 | one per change event delivered |
| `product-observations-1k` | $0.30 | one per 1,000 full product rows delivered |
| `apify-actor-start` | $0.00005 per GB | once per run, for the container start |

**The three result events are charged only for a store that returned a complete,
verified catalogue.** Nothing else is billed: not retries, and not `PARTIAL`,
`FAILED`, `ACCESS_RESTRICTED`, password-protected, robots-skipped or non-Shopify
stores - including their products and their change events, which are still
delivered in full. A store named twice in one watchlist is fetched once, charged
once, and returned under both external IDs.

The start event is the one exception, and it is the platform's standard run
overhead: at the default 2 GB it is **$0.0001 per run**. A run in which every
single store failed therefore costs a hundredth of a cent, and nothing else.

Why this shape rather than per product: a successful check is worth something
even when nothing changed, and a daily monitor should not cost the same as a full
export every single day. Two illustrative runs:

- **50-store daily monitor, quiet day** - 50 checks, ~120 changes:
  $1.00 + $0.06 + $0.0001 = **$1.06**.
- **50-store full snapshot, 40,000 products** - 50 checks, 40,000 observations:
  $1.00 + $12.00 + $0.0001 = **$13.00**.
- **A run where every store was blocked** - 0 checks: **$0.0001**.

#### Why change-only monitoring costs less

This pricing is designed around the recurring job, not around winning a
short-lived "cheapest scraper" claim. A quiet 50-store daily watchlist costs
about **$1.05** in the default `changesOnly` mode: successful store checks plus
roughly 100 actual changes. Returning all ~15,000 products from the same
watchlist would cost about **$5.50** instead. That is roughly 80% less when what
you need is the change feed, using the same Actor and the same current prices.

A one-off snapshot remains inexpensive at **$0.30 per 1,000 product rows**. The
larger saving comes from not buying those rows again every day when nothing in
them changed.

One thing to be deliberate about: `outputMode: all` in monitor mode returns
every product on every run, so it re-bills the whole catalogue daily and costs
about five times the default. That is the pattern this Actor exists to replace.
Use `all` for a one-off export, and leave the default in place for monitoring.

### Input reference

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `storeUrls` | string list | empty | Fast start: paste storefront URLs or domains, one per line |
| `stores` | object array | empty | Advanced entries shaped as `{ "url": …, "externalId": … }`; combined limit with `storeUrls` is 200 |
| `mode` | `monitor` | `snapshot` | `monitor` | `snapshot` neither reads nor writes state |
| `outputMode` | `changesOnly` | `all` | `changesOnly` | In `monitor`, returns the baseline, changes and store rows. In `snapshot`, returns store rows only. `all` returns every product. |
| `includeImages` | boolean | `true` | up to 10 image URLs per product; never diffed |
| `includeDescriptions` | boolean | `false` | enables `DESCRIPTION_CHANGED`; changing it re-baselines |
| `country` | ISO 3166-1 alpha-2 | none | pins the market prices are quoted in; changing it re-baselines |
| `proxyConfiguration` | object | off | only for stores reported `ACCESS_RESTRICTED` |

### Call it from your own code

Run it and get the rows back in one request:

```bash
curl -X POST "https://api.apify.com/v2/acts/automa-flow~shopify-price-stock-monitor/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "stores": [
      { "url": "https://www.allbirds.com", "externalId": "competitor-a" }
    ],
    "mode": "monitor",
    "outputMode": "changesOnly"
  }'
```

Fetch the change-oriented columns from a finished run:

```bash
curl "https://api.apify.com/v2/datasets/DATASET_ID/items?view=changes&clean=true&format=json" \
  -H "Authorization: Bearer $APIFY_TOKEN"
```

Then filter client-side on `record_type == "change_event"`. In the Console, the
**Change event columns** view selects the useful alert fields but intentionally
keeps `record_type` visible for the same reason. Every record carries
`source_id`, `scraped_at`, `schema_version` and `fingerprint`, so rows from
different runs join to each other without any extra bookkeeping on your side.

#### Use it from an AI agent through MCP

Apify's hosted MCP server can expose this public Actor as a tool with structured
input and output. Add this server URL to an MCP-compatible client and sign in
through Apify when prompted:

```text
https://mcp.apify.com?tools=automa-flow/shopify-price-stock-monitor
```

Example prompt for a one-off acquisition workflow:

```text
Run automa-flow/shopify-price-stock-monitor in snapshot mode with all product
output for these Shopify store domains. Compare price ranges, sale status,
availability and variant counts, and cite each product's source_url.
```

For a recurring agent workflow, use `mode: monitor` and
`outputMode: changesOnly`, then act only on `record_type: change_event`. A first
run is a `BASELINE`, not evidence that every product was newly launched.

### Run summary

`RUN_SUMMARY` in the default key-value store carries per-run economics and
counts: stores requested, successful, partial, failed and skipped; products
observed; every event type's count; suspicious catalogue drops; requests,
retries, 429s, 403s and bytes; and how many state snapshots were saved versus
retained.

### Legal and data handling

The Storefront API is Shopify's documented public product surface, and this
Actor reads public catalogue data only: products, variants, prices, compare-at
prices, availability, images. It collects no personal data of any kind. It does
not touch customer, cart, checkout, order, account or admin endpoints, does not
authenticate, and does not bypass any access control - password-protected stores
are detected and skipped rather than opened. Per-merchant `robots.txt` is
fetched and honoured for every host on every run.

You are responsible for your own use of the data, including any terms you have
agreed to with a specific merchant.

# Actor input Schema

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

Fast start: paste one to 200 public Shopify storefront URLs or domains, one per line. Use Advanced store entries below only when you need your own external IDs on the output.

## `stores` (type: `array`):

Optional JSON entries for stores that need an externalId echoed into every result. You may use this together with Store URLs; the combined limit is 200 unique input rows.

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

monitor compares each store against its last complete successful catalogue and emits change events. snapshot returns the current catalogue without reading or writing monitoring state.

## `outputMode` (type: `string`):

In monitor mode, changesOnly omits unchanged products but returns the first-run BASELINE, every change event and one store\_check row per store. In snapshot mode, changesOnly returns store\_check rows only for lightweight watchlist validation. all returns every product.

## `includeImages` (type: `boolean`):

Return up to 10 image URLs per product. Image URLs are never used for change detection, because Shopify's CDN rewrites them without the picture changing.

## `includeDescriptions` (type: `boolean`):

Return plain-text descriptions and monitor them for DESCRIPTION\_CHANGED. Off by default: descriptions are the largest field in the catalogue and change for reasons pricing teams rarely care about. Turning this on or off starts a new baseline.

## `country` (type: `string`):

Optional ISO 3166-1 alpha-2 code, for example GB or DE. Pins the market prices are quoted in, so results do not move when the run's egress location does. Shopify falls back to the store's default market if it does not sell to that country; the market actually applied is reported on every record. Changing this starts a new baseline.

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

Optional, and usually not the fix. All 35 reachable stores in testing worked over direct HTTP, and the blocked ones stayed blocked through a residential proxy, because they are headless hosts that serve no API rather than IP-blocked ones. Use it if your network requires egress through a proxy, or for a specific store you know a proxy helps.

## Actor input object example

```json
{
  "storeUrls": [
    "https://www.allbirds.com",
    "https://kith.com"
  ],
  "stores": [
    {
      "url": "https://www.allbirds.com",
      "externalId": "competitor-a"
    },
    {
      "url": "example-store.myshopify.com"
    }
  ],
  "mode": "monitor",
  "outputMode": "changesOnly",
  "includeImages": true,
  "includeDescriptions": false,
  "country": "GB",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

No description

## `changes` (type: `string`):

Change-oriented dataset projection. Filter returned rows on record\_type=change\_event when consuming the API.

## `runSummary` (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"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/shopify-price-stock-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.allbirds.com"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/shopify-price-stock-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.allbirds.com"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call automa-flow/shopify-price-stock-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/shopify-price-stock-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/MNg7jZPBynPqwJ5mB/builds/NPVLXpGsgYGsnATop/openapi.json
