Shopify Catalog & Price Monitor - Snapshots, Diffs, Alerts
Under maintenancePricing
from $0.00005 / actor start
Shopify Catalog & Price Monitor - Snapshots, Diffs, Alerts
Under maintenanceExport any public Shopify storefront's full catalog (products, variants, prices, stock) and get typed change alerts — price drops, new products, stock-outs — on every scheduled run.
Pricing
from $0.00005 / actor start
Rating
0.0
(0)
Developer
Eonix Pvt Ltd
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
4 days ago
Last modified
Categories
Share
Shopify Catalog & Price Monitor — Snapshots, Diffs, Alerts
Export any public Shopify storefront's entire catalog — every product, every variant, every price and stock flag — and get typed change alerts the moment something moves: price drops, price increases, stock-outs, restocks, new products, removed products.
Point it at a store once and you get a clean dataset. Put it on a schedule and you get a monitoring loop: each run compares the live catalog against the previous snapshot and emits only what actually changed, to your dataset, to Slack, and to your webhook.
No browser, no login, no scraping tricks — it reads the storefront's own public /products.json endpoint, the same JSON a shopper's browser can request.
Who it's for
- E-commerce & pricing teams watching competitors' prices and assortment.
- Brands and agencies verifying their own catalog and MAP compliance across storefronts.
- Deal, resale and drop communities that need to know the second something restocks.
- Analysts building assortment, discount-depth and stock-availability datasets over time.
Three concrete uses
- Competitive price tracking. Add five competitor storefronts, schedule the actor hourly, set
watchFilters.minPctChangeto5, and pipe alerts into a#pricingSlack channel. You get a message only when a competitor moves a price by 5% or more — with the old price, the new price, the percentage, and a direct product link. - Restock sniping for high-demand drops. Watch one store, filter to
titleIncludes: ["Jordan"], and run every 10 minutes withexportCatalog: false. Each run costs a couple of cents and posts aback_in_stockalert the moment a variant flips available. - Assortment and discount research. Run the full export weekly across a dozen storefronts and load the dataset into a warehouse. Every row carries
price,compareAtPrice,tags,vendorandproductType, so discount depth, category mix and catalog churn all fall out of simple SQL.
How it works
storeUrls ─▶ robots.txt check ─▶ GET /products.json?limit=250&page=1..n│▼normalise to one row per variant(prices in cents)│┌───────────────────────────┼──────────────────────────┐▼ ▼ ▼dataset: kind="variant" named KV store: diff vs previous(full catalog export) snapshot per hostname snapshot│▼dataset: kind="change" +CHANGES.json artifact +Slack / webhook summary
The first run for a store is a baseline — it writes the snapshot and emits zero change events. Every run after that produces the diff. Keep storeName stable across runs; it is the memory that makes diffing possible.
Quick start
On Apify Console
- Paste one or more storefront URLs into Shopify store URLs.
- Run it once to build the baseline.
- Add a Schedule (hourly or daily) and paste a Slack incoming webhook into Slack incoming webhook URL.
Locally
$npm install && npm run build && apify run
The repository ships a working storage/key_value_stores/default/INPUT.json, so apify run works out of the box against two real storefronts.
Input
| Field | Type | Default | Description |
|---|---|---|---|
storeUrls | array of strings | — required | Storefront URLs. Any URL on the store works; it is normalised to the https origin (https://www.allbirds.com/collections/mens → https://www.allbirds.com). Duplicated hostnames are de-duplicated. Max 100. |
exportCatalog | boolean | true | Push every variant to the dataset. Set to false for alert-only runs: snapshots and change events still happen, but no catalog rows are stored — far cheaper on a tight schedule. |
storeName | string | "shopify-monitor-snapshots" | Named key-value store holding one snapshot record per hostname. Keep it stable between runs — this is what the diff compares against. Use a different name to run separate, independent monitoring streams. |
watchFilters | object | {} | Narrows change events only, never the catalog export. See below. |
slackWebhookUrl | string (secret) | — | Slack incoming webhook. Receives a grouped summary per store. |
alertWebhookUrl | string (secret) | — | Any HTTPS endpoint. Receives the same summary as JSON. |
proxyConfiguration | object | { "useApifyProxy": true } | Apify Proxy settings. Datacenter proxies are enough for /products.json; switch to residential if a storefront blocks datacenter ranges. |
watchFilters
{"titleIncludes": ["hoodie", "jordan"],"vendors": ["Nike"],"tags": ["sale"],"minPctChange": 5}
| Key | Matching | Notes |
|---|---|---|
titleIncludes | case-insensitive substring, any match | Matched against the event title (Product — Variant). |
vendors | case-insensitive exact match, any match | "Nike" matches Nike, not Nike Inc. |
tags | case-insensitive exact match, any match | Matches if the product carries any listed tag. |
minPctChange | number ≥ 0, default 0 | 0 reports every price move. Above 0, keeps only moves whose absolute percentage change is ≥ the threshold. Never filters stock or product events. |
Different keys combine with AND; entries within one key combine with OR. An unrecognised key fails the run immediately rather than being silently ignored — a typo'd filter that quietly does nothing is worse than a clear error.
Output
Three record kinds land in the default dataset, each tagged with kind.
1. kind: "variant" — the catalog export
One row per variant. Real output from a local run against deathwishcoffee.com:
{"kind": "variant","store": "https://www.deathwishcoffee.com","productId": 7940945025,"handle": "pumpkin-chai","title": "Pumpkin Chai Coffee","vendor": "Death Wish Coffee Company","productType": "Coffee","tags": ["Bagged", "LTO Coffee", "National Coffee Day", "Society of Strong Coffee"],"variantId": 41181425598519,"variantTitle": "1 bag","sku": "LGPMPK09","price": 13.99,"compareAtPrice": null,"available": true,"url": "https://www.deathwishcoffee.com/products/pumpkin-chai?variant=41181425598519","imageSrc": "https://cdn.shopify.com/s/files/1/0271/7209/files/Pumpkin_Chai_9oz_Ground_Angle_Cathedral_DTC_A2_EA.jpg?v=1786632919","collectedAt": "2026-08-15T18:46:36.668Z"}
2. kind: "change" — the change events
type | Emitted when | Extra fields |
|---|---|---|
price_drop | a variant's price fell | oldPrice, newPrice, oldPriceCents, newPriceCents, pctChange |
price_increase | a variant's price rose | same as above |
back_in_stock | a variant flipped to available | — |
out_of_stock | a variant flipped to sold out | — |
new_product | a product id appeared that the previous snapshot did not have | — |
removed_product | a product id from the previous snapshot is gone | — |
Price and stock events are variant-level and carry variantId; product events are product-level. Every event carries productId, handle, title, url, vendor and tags.
Real output from a local run (all six types, one of each):
[{"type": "price_drop","productId": 179754517,"handle": "gift-card","title": "Death Wish Coffee Digital Gift Card — $25","url": "https://www.deathwishcoffee.com/products/gift-card?variant=411724869","vendor": "Death Wish Coffee Company","tags": ["Gifting", "Merch", "Website Exclusive Sale"],"variantId": 411724869,"oldPrice": 31.25,"newPrice": 25,"oldPriceCents": 3125,"newPriceCents": 2500,"pctChange": -20,"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"},{"type": "price_increase","productId": 272081685,"handle": "valhalla-java-odin-force-blend","title": "Valhalla Java Odinforce Blend — Ground / 1 bag","url": "https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625","vendor": "Death Wish Coffee Company","tags": ["Bagged","National Coffee Day","Subscription","Valhalla Java","Valhalla Java Odinforce Blend"],"variantId": 638652625,"oldPrice": 12.79,"newPrice": 15.99,"oldPriceCents": 1279,"newPriceCents": 1599,"pctChange": 25.02,"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"},{"type": "back_in_stock","productId": 171104582,"handle": "valhalla-java-single-serve-pods","title": "Valhalla Java Single-Serve Pods — 10 count","url": "https://www.deathwishcoffee.com/products/valhalla-java-single-serve-pods?variant=392715452","vendor": "Death Wish Coffee Company","tags": ["Intro Offer","National Coffee Day","Single Serve","Subscription","Valhalla Java","Valhalla Java Odinforce Blend"],"variantId": 392715452,"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"},{"type": "out_of_stock","productId": 7502387511351,"handle": "espresso-roast-cold-brew-coffee-48oz","title": "Espresso Roast, Cold Brew Coffee, Unsweetened","url": "https://www.deathwishcoffee.com/products/espresso-roast-cold-brew-coffee-48oz?variant=42379014438967","vendor": "Death Wish Coffee","tags": ["Cold Brew", "Espresso Roast", "Multiserve"],"variantId": 42379014438967,"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"},{"type": "new_product","productId": 4346529579063,"handle": "death-wish-instant-coffee-1","title": "Dark Roast Instant Coffee","url": "https://www.deathwishcoffee.com/products/death-wish-instant-coffee-1","vendor": "Death Wish Coffee Company","tags": ["Dark", "Dark Roast", "Gifting", "Instant", "Intro Offer", "Subscription"],"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"},{"type": "removed_product","productId": 999000111,"handle": "discontinued-holiday-blend","title": "Discontinued Holiday Blend","url": "https://www.deathwishcoffee.com/products/discontinued-holiday-blend","vendor": "Death Wish Coffee Company","tags": ["Bagged", "Seasonal"],"kind": "change","store": "https://www.deathwishcoffee.com","detectedAt": "2026-08-15T18:46:36.777Z","previousFetchedAt": "2026-08-15T18:46:31.673Z"}]
How this example was produced: the catalog values above are live data from a real run against
deathwishcoffee.com. To show all six event types in one place without waiting for the storefront to change overnight, the stored baseline from the previous run was edited to represent yesterday's state, and the actor was then run normally against the live catalog. The events are the real diff engine's output; only the "yesterday" side was staged.
3. kind: "summary" — one per run
The last record of every run. Real output:
{"kind": "summary","runId": "local-1786819597943","startedAt": "2026-08-15T18:46:35.623Z","finishedAt": "2026-08-15T18:46:37.944Z","storesRequested": 2,"storesSnapshotted": 2,"storesFailed": 0,"totalVariants": 3242,"totalEvents": 6,"eventCounts": {"price_drop": 1,"price_increase": 1,"back_in_stock": 1,"out_of_stock": 1,"new_product": 1,"removed_product": 1},"changesArtifactKey": "CHANGES-local-1786819597943.json","changesArtifactUrl": null,"stores": [{"store": "https://www.deathwishcoffee.com","hostname": "www.deathwishcoffee.com","status": "ok","statusReason": null,"productCount": 146,"variantCount": 413,"pagesFetched": 2,"isBaseline": false,"eventsEmitted": 6,"eventCounts": {"price_drop": 1,"price_increase": 1,"back_in_stock": 1,"out_of_stock": 1,"new_product": 1,"removed_product": 1},"exportedVariants": 413,"fetchedAt": "2026-08-15T18:46:36.668Z","previousFetchedAt": "2026-08-15T18:46:31.673Z","durationMs": 1092},{"store": "https://www.allbirds.com","hostname": "www.allbirds.com","status": "ok","statusReason": null,"productCount": 291,"variantCount": 2829,"pagesFetched": 3,"isBaseline": false,"eventsEmitted": 0,"eventCounts": {"price_drop": 0,"price_increase": 0,"back_in_stock": 0,"out_of_stock": 0,"new_product": 0,"removed_product": 0},"exportedVariants": 2829,"fetchedAt": "2026-08-15T18:46:37.390Z","previousFetchedAt": "2026-08-15T18:46:32.347Z","durationMs": 2254}]}
changesArtifactUrl is null on local runs and a public API URL on the platform.
Key-value store artifacts
| Store | Key | Contents |
|---|---|---|
| default (run-scoped) | CHANGES-{runId}.json | Every change event of the run plus the per-store results and the filters that were applied. |
| default (run-scoped) | CHANGES.json | The same report under a fixed key, which is what the Output tab links — a template cannot interpolate the run id. |
named (storeName) | {hostname} | The latest snapshot for that storefront: { version, store, fetchedAt, products, variants }, with all money as integer cents. |
Output tab
.actor/output_schema.json declares what a finished run produced, so the Console shows it directly instead of making buyers hunt through storages:
| Output | Points at |
|---|---|
| Catalog and change events | The full dataset — variants, changes and the run summary |
| Change report (JSON) | The CHANGES.json record |
The dataset's Overview view is shaped for the catalog. Change and summary rows share the dataset (Apify dataset views select columns but cannot filter rows), so they appear there with the catalog columns blank — read them from kind, or take the change report instead.
Alerts
Alerts fire only when a run produced at least one change event — a monitor that posts "nothing happened" every fifteen minutes gets muted, and a muted monitor is useless.
Slack (slackWebhookUrl) receives a header, one section per store with the counts and the ten most significant price moves, and a context line linking the full report. Real payload:
{"text": "Shopify monitor — 6 catalog changes across 1 store","blocks": [{"type": "header","text": {"type": "plain_text","text": "Shopify monitor — 6 catalog changes across 1 store","emoji": false}},{"type": "section","text": {"type": "mrkdwn","text": "*https://www.deathwishcoffee.com* — 1 price drop, 1 price increase, 1 back in stock, 1 out of stock, 1 new product, 1 removed product\n• ↑ <https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625|Valhalla Java Odinforce Blend — Ground / 1 bag> — 12.79 → 15.99 (+25.02%)\n• ↓ <https://www.deathwishcoffee.com/products/gift-card?variant=411724869|Death Wish Coffee Digital Gift Card — $25> — 31.25 → 25 (-20%)"}},{"type": "context","elements": [{"type": "mrkdwn","text": "<https://api.apify.com/v2/key-value-stores/EXAMPLE/records/CHANGES-abc123.json|Full change report> · Run `local-1786819597943`"}]}]}
Generic webhook (alertWebhookUrl) receives the machine-readable version of the same summary:
{"actor": "shopify-monitor","runId": "local-1786819597943","generatedAt": "2026-08-15T18:46:37.944Z","totalEvents": 6,"eventCounts": {"price_drop": 1,"price_increase": 1,"back_in_stock": 1,"out_of_stock": 1,"new_product": 1,"removed_product": 1},"storesSnapshotted": 2,"storesFailed": 0,"changesArtifactUrl": "https://api.apify.com/v2/key-value-stores/EXAMPLE/records/CHANGES-abc123.json","stores": [{"store": "https://www.deathwishcoffee.com","status": "ok","isBaseline": false,"variantCount": 413,"totalEvents": 6,"eventCounts": {"price_drop": 1,"price_increase": 1,"back_in_stock": 1,"out_of_stock": 1,"new_product": 1,"removed_product": 1},"topMoves": [{"type": "price_increase","title": "Valhalla Java Odinforce Blend — Ground / 1 bag","url": "https://www.deathwishcoffee.com/products/valhalla-java-odin-force-blend?variant=638652625","oldPrice": 12.79,"newPrice": 15.99,"pctChange": 25.02},{"type": "price_drop","title": "Death Wish Coffee Digital Gift Card — $25","url": "https://www.deathwishcoffee.com/products/gift-card?variant=411724869","oldPrice": 31.25,"newPrice": 25,"pctChange": -20}]}]}
A failed delivery is logged as an error and counted in the run's status message; it never fails the run, because the dataset and the artifact already hold every event.
Pricing — pay per event
Every event is charged singly — one snapshot, one variant, one change. Nothing is bundled, so you pay exactly for what a run delivered.
| Event name | Charged once per | Price |
|---|---|---|
catalog-snapshot | Store, after its snapshot is written | $0.05 |
variant-exported | Variant, after the row is pushed (only when exportCatalog is true) | $0.0005 |
change-alert | Change event, after the events are pushed to the dataset | $0.002 |
Create these three events verbatim in Console ▸ Monetization ▸ Pay per event — the exact titles and descriptions to paste in are in .actor/MONETIZATION.md.
Nothing is charged for work that failed. A store that is blocked, has the endpoint disabled, is disallowed by robots.txt, or errors out costs nothing at all — every charge happens strictly after the corresponding records are persisted. A storefront whose catalog is empty is snapshotted but not charged, because it delivered nothing.
Worked example, from the real run above — 2 stores, 3,242 variants, 6 change events:
| Line | Quantity | Unit price | Cost |
|---|---|---|---|
catalog-snapshot | 2 | $0.05 | $0.10 |
variant-exported | 3,242 | $0.0005 | $1.62 |
change-alert | 6 | $0.002 | $0.012 |
| Total | $1.73 |
The same run with exportCatalog: false costs $0.11 — which is the point: build the catalog once, then run the cheap alert loop on a schedule.
Because variants are priced individually, a 40-variant storefront costs $0.02 to export rather than being rounded up to a bundle it did not fill.
Scheduling
| Goal | Schedule | Settings |
|---|---|---|
| Competitor price watch | hourly | exportCatalog: false, minPctChange: 3 |
| Restock alerts | every 10–15 min | exportCatalog: false, titleIncludes narrowed |
| Assortment dataset | daily or weekly | exportCatalog: true |
All schedules must share the same storeName as the run that created the baseline, or every run will look like a first run.
Per-store statuses
A run does not fail because one storefront misbehaved. Each store reports its own status in the summary record, and the run's status message names the failures.
status | Meaning | Charged? |
|---|---|---|
ok | Catalog fetched, snapshot written, diff produced. | Yes |
endpoint_unavailable | The store disabled /products.json, or answered with HTML, 403 or 404. Common — plenty of stores turn it off. | No |
robots_disallowed | robots.txt forbids the endpoint, or could not be read because the server returned 5xx. | No |
catalog_too_large | More than 25,000 products. See the FAQ. | No |
empty_catalog_guard | The catalog came back empty while a non-empty snapshot exists. The old snapshot is kept and no events are emitted. | No |
failed | Network failure after retries, a storefront that ignores the page parameter, or an unexpected error. | No |
FAQ
Is this legal / allowed?
It reads a storefront's public /products.json, the endpoint Shopify serves to anyone — the same catalog data the store publishes to shoppers and to search engines. Public catalog data for competitive price research is a standard, legitimate use. The actor respects robots.txt (including Crawl-delay), waits 500 ms between pages, holds at most three connections per hostname, and reads no personal data of any kind. It does not log in, does not touch checkout, and does not place orders.
Why did my first run produce no change events? That is by design. The first run for each storefront writes the baseline snapshot. From the second run on, every run produces the diff.
Why did all my stores suddenly look like first runs?
storeName changed, or a different named store was used. That value is the memory between runs — keep it identical across your schedule.
What is the 25,000 product limit?
Shopify refuses /products.json?page=101 with HTTP 400, so the endpoint can hand out at most 100 pages × 250 products. A storefront bigger than that cannot be captured completely. Rather than snapshot a truncated catalog — which would fire a removed_product event for thousands of products on the next run — the store is reported as catalog_too_large and nothing is exported, snapshotted or charged. If you need a store that large, split it by collection (/collections/{handle}/products.json paginates the same way) or monitor a subset of the catalog.
Where is the currency?
/products.json does not include one. Prices are in the storefront's own default currency. If you monitor stores in different currencies, record that mapping on your side.
Why is compareAtPrice sometimes null when the store shows a "compare at" value?
Shopify serialises "no compare-at price" as either null or "0.00"; both are normalised to null, matching how the storefront itself treats them.
Do prices drift over time?
No. Money is parsed straight from the decimal string into integer cents and compared as integers, so 19.99 is always 1999 and never 1998.9999999999998. A price that did not move can never produce an event.
Can a variant emit two events at once?
Yes — a variant that both changed price and sold out emits a price event and an out_of_stock event. That is intentional; both facts matter.
What about a variant added to an existing product?
It emits nothing: there is no previous price to compare against and it is not a new product. Only genuinely new products raise new_product.
A store returned 403. What now?
Enable Apify Proxy (on by default) and, if it persists, switch proxyConfiguration to residential. If the endpoint is genuinely disabled, no proxy will help — that is what endpoint_unavailable means.
Does the run fail if a store fails?
No. The run completes and reports per-store statuses; alert on the summary record's storesFailed rather than on the run status. A run only fails on unusable input or an unexpected internal error.
Local development
npm installnpm run build # tsc → dist/npm test # compiles tests, runs node --test (124 tests)npm run lint # eslint, type-awarenpm run format:check # prettierapify run # full local run against storage/key_value_stores/default/INPUT.json
Requires Node.js 22+ and the Apify CLI (npm i -g apify-cli).
| Path | Purpose |
|---|---|
src/main.ts | Orchestration: input → per-store pipeline → artifact → alerts → charging |
src/catalog.ts | /products.json pagination and variant normalisation |
src/snapshot.ts | Snapshot build / read / write in the named key-value store |
src/diff.ts | Pure diff engine: two snapshots in, typed events out |
src/filters.ts | watchFilters applied to events |
src/alerts.ts | Grouped summary, Slack blocks, delivery |
src/money.ts | Decimal-safe string → integer cents |
src/http.ts | Backoff, Retry-After, per-host concurrency |
src/robots.ts | RFC 9309 robots.txt parsing and enforcement |
src/net-guard.ts | SSRF guard on every user-supplied URL |
src/charging.ts | chargeSafely — charging never breaks a run |
Store-listing assets live alongside the actor config:
| Asset | Purpose |
|---|---|
.actor/logo.png | 1024×1024 Actor picture, full bleed — upload in Console ▸ Settings |
.actor/logo.svg | Vector source for the logo |
.actor/MONETIZATION.md | Pay-per-event names, titles, descriptions and prices for the Console |
See SECURITY.md for the security model and RUNBOOK.md for operations.
Changelog
0.1.0 — 2026-08-15
- Initial release: full catalog export, snapshot + diff engine, six change event types,
watchFilters, Slack and webhook alerts,CHANGES.jsonchange report, an Output schema for the Console, and three pay-per-event charges billed one-for-one.