Practical guide

How to Monitor Shopify and WooCommerce Competitor Prices

Last reviewed: October 22, 2026

Fetch a public catalog, normalize variant identity, price, currency and availability, then compare it with the previous successful snapshot. A product name or a storefront sale badge is not a stable comparison key.

Choose stores, products and comparison scope

Write down the exact origin, collection if applicable, currency context and variants you need. Review robots.txt and site terms before recurring requests. Use a truthful client identifier, space requests and stop on access blocks. Public access does not grant unrestricted rights to republish images or descriptions.

Start with a small permitted sample. Preserve the raw response and collection time. An outage or password page must be a failed observation rather than an empty catalog. The examples below use live endpoints checked on October 22, 2026; future responses may differ.

Shopify products.json: limit=250 and page

curl --fail --silent --show-error --location --max-time 30 \
  'https://www.allbirds.com/products.json?limit=250&page=1' > shopify-page1.json
jq -e '.products | type == "array"' shopify-page1.json
jq '.products[0] | {id,title,handle,variants}' shopify-page1.json

The checked catalog returned product IDs, titles, handles, vendor/type/tags, images, options and nested variants including prices and availability. Request successive pages with limit=250&page=2, then page 3, until an empty products array. Deduplicate IDs and abort on repeated pages or malformed responses. Space requests and retain the prior baseline on any failure.

Evidence limit: this is observed public storefront behavior, not a documented Shopify Admin API contract. The first checked page returned 250 products and the next page was separately checked. We did not establish an official guarantee that 250 is a universal maximum or that every store supports this route. The official Ajax Product API instead documents a product-by-handle route such as /products/{handle}.js. Sources for the catalog observations: first page and second page.

Some stores can block, disable or not implement the feed. Headless storefronts may differ. Do not bypass a 401, 403 or password gate. Catalog fields may omit currency; never assume USD. Confirm currency separately before comparing. Availability is a feed flag, not warehouse quantity or guaranteed checkout inventory.

WooCommerce Store API product prices and stock

Use the public Store API, not the authenticated administrative WooCommerce REST API. Its documented products endpoint exposes published product data for storefront use. Example:

curl --fail --silent --show-error --location --max-time 30 \
  --dump-header woo.headers \
  'https://offermanwoodshop.com/wp-json/wc/store/v1/products?per_page=100&page=1' \
  > woo-page1.json
jq -e 'type == "array"' woo-page1.json
jq '.[0] | {id,name,type,prices,is_in_stock,variations}' woo-page1.json

Use page/per_page and the response pagination headers to collect the intended scope. Validate each page and compare counts before advancing state. Money fields under prices are strings with currency metadata. Convert prices.price using currency_minor_unit; do not hard-code two decimal places for every currency. Sources: WooCommerce: Store API products and WooCommerce: Store API guiding principles and pagination.

A variable parent can represent multiple sizes or colors. The documented type=variation query retrieves variation records; include can select exposed IDs. Retrieve those records before treating the parent as variant-level data. If the store does not provide those records, mark the comparison incomplete. The parent's displayed price alone is not evidence that every variation has that price.

Normalize snapshots and diff prices and availability

After collecting every Shopify page, flatten variants with jq. Set the currency only after confirming it for that store and market. These commands create the array consumed by the comparison script:

jq -s --arg store 'https://www.allbirds.com' --arg currency 'USD' '
  [.[].products[].variants[] |
   {id:($store + ":" + (.id|tostring)),price:.price,
    currency:$currency,available:.available}]' shopify-page*.json > after.json

For WooCommerce, save real variations in additional page JSON files if needed. Convert amounts with decimal arithmetic. This example excludes variable parents, whose child records must have been fetched separately:

# Save as normalize_woo.py, then run:
# python3 normalize_woo.py woo-page*.json > after.json
import json, sys
from decimal import Decimal
rows = {}
for name in sys.argv[1:]:
    with open(name, encoding="utf-8") as f:
        products = json.load(f)
    for product in products:
        if product["type"] in ("variable", "grouped"):
            continue
        prices = product["prices"]
        amount = Decimal(prices["price"]) / (Decimal(10) ** prices["currency_minor_unit"])
        key = "https://offermanwoodshop.com:" + str(product["id"])
        rows[key] = {"id": key, "price": str(amount),
                     "currency": prices["currency_code"],
                     "available": product["is_in_stock"]}
print(json.dumps(list(rows.values())))

For Shopify, key rows by origin plus variant ID. Store decimal price strings and an explicitly confirmed currency. For WooCommerce, use the product or real variation ID and convert minor units with decimal arithmetic. Keep is_in_stock distinct from purchasability; a product can be in stock yet unsuitable for purchase for another reason.

Save complete normalized arrays as before.json and after.json. Example input shape:

[{"id":"shop.example:variant-42","price":"19.95","currency":"USD","available":true}]

