# Shopify Price & Stock Monitor – Competitor Price Change Tracker (`pooh20240/shopify-price-stock-monitor`) Actor

Give a list of Shopify store domains and get price, compare-at price and stock availability for every product variant, plus a built-in changes view (price/stock moves, new and delisted products) vs the previous run.

- **URL**: https://apify.com/pooh20240/shopify-price-stock-monitor.md
- **Developed by:** [Thongpoo Supong](https://apify.com/pooh20240) (community)
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.25 / 1,000 product rows

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

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

## What's an Apify Actor?

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

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

## How to integrate an Actor?

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

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

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

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

# README

## Shopify Price & Stock Monitor (products.json diff)

Given a list of Shopify storefront domains, fetch **price, compare-at price and
stock availability for every product variant** straight from the public
`/products.json` feed — plain HTTP, no login, no cookies, no browser. The actor
ships a built-in **changes view**: it remembers each store's snapshot in the
named KV store `shopify-price-stock-monitor-state` and, in monitoring mode,
emits only rows that moved — price / compare-at / availability changes, plus new
and delisted products — so scheduled runs report exactly what changed.

Status: **M3** (pay-per-event `product-row` + `store-checked`, default full
snapshot, changes mode for scheduled monitoring).

Billing: `product-row` ($1.25 / 1,000 rows actually pushed) and `store-checked`
($0.002 per healthy store, even when 0 rows change). See `PRICING.md`.

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `storeDomains` | array\[string] | required | storefront hosts, e.g. `["brooklinen.com","allbirds.com"]` (URLs also accepted, normalized to host) |
| `onlyChanges` | boolean | `false` | **Turn ON for scheduled monitoring:** emit only rows whose price / compare-at / availability changed, plus new & delisted products. OFF (default) = full snapshot of every current row. The first run of a store is always a full baseline. |
| `priceChangedOnly` | boolean | `false` | further restrict to rows where `price` or `compareAtPrice` changed (availability-only moves are skipped) |
| `maxItemsPerStore` | int | `10000` | hard cap per store (the feed itself caps at ~1,000 products) |
| `maxItems` | int | `50000` | hard cap total across all stores |
| `includeFullFields` | boolean | `false` | add `tags`, `images[]`, `createdAt`, `updatedAt`, `bodyHtml` (truncated) |

Stores are processed **sequentially** (1 req/s per host, 30 s timeout, 3× retries
with backoff for 429/5xx/network errors; `Retry-After` honoured and capped).

### Scheduling recipe

For a daily or weekly price/stock alert, schedule the actor with `onlyChanges`
turned **on**:

```json
{
  "storeDomains": ["brooklinen.com", "allbirds.com"],
  "onlyChanges": true,
  "priceChangedOnly": false,
  "maxItemsPerStore": 200,
  "maxItems": 50000
}
```

- The **first** run of each store is always a full baseline (every row
  `isNew: true`); every run after that emits only the moves.
- A scheduled run with nothing changed pushes **0 rows** but still checks every
  store (one `store-checked` per healthy store) and writes a SUMMARY.
- Keep the actor's named key-value store `shopify-price-stock-monitor-state`
  between runs — that is the snapshot it diffs against. Wiping it resets to a
  fresh baseline.
- The default input (`onlyChanges: false`) always yields a full, non-empty
  snapshot, so an unscheduled "run now" or a daily auto-test is never empty.

### Output

**Images and description.** Every row has `imageUrl` (the variant image, else the first product image). Turn on **Include full product fields** to also get `images` (all image URLs), `descriptionText` (plain text, first 1,000 characters), `bodyHtml`, `tags`, `createdAt` and `updatedAt`.

**Change rows read old → new.** Every row carries `previousPrice`, `previousCompareAtPrice` and `previousAvailable` (null for new products), so a price move looks like `previousPrice: 16 → price: 21` with no dataset join. Use the **Changes** table view in the Console, or filter on `changeType` (`price`, `compareAt`, `availability`, `new`, `delisted`). `inventoryQuantity` is usually null: Shopify does not publish stock counts on the public feed, only in-stock yes/no.

One dataset row per **product × variant** (or one row for a delisted variant):

| Field | Type | Example |
|---|---|---|
| `store` | string | `brooklinen.com` |
| `productId` | number | `6578170331226` |
| `handle` | string | `super-plush-hand-towels-last-call` |
| `title` | string | `Super-Plush Turkish Cotton Hand Towels Set of 2 - Last Call` |
| `url` | string | `https://brooklinen.com/products/super-plush-hand-towels-last-call` |
| `vendor`, `productType` | string | `Brooklinen` / `Towels` |
| `variantId` | number | `39421111271322` |
| `variantTitle` | string | `Ocean / 2-Pack` (option summary) |
| `sku` | string|null | `BKN-TW-OCN` |
| `options` | object | `{"Color":"Ocean","Size":"2-Pack"}` |
| `price` | number | `24.9` (parsed from string) |
| `compareAtPrice` | number|null | `39.0` (null = not on sale) |
| `currency` | string|null | `USD` (from meta.json; null if meta unreachable) |
| `available` | boolean | `true` |
| `inventoryQuantity` / `grams` | int|null | `12` / `340` (may be absent on some themes) |
| `isNew` | boolean | `true` on the first run (baseline) or when a variant first appears |
| `priceChanged` / `compareAtChanged` / `availabilityChanged` | boolean | flag what moved vs the previous run |
| `changeType` | string | `price` | `compareAt` | `availability` | `new` | `delisted` | `unchanged` (first matching priority) |
| `status` | string | `active` (present) — `delisted` rows carry the prior snapshot's title/url |
| `scrapedAt` | ISO string | when the row was collected |

Prices are compared **numerically** (`24.9` equals `"24.90"`). On the first run
of a store there is no baseline, so every row is emitted with `isNew: true` —
that is the paid baseline snapshot. A `SUMMARY` key-value record lists one status
row per store with `rowsFound` / `rowsPushed` / `pages` / `complete`. If **every**
store fails, the run fails loudly — never a silent empty dataset.

### Per-store status codes (never silent empty output)

| Status | Meaning |
|---|---|
| `OK` | full catalogue fetched, last page short |
| `CAPPED` | feed stopped on a full page (storefront caps at ~1,000 products) — documented limitation, not an error |
| `EMPTY_CATALOGUE` | `products.json` returned `{"products":[]}` |
| `HEADLESS_STOREFRONT` | `products.json` 404 on host, `www.` and apex (headless/disabled storefront) |
| `PASSWORD_PROTECTED` | `products.json` redirected to `/password` |
| `NOT_SHOPIFY` | `products.json` answered without a `products` payload |
| `BLOCKED` | 403 / DataDome / Vercel security checkpoint — recorded and stopped, never bypassed |
| `RATE_LIMITED` | HTTP 429 after retries (Retry-After honoured, capped 60 s) |
| `ERROR` | network error or non-transient failure after retries |
| `SKIPPED` | the run stopped before this store: global `maxItems` budget or the pay-per-event charge limit was reached |

`OK`, `EMPTY_CATALOGUE` and `CAPPED` stores are billed one `store-checked` each
(even when they push 0 changed rows). `BLOCKED`, `RATE_LIMITED`,
`HEADLESS_STOREFRONT`, `PASSWORD_PROTECTED`, `NOT_SHOPIFY`, `ERROR` and
`SKIPPED` stores are **free** — you get the loud status line instead of a charge.

### How monitoring works

- Per-store snapshot in named KV `shopify-price-stock-monitor-state`, key
  `store-<sha1(host)>` (the `www.` prefix is ignored so both spellings share one
  snapshot). Snapshot = `{"<handle>:<variantId>": {price, compareAtPrice,
  available, title, url, productId, vendor, productType, variantTitle, sku,
  options, currency, inventoryQuantity, grams, firstSeenAt, lastSeenAt,
  delistedAt?}}`.
- Only rows **actually pushed** update the snapshot; on a repeat run,
  fetched-but-unchanged rows refresh `lastSeenAt` too. A **first run's** baseline
  snapshot holds only the pushed rows, so rows cut by `maxItems` /
  `maxItemsPerStore` stay out and the next run still reports them as new. **A
  changed row cut off by `maxItems` / `maxItemsPerStore` is NOT written**, so the
  next run still reports it (see Limitations).
- Delisted rows (products present in the snapshot, absent now) are emitted with
  the previous snapshot's title/url **only when the store fetch was complete** —
  a capped or budget-truncated store never generates false "delisted" rows, and
  its untouched snapshot entries stay exactly as they were. (In full-snapshot
  mode `onlyChanges: false`, delistings are not part of the output.)
- Delisted entries older than 90 days are pruned from the snapshot.
- State is saved per store at the end of processing and again on Apify
  `migrating` / `aborting` events.

### Limitations

- **~1,000 product feed cap**: Shopify's public `/products.json` is capped at 5
  pages × 250; larger catalogues are reported `CAPPED` (`complete: false`) and
  never produce delisted rows.
- **Delisting needs a complete fetch**: a run whose store was capped or cut by
  `maxItems` is not authoritative, so it emits no `delisted` rows. You need at
  least one prior full `complete: true` run before delistings can be reported.
- **First run = baseline**: expect every row to come back `isNew` once per store
  (or once more if the store spelling produced a new `store-<sha1>` key). On a
  **capped first run** (`maxItems`/`maxItemsPerStore`), the baseline stores only
  the pushed rows, so the remaining rows come back `isNew` on the next run —
  raise the cap (e.g. `50000`) to baseline the whole catalogue in one go.
- **Changed rows held back by `maxItems` are reported on the next run** — because
  they are not written to state, the following run still sees them as changed and
  emits them. Raising `maxItems` (e.g. `50000`) avoids the wait.

### Out of scope

- Login/password catalogue scrape, reviews, sales counts, customer data.
- BigCommerce/WooCommerce detection — v1 is Shopify Core only (`NOT_SHOPIFY`).
- BYOK proxies / IP rotation / browser crawling — this actor is plain HTTP.

# Actor input Schema

## `storeDomains` (type: `array`):

Storefront hosts, e.g. \["fashionnova.com","brooklinen.com"]. URLs are also accepted and normalized to the bare host. Each host is tried, then www.<host>, then the apex; the first that serves /products.json wins.

## `onlyChanges` (type: `boolean`):

Turn ON for scheduled monitoring: emit only rows whose price, compare-at price or availability changed since the previous run, plus new and delisted products. OFF (default) = full snapshot of every current row; the first run of a store is always a full baseline.

## `priceChangedOnly` (type: `boolean`):

Further restrict to rows where price or compareAtPrice changed; availability-only moves are skipped.

## `maxItemsPerStore` (type: `integer`):

Hard cap per store. Note the storefront feed itself caps at ~1,000 products, so stores larger than that are reported as CAPPED.

## `maxItems` (type: `integer`):

Hard cap total across all stores.

## `includeFullFields` (type: `boolean`):

When true, add tags, images\[], createdAt, updatedAt and a truncated bodyHtml — the products.json fields beyond the price core.

## Actor input object example

```json
{
  "storeDomains": [
    "brooklinen.com",
    "allbirds.com"
  ],
  "onlyChanges": false,
  "priceChangedOnly": false,
  "maxItemsPerStore": 200,
  "maxItems": 50000,
  "includeFullFields": false
}
```

# Actor output Schema

## `rows` (type: `string`):

One dataset item per product variant: price, compareAtPrice, available, changeType and previous values when changed.

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

Per-store status (OK, CAPPED, BLOCKED, NOT\_SHOPIFY, ...), rows found and pushed.

# 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 = {
    "storeDomains": [
        "brooklinen.com",
        "allbirds.com"
    ],
    "maxItemsPerStore": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("pooh20240/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 = {
    "storeDomains": [
        "brooklinen.com",
        "allbirds.com",
    ],
    "maxItemsPerStore": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("pooh20240/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 '{
  "storeDomains": [
    "brooklinen.com",
    "allbirds.com"
  ],
  "maxItemsPerStore": 200
}' |
apify call pooh20240/shopify-price-stock-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,pooh20240/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/XsT9SCY4RDjMfbtRd/builds/eVQIleSDUDvPqPzVD/openapi.json
