Shopify Store Monitor — Price, Stock & Catalog Changes avatar

Shopify Store Monitor — Price, Stock & Catalog Changes

Pricing

from $1.00 / 1,000 variant rows

Go to Apify Store
Shopify Store Monitor — Price, Stock & Catalog Changes

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

John Dow

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

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

FieldTypeRequiredDefaultDescription
storeUrlsarray of stringyesStore 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.
modeenumnochanges_onlychanges_only, changes_and_new or full_catalog — see Output modes.
stateStoreNamestringnoshopify-monitor-stateNamed Key-Value Store holding one state record per domain between runs.
compareDatasetIdstringnonullBuild the baseline from a previous run's dataset instead of the state store.
maxProductsPerStoreintegerno5000Per-store safety cap.
includeUnavailablebooleannotrueWrite rows for out-of-stock variants.
productHandleFilterarray of stringno[]Only output these product handles.
vendorFilterarray of stringno[]Only output these vendors (case-insensitive).
minPrice / maxPriceintegernonullPrice range filter, in the store's own currency.
requestDelayMsintegerno400Pause between requests to the same store.
maxConcurrentDomainsintegerno3How many stores to process at once.
proxyConfigurationobjectnoApify ProxyStandard 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

ModeWhat lands in the dataset
changes_onlyPrice, sale and stock changes on variants you already saw, plus removals. New products and variants are not included.
changes_and_newThe above, plus new_product and new_variant rows.
full_catalogEvery 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.

FieldTypeDescription
domainstringNormalised host
record_typestringAlways variant for these rows
change_statusstringExactly one of the statuses below
product_idnumber | nullShopify product ID
product_handlestring | nullProduct slug
product_titlestring | nullProduct name
vendorstring | nullBrand
product_typestring | nullProduct type
tagsstringTags, comma-separated
variant_idnumber | nullShopify variant ID
variant_titlestring | nullVariant name
skustring | nullSKU
option1 / option2 / option3string | nullOption values (size, colour, …)
pricenumber | nullCurrent price
compare_at_pricenumber | nullStruck-through price ("0.00" is normalised to null)
currencystring | nullRead from /cart.js; null when unavailable
on_salebooleancompare_at_price > price
discount_pctnumber | nullDiscount off the compare-at price, one decimal
availablebooleanIn stock
previous_pricenumber | nullSet when the price changed
price_change_pctnumber | nullPrice change in percent, two decimals
previous_availableboolean | nullSet when availability changed
product_urlstringDirect link to the product
image_urlstring | nullFirst product image
published_atstring | nullISO timestamp
scraped_atstringISO timestamp of this run

Change statuses

StatusMeaning
baselineFirst run for this domain — nothing to compare against yet
new_productThe product was not in the previous state
new_variantThe product was, this variant was not
removedPresent in the previous state, absent from the current catalog
price_up / price_downThe price moved
sale_startedcompare_at_price went from empty or ≤ price to > price
sale_endedThe reverse
back_in_stock / sold_outAvailability flipped
unchangedNone of the above

When several things change at once, one status wins, in this order: new_productnew_variantremovedsold_outback_in_stockprice_downprice_upsale_startedsale_endedunchanged.

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

FieldDescription
domainNormalised host
record_typestore_status
store_statuscatalog_restricted, password_protected, not_shopify or unreachable
detection_methodWhich check identified the store, e.g. meta.json
messagePlain-English explanation and what to do about it
scraped_atISO 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:

#CheckSuccess signal
1GET /products.json?limit=250HTTP 200, JSON content type, body has a products array
2GET /collections/all/products.json?limit=250Same — some stores close the root path only
3GET /meta.jsonHTTP 200, JSON with id and name
4GET /cart.jsHTTP 200, JSON with token or currency
5Homepage response headersx-shopid, x-shopify-stage, x-sorting-hat-shopid or powered-by: Shopify
6Homepage cookies_shopify_y, _shopify_s or secure_customer_sig
7Homepage HTMLContains cdn.shopify.com, Shopify.shop or shopify-features

Which produces one of five honest outcomes:

StatusMeaningCharged?
okCheck 1 or 2 succeeded — the catalog is extractedyes
catalog_restrictedA Shopify store, but the public feed is offno
password_protectedThe storefront sits behind a Shopify password pageno
not_shopifyNone of the seven checks matchedno
unreachableDNS failure, timeout or connection error after retriesno

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

  1. Run the Actor once and check the results.
  2. Open the Actor → Schedules → create a schedule, e.g. 0 6 * * * for 06:00 daily.
  3. Keep stateStoreName the 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 stateStoreName values. Each name carries its own baseline, so each one pays for its own first run.
  • Prefer compareDatasetId for 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 in full_catalog mode, since a changes_only dataset 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:

EventPriceWhen it is charged
Actor Start$0.01Once per run, per GB of run memory (minimum one)
Store detected$0.005Once per domain confirmed to be a Shopify store with a readable catalog
Variant row$0.001Per 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 maxProductsPerStore to 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 fresh stateStoreName just buys another baseline at the same price.

Known limitations

  • No exact stock counts. /products.json exposes a boolean available flag 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, and currency comes 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 removed rows alongside new_product / new_variant rows. 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 in truncated_domains, and skips removed detection. If the previous run was truncated, variants with no prior state are also withheld instead of being reported as new_product, since a partial sample cannot tell "new" from "not sampled last time". The count is reported per domain in truncation_suppressed_rows; raise maxProductsPerStore to 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.com and www.shop.com into a single store.
  • A locked storefront reports password_protected, not catalog_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 requestDelayMs between them; parallelism only happens across stores. Retry-After is 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.errors and 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 install
npm run build
npm test # 78 unit and integration tests
npm run test:e2e # drives the built Actor across 5 runs against fake storefronts
npm run test:all # both
npm 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.

PathContents
src/normalize.tsInput URL normalisation
src/detect.tsSeven-check detection chain and status resolution
src/http.tsPer-domain client: politeness, retries, proxy escalation, decompression
src/catalog.tsStreaming pagination
src/delta.tsChange classification and priority
src/rows.tsFlattening products into dataset rows
src/state.tsKey-Value Store and dataset state backends
src/main.tsOrchestration, 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().