Shop Catalog & Price Tracker
Pricing
Pay per event
Shop Catalog & Price Tracker
Shopify, WooCommerce and other shops: track prices and stock on a schedule in one schema (brand, GTIN/MPN/SKU, price, currency, stock, variants). Only changed products, price history kept. Public data only, robots.txt respected.
Pricing
Pay per event
Rating
0.0
(0)
Developer
Benjamin Cojocaru
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
For Shopify, WooCommerce and other shops that publish schema.org product data.
Track the public catalog of online shops on a schedule and get a row only for products that changed: new and removed products, per-variant price drops and increases (with %), sale starts and ends (compare-at price), back-in-stock and out-of-stock, new and removed variants. Each row carries the product's current variants and their recent price/availability history. The catalog and the history are kept between runs in a named key-value store.
Price: product-change-returned $0.002 per product row (and per GTIN match row). store-checked-unchanged $0.003 per started 1,000 products when a Shopify, WooCommerce or feed store is checked and nothing is reported (and for the first, baseline check). Shops read page by page cost one request per product, so they pay product-pages-read instead: $0.01 per started 100 page requests on every check, changed or not, with change rows on top. Failures are free. Ten stores of about 800 products checked daily, with 20 changed products a day, cost about $0.90 + $1.20 = $2.10/month.
It reads only what each shop publishes for machines, and finds the source by itself on the first check:
Source (platform) | What is read | Requests |
|---|---|---|
shopify | /products.json (or /collections/<handle>/products.json) | 1 per 250 products |
woocommerce | the public WooCommerce Store API /wp-json/wc/store/v1/products | 1 per 100 products |
feed | a Google Merchant RSS/Atom feed the shop publishes (you give feedUrl) | 1 |
structured-data | sitemaps, then each product page's schema.org Product data (JSON-LD or microdata) | 1 per product page, plus up to 12 sitemap requests |
auto (default) tries feed (only if you gave a feedUrl), then Shopify, then WooCommerce, then product pages. A shop keeps the source found on its first check. If you force a different platform later, the next check becomes a free new baseline.
Every product comes out in one schema: title, brand, GTIN, MPN, SKU, price, compare-at price, currency, availability, variants, URL and image. Optional, off by default: matchByGtin groups products that share a GTIN (EAN/UPC) across your shops into one row. This is a niche option. In our measurement it works for only about 25 % of shops, because only those publish valid GTINs, and only in product-page data.
robots.txt is respected and requests to one shop are 2 s apart. No browser, no proxy, no login, no cart or checkout, no customer data, no reviews. Marketplaces (Amazon, eBay, Walmart, AliExpress, Etsy, ...) are out of scope.
Coverage, measured, not promised. On 200 shop-like domains sampled from the Tranco top-1M list, see COVERAGE.md: at least one source worked on 81 of the 152 sites we were allowed to read (53 %): 51 via Shopify, 10 via WooCommerce, 20 via product-page data. A valid GTIN was published by 25 % of those shops, only in product-page JSON-LD. Neither the Shopify feed nor the WooCommerce API had one in this sample, so matching by GTIN needs platform: "structured-data".
Input
{"stores": ["www.allbirds.com",{"domain": "shop.example.com", "collections": ["sale", "new-arrivals"], "label": "rival A"},{"domain": "woo-shop.example", "label": "rival B"},{"domain": "brand.example", "feedUrl": "https://brand.example/feeds/google.xml"}],"matchByGtin": true,"reportChangeTypes": ["price_drop", "sale_started", "back_in_stock", "new_product"],"confirmRemovals": true}
| Field | Default | Meaning |
|---|---|---|
stores | - | Up to 200 domains (a URL is accepted; only the host is used). An object can set platform, feedUrl (https, on the shop's own domain) and, for Shopify, collections: collection handles (the part after /collections/ in a URL). They are read from /collections/<handle>/products.json instead of the whole catalog. Each store and collection set has its own history. |
platform | auto | auto, shopify, woocommerce, structured-data, feed. Applies to every shop without its own platform. |
matchByGtin | false | Also output one gtin_match row per GTIN found in two or more shops of this run (charged like a product row). |
maxPagesPerStore | 200 | structured-data only: product pages read per shop (one request each). Past the limit nothing is reported as removed. |
reportChangeTypes | all | new_product, removed_product, price_drop, price_increase, sale_started, sale_ended, back_in_stock, out_of_stock, new_variant, removed_variant. Other types are still written to the history, just not output. |
confirmRemovals | true | A product counts as removed only when it is missing on two checks in a row. Paging through a catalog that changes during the check can skip a product once. |
emitBaselineProducts | false | On the first run, output every product once (status baseline, charged like a change row). Off: the first run returns one free baseline_summary row instead (see "First run"). |
historyPerVariant | 30 | History points kept per variant (a point is added only when price, compare-at price or availability changed). |
maxProductsPerStore | 10,000 | Products read per store (250 per request). Past the limit nothing is compared and nothing is reported as removed. |
monitorStoreName | shopify-catalog-tracker | The named key-value store holding the catalogs. Use one schedule per store name. A LOCK record (3 h expiry) refuses a second run on the same store name, and it is re-read after writing so two runs that start together do not both proceed. This is best-effort, not a guarantee: the key-value store has no atomic lock, so do not rely on it to run overlapping schedules. |
perHostDelayMs | 2000 | Pause between two requests to one store (min 1 s). A longer robots.txt Crawl-delay wins (above 10 s only one page is read). |
Schedule it in Apify Console (Schedules, e.g. daily) and add an integration (email, Slack, webhook) on "Run succeeded" that sends you the dataset: rows exist only when something changed.
Output (one row per changed product)
{"store": "shop.example.com","label": "rival A","status": "changed","productId": "7340901859408","title": "Women's Flip Flop - Dusty Pink","url": "https://shop.example.com/products/womens-flip-flop-dusty-pink","changeTypes": ["price_drop", "sale_started"],"changes": [{"variantId": "42146889039952", "variantTitle": "5", "sku": "A12513W050", "type": "price_drop", "before": "50.00", "after": "25.00", "changePct": -50.0},{"variantId": "42146889039952", "variantTitle": "5", "sku": "A12513W050", "type": "sale_started", "before": null, "after": {"price": "25.00", "compareAtPrice": "50.00"}}],"variants": [{"variantId": "42146889039952", "title": "5", "sku": "A12513W050", "price": "25.00", "compareAtPrice": "50.00", "available": false, "onSale": true}],"history": {"42146889039952": [{"at": "2026-10-13T08:00:00+00:00", "price": "50.00", "compareAtPrice": null, "available": false}, {"at": "2026-10-14T08:00:00+00:00", "price": "25.00", "compareAtPrice": "50.00", "available": false}]},"checkedAt": "2026-10-14T08:00:00+00:00","previousCheckAt": "2026-10-13T08:00:00+00:00","error": null}
Rows also carry platform, brand, currency, image and gtins. Each variant has gtin and mpn, which are null when the shop does not publish them. For shops read from the WooCommerce API, product pages or a feed, productId is the API id, the product URL or the feed id, and url is the shop's own product URL.
- Prices are decimal strings in the shop's own currency.
currencycomes from the WooCommerce API, the page'spriceCurrencyor the feed. Shopify's/products.jsondoes not say which currency, so it isnullthere (usually the store's base currency). - A GTIN is output only when its check digit is valid.
- Availability that a page does not state is
null. Going from unknown to known is not reported as a stock change.
First run: the baseline_summary row
The first check of a store only stores its catalog, so it returns no product rows. So that the run does not look broken, it returns one free row per store with status: "baseline_summary": store, platform, storedProducts, storedVariants, sample (the first 5 products: title, url, price, currency), message, nextStep and charged: false. It has no productId, is never a charged event, and is not the baseline status (that is the charged per-product row of emitBaselineProducts, which replaces the summary row when on). The only charge is the usual store-checked-unchanged (or product-pages-read for page-by-page shops). The run's status message reads "Baseline stored for N store(s)", and SUMMARY.baselineSummaryRows counts the rows. The second run with no change returns nothing; changed products come back as changed rows. A free rebaseline after a currency or domain switch returns no summary row, only a warning.
GTIN match rows (matchByGtin)
{"status": "gtin_match", "gtin": "4006381333931", "shops": 2, "title": "Merino Beanie", "brand": "Nordwolle","currency": "EUR", "cheapestStore": "feed.example","offers": [{"store": "feed.example", "platform": "feed", "price": "19.00", "compareAtPrice": "25.00", "currency": "EUR", "available": true, "url": "https://feed.example/merino-beanie", "productId": "MB", "variantId": "MB-1", "gtin": "4006381333931"},{"store": "woo.example", "platform": "woocommerce", "price": "19.90", "compareAtPrice": "24.90", "currency": "EUR", "available": true, "url": "https://woo.example/product/merino-beanie/", "productId": "101", "variantId": "101", "gtin": "4006381333931"}],"checkedAt": "2026-10-14T08:00:00+00:00", "error": null}
Match rows cover every shop checked in the run, not only shops that changed. Each shop contributes one offer per GTIN. UPC-12 and its EAN-13 form (with a leading 0) count as the same GTIN. cheapestStore is set only when every offer states the same currency. Prices are never converted, and no VAT is guessed. Match rows are output only when the run did not stop early.
- "On sale" means
compare_at_priceis set and higher thanprice. - Only buyer-visible fields are compared: title, handle, variant title, SKU, price, compare-at price and availability.
updated_at, descriptions, images and tags change all the time and are ignored. - Failed stores give a free row with
status: "error"anderror.code:robots_disallowed,no_product_source(auto-detection found no source; the message lists what each source answered),not_shopify_feed/not_woocommerce/no_sitemap/no_product_pages/no_structured_data/not_product_feed(a forced source is not there),page_errors(more than 20 % of the product pages failed; nothing compared),store_password_protected,unexpected_redirect(the feed redirected to a bot check, login or other page),suspected_partial_catalog(less than half of last check's products: nothing compared; accepted if the next check shows the same count),http_error,timeout,catalog_too_large,store_error, ... - The SUMMARY record (key-value store of the run) lists counts, charged events, stores with changes, stores whose charging is paused, truncated stores and warnings.
Run-summary row (free). When a run produces no other row (for example a monitor run where nothing changed, or a run stopped at once by your maximum charge or the run timeout), the Actor adds one row {"type": "run_summary", "status": "run_summary", "message": "...", "stopReason": ..., "warnings": [...], "summary": {...}} (summary is the same as the SUMMARY record), so the dataset is never empty. It is never charged. Skip it with type != "run_summary".
What is stored, and how billing stays fair
- Per store: a small
state-<id>record (last check, counts, a 200-entry run timeline), pluscatalog-<id>-a|bandhistory-<id>-a|bgzip records. A new catalog is written to the inactive slot, and only saving the state record afterwards switches to it. - Rows are delivered (and charged with their row) before the check is committed. If the commit then fails, the check is not committed and the same rows are delivered again next run: a change can appear twice, but it is not dropped. The budget is checked before any row is pushed. If the budget still runs out part-way through a store's rows, the check is not committed and all of its rows come again next run, so the ones already delivered appear twice.
- An unchanged check, and
product-pages-read, are charged only after the state record is saved. The budget for change rows plus page reads is checked before anything is pushed. A failed save there gives a freestore_errorrow. - Record sizes are checked before charging. History is trimmed (oldest points first) to fit 4 MB. A catalog that still does not fit gives a free
catalog_too_largerow. - If a store's state cannot be saved twice in a row, its checks are free (and listed in
SUMMARY.chargingPausedStores) until a save works again. - Unchanged checks write only the small state record.
Other Apify Store Actors (prices as listed on 2026-10-14)
Prices are the FREE-plan prices from the public Store API on 2026-10-14 (source: store-evidence/shopify-and-app-reviews-2026-10-14.json); higher plans are cheaper and prices may have changed since.
| Actor | Users (all / 30 d) | What it returns | Price listed on 2026-10-14 |
|---|---|---|---|
| autofacts/shopify | 2,347 / 84 | full catalog on every run | $0.0012 per product (down to $0.0008 on higher plans), $0.0008 per collection |
| trovevault/shopify-products-scraper | 719 / 96 | full catalog on every run | $0.001 per dataset item, plus $0.001 per start |
| webdatalabs/shopify-product-scraper | 405 / 17 | full catalog on every run | $0.01 per product (down to $0.007) |
| phantom_coder/shopify-competitor-monitor | 4 / 0 | changes only | $0.012 per change, $0.008 per store, $0.004 per 1,000 products fetched (FREE plan) |
Price comparison. This Actor's product-change-returned is $0.002 per row, which is higher than the roughly $0.001-0.0012 per product that autofacts and trovevault listed, and lower than phantom_coder's $0.012 per change. The two work differently: a catalog Actor returns, and charges for, every product on every run, and you compare the catalogs yourself. This Actor stores the catalog between runs and returns (and charges for) only the products that changed, plus $0.003 per started 1,000 products for each check. A daily check of a 1,000-product store with nothing changed costs $0.003 here; a daily full catalog from autofacts at $0.0012 per product is $1.20 for that run. On a day when most of the store changes, a full dump can cost less than the change rows: 800 changed products are $1.60 here, and 800 products at $0.0012 are $0.96.
Limits
-
Currency, market and domain switches. The feed has no currency field. A store can start answering in another currency or market (for example a redirect to
/en-gb/products.json), or move to a new domain. Then the Actor treats that check as a free new baseline (statusrebaseline, no rows, a warning in SUMMARY) instead of reporting every price as changed. This happens when the final host or path prefix of the feed changes, or when more than 80 % of the variants seen in both checks changed price by the same factor (at least 5 variants). The cost: a genuine store-wide sale of exactly the same percentage, on more than 80 % of the variants, is also treated as a rebaseline and not reported. A permanent domain move shows up as one warning; set the new domain instoresto keep things tidy. -
Variants without a price on either check never produce price events.
-
Public data only. Some shops turn their feed off, put the store behind a password, block bots, or disallow paths in robots.txt. Those shops give free error rows. None of this is ever worked around.
-
Product pages are slow and partial. One request per page at 2 s each is about 7 minutes for 200 pages. A missing page counts as a missing product, and
confirmRemovalsgives one check of grace. Pages rendered only by JavaScript carry no data here. -
WooCommerce variable products are tracked as two points, "lowest price" and "highest price" (the Store API price range). They never get a compare-at price, because the API's minimum price and minimum regular price can come from different variations. Per-variation prices are not read.
-
Currency changes. When a product's stated currency changes (WooCommerce, page data, feed), its prices are not compared on that check. The product is re-baselined for free, and SUMMARY carries a warning.
-
Incomplete page crawls. If any sitemap or product page was skipped (failed, disallowed by robots.txt, or without Product data), the check is treated like a capped one: products not seen are kept, not reported as removed. A product is reported removed when its page answers 404/410 on two checks in a row, or when it is missing from a complete crawl, also confirmed on two checks.
-
Capped catalogs (
maxProductsPerStore/maxPagesPerStorereached) do not reportnew_product: the window may simply have moved. A product with several offers keyed only by position (no SKU, GTIN or URL) is not diffed per variant. -
Shopify
/products.jsonhas no GTIN and no currency. GTINs for Shopify shops are available only when the theme puts them in its page JSON-LD. To get them, forcestructured-data(one request per product). -
No inventory counts. The public feed has
availableonly (in stock or not), not quantities. Prices are the storefront's default-market prices: no per-country markets and no B2B price lists. -
Paging uses
?limit=250&page=N. Shopify does not document this storefront feed for third parties, and it can change it. Very large catalogs (tens of thousands of products) are slow at a polite 2 s per page, so usecollectionsormaxProductsPerStore. -
Product title or handle changes alone are not reported (the URL in the next row uses the new handle).
-
Plain HTTP: no JavaScript, no proxies, and no evasion of bot protection.
Acceptable use
- Use it for public catalog data only: product titles, prices, availability, variants and identifiers. It never requests customer, order, cart, checkout or account endpoints, and it never reads reviews. The data contains nothing about people, and the Actor stores nothing personal.
- Respect each store's terms and the law that applies to you (for example the EU database right and unfair-competition rules for systematic copying). Watching competitors' public prices is common practice. Republishing someone's whole catalog is a different matter.
- robots.txt (RFC 9309) is checked on every request, under the product token
DataFeedBot. To opt your shop out, addUser-agent: DataFeedBot+Disallow: /. - The Actor reads one store at a time with at least 1 s (default 2 s) between requests, and retries a 429/503 at most once, honouring
Retry-Afterup to 30 s.
Pricing events
| Event | When | Suggested |
|---|---|---|
product-change-returned | one per product row (change, or baseline row with emitBaselineProducts), and one per gtin_match row | $0.002 |
store-checked-unchanged | a Shopify, WooCommerce or feed store checked with no row (baseline or no reportable change), per started 1,000 products | $0.003 |
product-pages-read | a shop read page by page (structured-data): per started 100 requests (sitemaps + product pages), on every committed check, changed or not | $0.01 |
Publisher: remove apify-default-dataset-item in Console, or every row (free error rows included) is charged on top. The Actor warns in SUMMARY when it is priced.