Practical guide
WooCommerce Store API: Export Products and Track Competitor Prices (2026)
Last reviewed: October 24, 2026
Use the public WooCommerce Store API to export a catalog without API keys, convert price strings into currency amounts, and compare successful snapshots for competitor price drops and stock changes.
WooCommerce Store API versus the authenticated REST API
The public catalog route is /wp-json/wc/store/v1/products. WooCommerce describes the Store API as unauthenticated and intended for customer-facing functionality. Reading this product collection needs no consumer key, login or nonce. It does not grant access to other customers or orders, store settings, or administrative writes. Verified October 24, 2026: official Store API overview.
| Need | Endpoint | Access |
|---|---|---|
| Public storefront products | /wp-json/wc/store/v1/products | Keyless product reads, subject to site restrictions |
| Manage your own store | /wp-json/wc/v3/products | Authenticated administrative API with permitted read/write scope |
The REST API overview documents v3 and administrative operations. Its authentication guide explains consumer keys and permissions tied to a WordPress user. Never put administrative secrets into a competitor scraper.
The Products API reference documents published products; drafts and pending products are excluded. Password-protected products can expose catalog fields while descriptions are redacted. Expect IDs, names, slugs, permalinks, SKUs, images, descriptions, prices and availability where supplied. A public export is not a store backup.
Read price fields, stock and on-sale status
Under prices, price is the current amount, regular_price the regular amount, and sale_price the sale amount when applicable. These are strings in minor units. Keep currency_code and divide by 10 ** currency_minor_unit: "1299" with exponent 2 becomes 12.99; exponent 0 requires no division. Use Decimal, not binary floats.
on_sale is a boolean. Compare the current amount across runs for price drops, rather than treating a sale flag or reference-price edit as a drop. is_in_stock and is_purchasable describe different conditions. low_stock_remaining can be null; it is not a complete inventory count. The official product schema defines these fields; the Products API example shows their response structure.
Variable parents can have a price_range. Their displayed amount must not stand in for every size or color. For variant tracking, retrieve exposed records with type=variation, optionally scoped by parent, using the documented type and parent filters. Keep that as a separate, consistent export scope. The example below exports the default product collection and skips variable or grouped parents when diffing.
Fetch products with curl and paginate safely
Check the chosen site's robots.txt and terms first. Use its actual WordPress base URL, including a subdirectory if present. The example domain is a placeholder, not a tested live store.
# Replace the origin with a store you are permitted to query.
STORE='https://shop.example.com'
curl --fail --silent --show-error --max-time 30 \
--user-agent 'CatalogResearch/1.0' --dump-header woo.headers \
"$STORE/wp-json/wc/store/v1/products?per_page=100&page=1&orderby=id&order=asc" \
--output woo-page1.jsonInspect woo.headers for X-WP-Total and X-WP-TotalPages. Request page=2, then subsequent pages through that total. Page numbering starts at 1; per_page controls page size with a documented maximum of 100. See Store API pagination rules and WordPress pagination.
Use a fixed sort and scope. Reject HTML challenges, JSON error objects, repeated IDs, missing pagination metadata and changing totals. A catalog can change between requests even when counts stay constant, so a paged export is not transactional. Review surprising changes against the product page before acting.
Export WooCommerce products to CSV with Python
This small collector writes one row per returned product, normalizes money and refuses to replace the destination until all pages pass validation. HTTP errors and timeouts stop it. It deliberately requires pagination headers rather than guessing that the first page is the whole catalog.
# Save as export_woo.py. Standard library only, Python 3.
# After checking robots.txt and terms:
# python3 export_woo.py https://shop.example.com current.csv
import csv, json, sys, time
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
store = sys.argv[1].rstrip("/")
destination = Path(sys.argv[2])
rows, seen, expected = [], set(), None
page = 1
while True:
query = urlencode(dict(per_page=100, page=page, orderby="id", order="asc"))
request = Request(store + "/wp-json/wc/store/v1/products?" + query,
headers={"User-Agent": "CatalogResearch/1.0"})
with urlopen(request, timeout=30) as response:
totals = (int(response.headers["X-WP-Total"]),
int(response.headers["X-WP-TotalPages"]))
products = json.load(response)
if expected is None:
expected = totals
if totals != expected or not isinstance(products, list):
raise ValueError("Changed totals or invalid product collection")
if not products and expected[0] != 0:
raise ValueError("Unexpected empty page")
for product in products:
identity = str(product["id"])
if identity in seen:
raise ValueError("Duplicate ID; retry the scan later")
seen.add(identity)
prices = product["prices"]
unit = prices["currency_minor_unit"]
if type(unit) is not int or not 0 <= unit <= 6:
raise ValueError("Unsupported currency minor unit")
if not prices["currency_code"]:
raise ValueError("Missing currency")
def amount(field):
raw = prices.get(field)
if raw in (None, ""):
return ""
value = Decimal(raw) / (Decimal(10) ** unit)
if not value.is_finite() or value < 0:
raise ValueError("Invalid price")
return format(value, "f")
flags = [product[k] for k in ("is_in_stock", "on_sale")]
if any(type(flag) is not bool for flag in flags):
raise ValueError("Invalid stock or sale flag")
rows.append(dict(store=store, id=identity, name=product["name"],
type=product["type"], url=product["permalink"],
currency=prices["currency_code"], price=amount("price"),
regular_price=amount("regular_price"),
sale_price=amount("sale_price"),
is_in_stock=str(flags[0]).lower(),
on_sale=str(flags[1]).lower()))
if page >= expected[1]:
break
page += 1
time.sleep(2) # Increase if site policy requires a longer interval.
if len(rows) != expected[0]:
raise ValueError("Incomplete export")
fields = "store id name type url currency price regular_price sale_price is_in_stock on_sale".split()
temporary = destination.with_name(destination.name + ".tmp")
with temporary.open("w", newline="", encoding="utf-8") as output:
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
temporary.replace(destination) # Publish only after the whole scan succeeds.
print(f"Saved {len(rows)} products to {destination}")The two-second delay is an example starting interval, not permission or a universal safe rate. This script does not evaluate robots.txt, cache responses, retry requests or send notifications. Do those policy checks before running it. It keeps the catalog in memory, suitable for modest exports. Preserve CSV IDs as text and import names as text rather than spreadsheet formulas. Missing amounts remain blank instead of becoming zero. Python documents HTTP requests, CSV writing and Decimal arithmetic.
Build a WooCommerce price tracker and back in stock alert
On the first successful export, copy current.csv to baseline.csv. Later, export to current.csv again and run the diff below. Keep the baseline if collection fails. Compare the same store base URL, filters and product or variation scope; never compare across currencies.
# Save as diff_woo.py; run: python3 diff_woo.py baseline.csv current.csv
import csv, json, sys
from decimal import Decimal
def load(path):
with open(path, newline="", encoding="utf-8") as source:
rows = list(csv.DictReader(source))
result = {}
for row in rows:
key = (row["store"], row["id"])
if key in result:
raise ValueError("Duplicate identity")
result[key] = row
return result
before, after = map(load, sys.argv[1:3])
for key in sorted(before.keys() & after.keys()):
old, new = before[key], after[key]
if old["type"] != new["type"] or new["type"] in ("variable", "grouped"):
continue
if old["currency"] == new["currency"] and new["currency"]:
if old["price"] and new["price"]:
if Decimal(new["price"]) < Decimal(old["price"]):
print(json.dumps(dict(event="price_drop", id=key,
previous=old["price"], current=new["price"])))
if old["is_in_stock"] == "false" and new["is_in_stock"] == "true":
print(json.dumps(dict(event="back_in_stock", id=key, url=new["url"])))The script emits JSON lines for existing identities only. A newly observed product has no previous stock observation, so it is not a back-in-stock event. Missing products are not classified as out of stock. Save the event lines for your notification system, then replace the baseline only after accepting the successful scan and saving its events. Keep notification delivery state separately so failed delivery can retry.
Retain dated snapshots and observation times. A false-to-true stock flag is a candidate signal, not proof of warehouse quantity or a successful checkout. Tax display, shopper context, shipping and personalized discounts can affect what a buyer sees. Keep comparison context stable and review unexpected drops manually. Avoid filtering to only in-stock or sale products, because doing so hides the earlier state needed for transitions.
Poll politely, cache responses and handle blocked feeds
Review robots.txt for your client and read the site's terms before recurring collection. Follow disallows, identify your client truthfully, use one request at a time, and choose the lowest useful frequency. A daily export may fit a slow-changing catalog; select the interval with the store's policy and your actual need. Robots rules are crawler instructions, not access authorization. See RFC 9309.
Reuse saved snapshots for analysis instead of refetching. Where a server supplies validators, cache each exact page URL with its ETag or Last-Modified value and use If-None-Match or If-Modified-Since on later checks. A 304 means reuse that cached representation, not an empty page. Revalidate the collection totals and full scan before advancing state. Honor cache directives and Retry-After; stop the scan and back off on throttling or server errors. These HTTP mechanisms are defined in RFC 9110. Conditional requests are an extension to the example collector.
If access is disabled or blocked, preserve the previous baseline. A 401, 403, challenge page or login screen is a reason to stop. A 404 alone does not prove WooCommerce is absent: check the WordPress base path with the owner. WordPress documents an alternative /?rest_route=/wc/store/v1/products URL form for routing; use it only to resolve a routing issue where collection is allowed, never to evade a restriction. See WordPress routes and endpoints. If no approved public feed works, request an owner-provided export or authorized API access for your own store.
The easy option: Store Product Feed Monitor
Store Product Feed Monitor reads public WooCommerce Store API and Shopify feeds. Its README documents product, variant and changes modes, CSV exports, currency minor-unit conversion, and price-drop, price-increase, back-in-stock, out-of-stock, new and removed product events. Use this input with a permitted store URL:
{
"startUrls": ["https://shop.example.com"],
"platform": "woocommerce",
"mode": "changes",
"monitorKey": "woo-competitor",
"maxProductsPerStore": 0,
"maxResults": 2000,
"emitBaselineOnFirstRun": false,
"requestDelayMs": 2000
}The first successful changes run saves a silent baseline by default. Keep the same host spelling, monitor key and scope. Later records describe changes per variant. Unlimited parent scans support removal detection; capped scans suppress removal events. Inspect RUN_SUMMARY before treating a quiet run as evidence of no changes.
The README says it respects robots.txt and Crawl-delay, leaves the baseline intact after failed scans, and has no bypass for blocked feeds. WordPress subdirectory installations are unsupported. The Actor sends no alerts itself: configure scheduling and a webhook or integration to route dataset changes. Listed pricing in the README is $1.00 per 1,000 results; confirm current pricing and fees on its Store page. Public availability does not grant unrestricted reuse rights for descriptions or images.
Frequently asked questions
Can I use the WooCommerce products API without a key?
Yes. The public Store API products endpoint needs no API key. The administrative wc/v3 REST API requires authorized access, and stores can restrict public feed access.
Does this export include every product variation?
No. The default collection is a product export. Fetch exposed variation records separately before comparing individual sizes or colors; a variable parent price is not a variant price.
How do I convert WooCommerce Store API prices?
Divide the price string by 10 raised to currency_minor_unit using decimal arithmetic. For example, 1299 with a minor unit of 2 means 12.99 in the supplied currency.
How do I create a WooCommerce back in stock alert?
Compare is_in_stock for the same store and product or variation ID across successful snapshots. A false-to-true transition is a candidate alert; route it through your own notification integration.
What if the Store API is blocked?
Stop on access restrictions and keep the previous baseline. Ask the store owner for an approved feed or use authorized REST API access for your own store. Do not bypass authentication or site protections.
Sources
Official documentation reviewed October 24, 2026. Examples use a placeholder store; availability on any particular store is not guaranteed.
- WooCommerce: Store API overview, reviewed October 24, 2026
- WooCommerce: Products API, reviewed October 24, 2026
- WooCommerce: pagination and guiding principles, reviewed October 24, 2026
- WooCommerce: REST API overview, reviewed October 24, 2026
- WooCommerce: REST API authentication, reviewed October 24, 2026
- WooCommerce: official product schema source, reviewed October 24, 2026
- WordPress: pagination, reviewed October 24, 2026
- WordPress: routes and endpoints, reviewed October 24, 2026
- Python: urllib.request, reviewed October 24, 2026
- Python: csv, reviewed October 24, 2026
- Python: decimal, reviewed October 24, 2026
- RFC 9309: robots.txt, reviewed October 24, 2026
- RFC 9110: conditional requests and HTTP semantics, reviewed October 24, 2026
Tool inputs, outputs and limitations were checked against the repository README for Store Product Feed Monitor. No live pricing or universal store compatibility claim is made.