Shopify Price & Stock Monitor – Competitor Price Change Tracker avatar

Shopify Price & Stock Monitor – Competitor Price Change Tracker

Pricing

from $1.25 / 1,000 product rows

Go to Apify Store
Shopify Price & Stock Monitor – Competitor Price Change Tracker

Shopify Price & Stock Monitor – Competitor Price Change Tracker

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.

Pricing

from $1.25 / 1,000 product rows

Rating

0.0

(0)

Developer

Thongpoo Supong

Thongpoo Supong

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

6 hours ago

Last modified

Categories

Share

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

FieldTypeDefaultNotes
storeDomainsarray[string]requiredstorefront hosts, e.g. ["brooklinen.com","allbirds.com"] (URLs also accepted, normalized to host)
onlyChangesbooleanfalseTurn 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.
priceChangedOnlybooleanfalsefurther restrict to rows where price or compareAtPrice changed (availability-only moves are skipped)
maxItemsPerStoreint10000hard cap per store (the feed itself caps at ~1,000 products)
maxItemsint50000hard cap total across all stores
includeFullFieldsbooleanfalseadd 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:

{
"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):

FieldTypeExample
storestringbrooklinen.com
productIdnumber6578170331226
handlestringsuper-plush-hand-towels-last-call
titlestringSuper-Plush Turkish Cotton Hand Towels Set of 2 - Last Call
urlstringhttps://brooklinen.com/products/super-plush-hand-towels-last-call
vendor, productTypestringBrooklinen / Towels
variantIdnumber39421111271322
variantTitlestringOcean / 2-Pack (option summary)
skustring|nullBKN-TW-OCN
optionsobject{"Color":"Ocean","Size":"2-Pack"}
pricenumber24.9 (parsed from string)
compareAtPricenumber|null39.0 (null = not on sale)
currencystring|nullUSD (from meta.json; null if meta unreachable)
availablebooleantrue
inventoryQuantity / gramsint|null12 / 340 (may be absent on some themes)
isNewbooleantrue on the first run (baseline) or when a variant first appears
priceChanged / compareAtChanged / availabilityChangedbooleanflag what moved vs the previous run
changeTypestringprice | compareAt | availability | new | delisted | unchanged (first matching priority)
statusstringactive (present) — delisted rows carry the prior snapshot's title/url
scrapedAtISO stringwhen 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)

StatusMeaning
OKfull catalogue fetched, last page short
CAPPEDfeed stopped on a full page (storefront caps at ~1,000 products) — documented limitation, not an error
EMPTY_CATALOGUEproducts.json returned {"products":[]}
HEADLESS_STOREFRONTproducts.json 404 on host, www. and apex (headless/disabled storefront)
PASSWORD_PROTECTEDproducts.json redirected to /password
NOT_SHOPIFYproducts.json answered without a products payload
BLOCKED403 / DataDome / Vercel security checkpoint — recorded and stopped, never bypassed
RATE_LIMITEDHTTP 429 after retries (Retry-After honoured, capped 60 s)
ERRORnetwork error or non-transient failure after retries
SKIPPEDthe 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.