Shopify Price & Stock Monitor – Competitor Price Change Tracker
Pricing
from $1.25 / 1,000 product rows
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
Maintained by CommunityActor 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
| 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:
{"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-checkedper healthy store) and writes a SUMMARY. - Keep the actor's named key-value store
shopify-price-stock-monitor-statebetween 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, keystore-<sha1(host)>(thewww.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
lastSeenAttoo. A first run's baseline snapshot holds only the pushed rows, so rows cut bymaxItems/maxItemsPerStorestay out and the next run still reports them as new. A changed row cut off bymaxItems/maxItemsPerStoreis 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/abortingevents.
Limitations
- ~1,000 product feed cap: Shopify's public
/products.jsonis capped at 5 pages × 250; larger catalogues are reportedCAPPED(complete: false) and never produce delisted rows. - Delisting needs a complete fetch: a run whose store was capped or cut by
maxItemsis not authoritative, so it emits nodelistedrows. You need at least one prior fullcomplete: truerun before delistings can be reported. - First run = baseline: expect every row to come back
isNewonce per store (or once more if the store spelling produced a newstore-<sha1>key). On a capped first run (maxItems/maxItemsPerStore), the baseline stores only the pushed rows, so the remaining rows come backisNewon the next run — raise the cap (e.g.50000) to baseline the whole catalogue in one go. - Changed rows held back by
maxItemsare reported on the next run — because they are not written to state, the following run still sees them as changed and emits them. RaisingmaxItems(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.