This standard-library script compares those arrays without making network requests. Run python3 diff_prices.py before.json after.json > events.jsonl. Only compare the same complete collection scope; otherwise the removal loop is unsafe.

# Save as diff_prices.py. Compare two complete, normalized JSON arrays.
# Each row: {"id":"store:variant", "price":"19.95",
#            "currency":"USD", "available":true}
import json, sys
from decimal import Decimal

def load(path):
    with open(path, encoding="utf-8") as f:
        rows = json.load(f)
    result = {}
    for row in rows:
        key = str(row["id"])
        if key in result:
            raise ValueError("Duplicate identity")
        if type(row["available"]) is not bool:
            raise ValueError("Availability must be boolean")
        Decimal(row["price"])  # validate before comparing
        result[key] = row
    return result

before, after = map(load, sys.argv[1:3])
for key in sorted(after):
    new = after[key]
    if key not in before:
        print(json.dumps({"id":key, "event":"new", "current":new}))
        continue
    old = before[key]
    if old["currency"] != new["currency"]:
        print(json.dumps({"id":key, "event":"currency_changed_review"}))
    elif Decimal(old["price"]) != Decimal(new["price"]):
        event = "price_drop" if Decimal(new["price"]) < Decimal(old["price"]) else "price_increase"
        print(json.dumps({"id":key, "event":event,
                          "previous":old["price"], "current":new["price"]}))
    if old["available"] != new["available"]:
        event = "back_in_stock" if new["available"] else "out_of_stock"
        print(json.dumps({"id":key, "event":event}))
# Only enable removal alerts when BOTH scans cover the same complete scope.
for key in sorted(set(before) - set(after)):
    print(json.dumps({"id":key, "event":"removed_from_feed"}))

A currency change goes to review rather than becoming a price drop. Use decimal values instead of binary floats for money comparisons. Do not substitute zero for missing prices or false for unknown availability. Validate inputs before calling the script; unknown currency needs review, not a guessed label.

Avoid false price-drop and back-in-stock alerts

  • Compare the same variant, currency and market context on both runs. Lowest product price can move when an inexpensive size is added or removed.
  • Keep regular/reference prices separate from current selling prices. A reference-price edit alone is not a change to the price a buyer pays.
  • Only infer removal from a complete scan of the same scope. A capped page or changed collection does not prove a product disappeared.
  • Expect catalogs to change while paging. Retain observation times and review surprising changes on the live product page.
  • Keep failures and delivery retries separate. A notification queue should acknowledge delivery independently of snapshot comparison.

Feeds do not establish landed cost, shipping, customer-specific discounts, tax equivalence or exchange-rate comparisons. Choose a review step before using a feed change to make a pricing decision.

Use Store Product Feed Monitor changes mode

For repeat collection across stores, use the Actor's changes mode with a stable monitor key:

{
  "startUrls": ["https://www.allbirds.com", "https://offermanwoodshop.com"],
  "platform": "auto",
  "mode": "changes",
  "monitorKey": "competitor-catalog",
  "maxProductsPerStore": 0,
  "maxResults": 2000,
  "minPriceChangePct": 1,
  "emitBaselineOnFirstRun": false,
  "requestDelayMs": 1000
}

The first successful Changes run saves a silent baseline by default. Later runs can emit price drops/increases, back-in-stock/out-of-stock and new/removed product events. These events are per variant; a new size can be a new_product. A price change and stock change can produce two rows.

maxProductsPerStore: 0 requests an unlimited parent-product scan, useful for removal detection. maxResults still caps output. Capped scans suppress removals and preserve unseen baseline entries. Changing collection, origin, platform or product limit starts a fresh baseline. Compare-at-only edits have no dedicated event.

Keep host spelling, scope and monitor key stable, and avoid overlapping runs. WordPress subdirectory installations are unsupported. The Actor does not send alerts itself: use Apify schedules for runs and webhooks or an existing integration to route dataset changes. Check RUN_SUMMARY for failures before interpreting silence.

Frequently asked questions

Does products.json work on every Shopify store?

No. Access and storefront implementation vary. The guide distinguishes observed catalog behavior from Shopify's documented product-by-handle Ajax API.

Are WooCommerce API keys required?

The public Store API products endpoint does not require administrative API keys. A store can still restrict access.

Does available mean an exact stock quantity?

No. Treat it as an observed availability flag. It does not prove warehouse quantity or that checkout will succeed.

Why is the first Actor Changes run empty?

It saves a baseline by default. Enable emitBaselineOnFirstRun to emit matching initial observations, or wait for a later change.

Can a partial scan prove a product was removed?

No. Compare complete snapshots of the same scope before inferring removal.

Sources

Primary documentation and official authorities reviewed for this guide. Live responses are observations on the review date; source policies and laws can change.

Tool inputs, outputs and limitations: Store Product Feed Monitor documentation.