Shopify Store Monitor — Price, Stock & Catalog Changes
Pricing
from $1.00 / 1,000 variant rows
Shopify Store Monitor — Price, Stock & Catalog Changes
Monitors Shopify stores on a schedule and returns what changed since the last run: price moves, sales starting and ending, new and removed products, out-of-stock and back-in-stock variants. Pay per change, not per crawl.
Pricing
from $1.00 / 1,000 variant rows
Rating
0.0
(0)
Developer
John Dow
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
Give this Actor a list of Shopify store domains and it returns their catalogs together with what changed since the previous run — price moves, sales starting and ending, products appearing and disappearing, variants selling out and coming back. It is built to sit on a daily schedule: the first run records a baseline, and every run after that hands you a short list of changes instead of another full catalog dump.
Copy this input and press Start
The default input works as-is — no editing required.
{"storeUrls": ["https://www.deathwishcoffee.com"],"mode": "changes_only","stateStoreName": "shopify-monitor-state","maxProductsPerStore": 25,"includeUnavailable": true,"requestDelayMs": 400,"maxConcurrentDomains": 3,"proxyConfiguration": { "useApifyProxy": true }}
This default is a small sample — one store, 25 products — so trying the Actor costs a few cents. Raise maxProductsPerStore and add your own domains for real monitoring. Rows are billed individually, so a full catalog is a deliberate choice rather than something the Start button drops you into; see What it costs.
The first run writes every variant with change_status: "baseline" — that is the snapshot the next run compares against. It is a full catalog export and is priced like one. Run it again tomorrow and you get only what moved, at a fraction of the cost.
Input reference
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
storeUrls | array of string | yes | — | Store domains or any URL from them. shop.com, www.shop.com, https://shop.com/products/x and https://shop.com/collections/all all work. Paths, query strings and redirects are resolved; duplicates are removed. |
mode | enum | no | changes_only | changes_only, changes_and_new or full_catalog — see Output modes. |
stateStoreName | string | no | shopify-monitor-state | Named Key-Value Store holding one state record per domain between runs. |
compareDatasetId | string | no | null | Build the baseline from a previous run's dataset instead of the state store. |
maxProductsPerStore | integer | no | 5000 | Per-store safety cap. |
includeUnavailable | boolean | no | true | Write rows for out-of-stock variants. |
productHandleFilter | array of string | no | [] | Only output these product handles. |
vendorFilter | array of string | no | [] | Only output these vendors (case-insensitive). |
minPrice / maxPrice | integer | no | null | Price range filter, in the store's own currency. |
requestDelayMs | integer | no | 400 | Pause between requests to the same store. |
maxConcurrentDomains | integer | no | 3 | How many stores to process at once. |
proxyConfiguration | object | no | Apify Proxy | Standard Apify proxy object. Datacenter by default. |
Filters apply to the output only, never to the stored state, so changing a filter between runs cannot corrupt change detection.
Output modes
| Mode | What lands in the dataset |
|---|---|
changes_only | Price, sale and stock changes on variants you already saw, plus removals. New products and variants are not included. |
changes_and_new | The above, plus new_product and new_variant rows. |
full_catalog | Every variant on every run, changed or not. Use this for one-off catalog exports. |
In every mode, the first run for a domain writes the full catalog as baseline rows — there has to be something to compare against.
Output
Two record types share the dataset, told apart by record_type. Both are completely flat, so CSV export keeps every field and adds no nested structures.
record_type: "variant" — one row per variant
A product with three sizes produces three rows, because price and availability live on the variant.
| Field | Type | Description |
|---|---|---|
domain | string | Normalised host |
record_type | string | Always variant for these rows |
change_status | string | Exactly one of the statuses below |
product_id | number | null | Shopify product ID |
product_handle | string | null | Product slug |
product_title | string | null | Product name |
vendor | string | null | Brand |
product_type | string | null | Product type |
tags | string | Tags, comma-separated |
variant_id | number | null | Shopify variant ID |
variant_title | string | null | Variant name |
sku | string | null | SKU |
option1 / option2 / option3 | string | null | Option values (size, colour, …) |
price | number | null | Current price |
compare_at_price | number | null | Struck-through price ("0.00" is normalised to null) |
currency | string | null | Read from /cart.js; null when unavailable |
on_sale | boolean | compare_at_price > price |
discount_pct | number | null | Discount off the compare-at price, one decimal |
available | boolean | In stock |
previous_price | number | null | Set when the price changed |
price_change_pct | number | null | Price change in percent, two decimals |
previous_available | boolean | null | Set when availability changed |
product_url | string | Direct link to the product |
image_url | string | null | First product image |
published_at | string | null | ISO timestamp |
scraped_at | string | ISO timestamp of this run |
Change statuses
| Status | Meaning |
|---|---|
baseline | First run for this domain — nothing to compare against yet |
new_product | The product was not in the previous state |
new_variant | The product was, this variant was not |
removed | Present in the previous state, absent from the current catalog |
price_up / price_down | The price moved |
sale_started | compare_at_price went from empty or ≤ price to > price |
sale_ended | The reverse |
back_in_stock / sold_out | Availability flipped |
unchanged | None of the above |
When several things change at once, one status wins, in this order:
new_product › new_variant › removed › sold_out › back_in_stock › price_down › price_up › sale_started › sale_ended › unchanged.
The losing changes are not lost — a variant that both sold out and dropped in price is reported as sold_out with previous_price and price_change_pct filled in.
record_type: "store_status" — one row per unreadable store
| Field | Description |
|---|---|
domain | Normalised host |
record_type | store_status |
store_status | catalog_restricted, password_protected, not_shopify or unreachable |
detection_method | Which check identified the store, e.g. meta.json |
message | Plain-English explanation and what to do about it |
scraped_at | ISO timestamp |
RUN_SUMMARY
A machine-readable report is written to the run's default Key-Value Store under the key RUN_SUMMARY — not into the dataset, so it never pollutes your export. It is written on every outcome, including aborts and failures.
{"started_at": "2026-08-16T06:00:00.000Z","finished_at": "2026-08-16T06:04:12.000Z","domains_input": 12,"domains_ok": 9,"domains_catalog_restricted": 1,"domains_password_protected": 1,"domains_not_shopify": 0,"domains_unreachable": 1,"products_seen": 4210,"variants_seen": 9877,"rows_written": 143,"changes_by_status": { "price_down": 31, "new_product": 12, "sold_out": 4 },"truncated_domains": [],"truncation_suppressed_rows": {},"invalid_inputs": [],"duplicate_inputs": [],"errors": [{ "domain": "x.com", "reason": "timeout after 3 attempts" }],"mode": "changes_only","status": "SUCCEEDED"}
status is SUCCEEDED when every domain was resolved, PARTIAL when some failed but results exist, and FAILED when nothing could be processed.
If a catalog is closed
catalog_restricted is a result, not a bug. It means the domain is definitely a Shopify store, but the owner has switched off the public product feed. There is no public catalog left to read, and no scraper can produce one without the owner turning it back on.
The Actor never fails silently on this. Every domain runs through a seven-step detection chain, and the first check that succeeds decides the answer:
| # | Check | Success signal |
|---|---|---|
| 1 | GET /products.json?limit=250 | HTTP 200, JSON content type, body has a products array |
| 2 | GET /collections/all/products.json?limit=250 | Same — some stores close the root path only |
| 3 | GET /meta.json | HTTP 200, JSON with id and name |
| 4 | GET /cart.js | HTTP 200, JSON with token or currency |
| 5 | Homepage response headers | x-shopid, x-shopify-stage, x-sorting-hat-shopid or powered-by: Shopify |
| 6 | Homepage cookies | _shopify_y, _shopify_s or secure_customer_sig |
| 7 | Homepage HTML | Contains cdn.shopify.com, Shopify.shop or shopify-features |
Which produces one of five honest outcomes:
| Status | Meaning | Charged? |
|---|---|---|
ok | Check 1 or 2 succeeded — the catalog is extracted | yes |
catalog_restricted | A Shopify store, but the public feed is off | no |
password_protected | The storefront sits behind a Shopify password page | no |
not_shopify | None of the seven checks matched | no |
unreachable | DNS failure, timeout or connection error after retries | no |
Checks 1 and 2 are tried on the domain you supplied and on wherever its homepage redirects, because plenty of real stores redirect the homepage to a www or regional host while only the original host serves /products.json.
Running it on a schedule
- Run the Actor once and check the results.
- Open the Actor → Schedules → create a schedule, e.g.
0 6 * * *for 06:00 daily. - Keep
stateStoreNamethe same across runs. That named Key-Value Store is what makes change detection work.
How the state works. After each successful run the Actor stores one compact record per domain, keyed state-<host>, mapping every variant_id to its price, compare-at price, availability, title and product handle. The next run reads that record, compares, and overwrites it. Storing a compact map rather than re-reading the previous dataset keeps runs cheap and fast.
A few properties worth knowing:
- State is only written after a successful extraction. If a store times out mid-crawl, its previous state is left untouched, so the next run does not report the whole catalog as removed.
- Deleting the state store never breaks a run. The next run simply treats every domain as a first run — which means a fresh baseline, priced as a full catalog export. Keep the state store to keep the runs cheap.
- Different lists can run side by side by pointing them at different
stateStoreNamevalues. Each name carries its own baseline, so each one pays for its own first run. - Prefer
compareDatasetIdfor explicit control. Set it to a previous run's dataset ID and the baseline is rebuilt from that dataset instead; the state store is then ignored and not updated. It works best against a dataset produced infull_catalogmode, since achanges_onlydataset only lists rows that changed.
What it costs
The Actor is priced per event, so on a schedule you pay for the changes, not for the crawl:
| Event | Price | When it is charged |
|---|---|---|
| Actor Start | $0.01 | Once per run, per GB of run memory (minimum one) |
| Store detected | $0.005 | Once per domain confirmed to be a Shopify store with a readable catalog |
| Variant row | $0.001 | Per variant row written to the dataset |
Nothing is charged for not_shopify, unreachable, password_protected or catalog_restricted domains — a domain that yields no catalog yields no bill, only a store_status row explaining why. unchanged variants cost nothing in changes_only mode because they are never written.
Rows on the first run for a domain are charged like any other row. A first run is a full catalog export, and that is what it is priced as; the saving comes from every run after it.
Worked example — 10 competitor stores, ~2,000 variants each, daily for a month, at 512 MB (one Actor Start event per run):
- First run, the baseline: $0.01 + 10 × $0.005 + 20,000 rows × $0.001 = $20.06.
- Each following run, with say 60 changed variants across the 10 stores: $0.01 + $0.05 + 60 × $0.001 = $0.12.
- 1 baseline run + 29 daily runs ≈ $23.54 for the month.
Exporting the same 10 catalogs in full every day instead — full_catalog mode, 20,000 rows per run — costs about $20.06 per run, roughly $600 a month. You pay the export price once, then about twelve cents a day. That gap is the whole point of the delta engine.
If you set a spend limit, the run respects it. When maxTotalChargeUsd is reached the Actor stops instead of crawling on: remaining stores are skipped, RUN_SUMMARY reports charge_limit_reached: true with rows_written reflecting what was really stored, and the run ends PARTIAL. Crucially, no state is saved for a store that was cut off — otherwise you would have paid for a partial catalog while the baseline recorded the whole one, making every row you never received look unchanged forever. Raise the limit, re-run, and the store is picked up from scratch.
Two things follow from this, worth planning around:
- The baseline scales with catalog size, nothing else does. A store with 30,000 variants costs $30 to baseline and then cents a day to monitor. Use
maxProductsPerStoreto put a ceiling on that first run, and keep the state store so you only pay it once. - A one-off export (scenario C) costs the export price, once. Run
full_catalog, take the data, walk away. There is no cheaper path — a freshstateStoreNamejust buys another baseline at the same price.
Known limitations
- No exact stock counts.
/products.jsonexposes a booleanavailableflag only. There is no way to get units-on-hand from the public endpoint, so the Actor reports availability, not inventory. - Base currency only. Multi-currency stores (Shopify Markets) serve their base currency on
/products.json. Prices are the store's base prices, andcurrencycomes from/cart.js. - Public catalogs only. No anti-bot circumvention, no headless browser, no JS rendering, no logging in. If the owner closed the feed, the answer is
catalog_restricted. - Recreated products look like a removal plus an addition. When a store deletes and re-creates a product, Shopify issues new variant IDs, so you will see
removedrows alongsidenew_product/new_variantrows. That is accurate — the identifiers really did change. - Truncated catalogs cannot prove absence. If a store exceeds
maxProductsPerStore, the run keeps what it read, lists the domain intruncated_domains, and skipsremoveddetection. If the previous run was truncated, variants with no prior state are also withheld instead of being reported asnew_product, since a partial sample cannot tell "new" from "not sampled last time". The count is reported per domain intruncation_suppressed_rows; raisemaxProductsPerStoreto fix it. - Store identity follows the host that serves the catalog. Homepage redirects are often geo- or locale-dependent, so keying state on the redirect target would silently reset change detection whenever the proxy region moved. The domain you supply is used when it serves the catalog; the redirect target is still used to collapse
shop.comandwww.shop.cominto a single store. - A locked storefront reports
password_protected, notcatalog_restricted. Shopify password pages carry the same markers as any Shopify page, so this status takes precedence when checks 1 and 2 fail — it is the more useful of the two answers.
Behaviour under load and failure
- Politeness. Requests to a single store are serialised with
requestDelayMsbetween them; parallelism only happens across stores.Retry-Afteris honoured on HTTP 429. - Retries. Up to 3 attempts per request with 1 s / 4 s backoff. 4xx other than 429 is not retried; a definitive DNS failure is not retried either.
- Proxies. Apify Proxy datacenter by default. A store answering HTTP 403 is retried once through a residential proxy, then left alone.
- Failure isolation. An exception on one domain never ends the run; it lands in
RUN_SUMMARY.errorsand the other domains carry on. - Memory. Catalogs stream page by page rather than accumulating. Measured peak: 348 MB RSS while reading three 5,000-product stores concurrently (102,713 rows). 512 MB is a comfortable run size; the default 1-hour timeout suits lists of a few dozen stores.
- Logging. One summary line per domain — domain, status, products, variants, changes, duration. No raw JSON dumps.
Development
npm installnpm run buildnpm test # 78 unit and integration testsnpm run test:e2e # drives the built Actor across 5 runs against fake storefrontsnpm run test:all # bothnpm start # runs against local storage in ./storage
Neither suite touches the network. The unit tests spin up local fake storefronts covering every branch of the detection chain, compression handling, pagination and truncation. The end-to-end script runs the real entry point five times against a mutating fake catalog and asserts every change status, the store-status rows, RUN_SUMMARY, filters, idempotency and the empty-feed guard.
| Path | Contents |
|---|---|
src/normalize.ts | Input URL normalisation |
src/detect.ts | Seven-check detection chain and status resolution |
src/http.ts | Per-domain client: politeness, retries, proxy escalation, decompression |
src/catalog.ts | Streaming pagination |
src/delta.ts | Change classification and priority |
src/rows.ts | Flattening products into dataset rows |
src/state.ts | Key-Value Store and dataset state backends |
src/main.ts | Orchestration, run summary, charging |
Pay-per-event prices live in Apify Console under Publication → Monetization; .actor/pay_per_event.json is a reference copy, and the event names there must match the ones passed to Actor.charge().