Competitor price monitoring for Shopify stores with Google Shopping data
Monitor competitor prices for your Shopify catalog from Google Shopping: match products, run a daily Python job, log prices to CSV and alert on undercuts.
CrawlerData teamPublished 4 min read
Contents
Competitor price monitoring for a Shopify store comes down to three steps: match each of your products to the same product on Google Shopping, pull every seller’s price for it on a schedule, and get told when someone undercuts you. This guide builds that with a small Python job: your Shopify product export in, a daily price history CSV and undercut alerts out.
Everything below ran against the live API on 13 October 2026, using two example products, a Hydro Flask bottle and a Ninja air fryer. The output is real and trimmed.
Why Google Shopping
Google Shopping already collects offers from many retailers for the same product. One request to the Google Shopping Sellers API returns every seller Google shows for a product, with price, shipping, total price and availability. You do not have to find, crawl and maintain a scraper for each competitor’s site. For most Shopify niches, the retailers you compete with are listed there.
What you need
- Your Shopify product export (CSV).
- Python 3.9+ with
requests(pip install requests). - A CrawlerData API key in
CRAWLERDATA_API_KEY. New accounts get 50 free credits with a verified work email, enough to try this on a few products.
Step 1: export your products from Shopify
In the Shopify admin, go to Products and click Export, then choose all products and a CSV file. For all products Shopify emails you the file (Shopify help).
The columns this guide uses are Title, URL handle, Vendor and Price (column reference). Shopify has renamed some columns over time, so the scripts accept a couple of spellings.
Step 2: a small API helper
Every request is a POST to https://api.crawlerdata.com/v1/jobs. The live queue returns the result in the response. The fast queue returns a job ID straight away, and you poll GET /v1/jobs/{id}. Both cost the same. Save this as crawlerdata.py:
import os
import time
import requests
API = "https://api.crawlerdata.com/v1/jobs"
HEADERS = {"Authorization": f"Bearer {os.environ['CRAWLERDATA_API_KEY']}"}
def _call(method, url, **kw):
for attempt in range(4):
resp = requests.request(method, url, headers=HEADERS, timeout=200, **kw)
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(5 * 2 ** attempt)
continue
if not resp.ok:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.json()['error']['message']}")
return resp.json()
raise RuntimeError(f"HTTP {resp.status_code} after retries")
def run_live(endpoint, params):
data = _call("POST", API, json={"endpoint": endpoint, "params": params, "queue": "live"})
if data["job"]["status"] != "completed":
raise RuntimeError(data["job"]["error"]) # failed jobs cost 0 credits
return data["result"]
def submit(endpoint, params):
return _call("POST", API, json={"endpoint": endpoint, "params": params, "queue": "fast"})["job"]["id"]
def wait(job_id, poll=15, max_wait=600):
waited = 0
while waited < max_wait:
time.sleep(poll)
waited += poll
data = _call("GET", f"{API}/{job_id}")
if data["job"]["status"] in ("completed", "failed"):
return data
return None
It retries HTTP 429 and 5xx, and stops on bad keys or an empty balance. A job that fails while crawling comes back with status: "failed" and costs 0 credits.
Step 3: match products to Google Shopping IDs, once
Sellers are fetched by Google Shopping product_id. There is no formula from a SKU or barcode to that ID, so you search once per product and save the right ID. This script searches each product and writes the top 5 candidates to candidates.csv for you to review:
import csv
import sys
from crawlerdata import run_live
def col(row, *names):
"""Shopify has renamed export columns over time, so accept a few spellings."""
for n in names:
if row.get(n):
return row[n].strip()
return ""
def main(export_csv, out_csv="candidates.csv", country="us"):
with open(export_csv, newline="", encoding="utf-8-sig") as f:
rows = [r for r in csv.DictReader(f) if col(r, "Title")]
with open(out_csv, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["handle", "my_price", "product_id", "google_title", "card_price", "card_merchant", "include", "exclude"])
for r in rows:
title, vendor = col(r, "Title"), col(r, "Vendor")
query = title if vendor.lower() in title.lower() else f"{vendor} {title}"
results = run_live("google-shopping-search", {"query": query, "country": country})
for p in results.get("shopping_results", [])[:5]:
w.writerow([col(r, "URL handle", "Handle"), col(r, "Price", "Variant Price"), p["product_id"],
p["title"], p["price"]["value"] if p["price"] else "", p["merchant"], "", ""])
print(f"{query}: {len(results.get('shopping_results', []))} results")
if __name__ == "__main__":
main(*sys.argv[1:])
Run it:
python match_products.py products_export.csv
Each search costs 1 credit. Two things we saw in our run:
- A full Shopify title is not always a good query. “Ninja AF101 Air Fryer 4 Qt” returned three different Ninja Crispi models as the top results. The shorter query “ninja af101 air fryer” put an AF101 listing in the top two. Brand plus model number is usually the best query. Edit the query and search again where the candidates look wrong.
- A barcode alone did not work. A search for a bare 12-digit UPC returned unrelated products, from ranges to laptops. Use barcodes to check a match, not to find one.
Then build mapping.csv by hand from the candidates: one row per product with your price, the chosen product_id, and two keyword lists (separated by ;) that an offer’s title must and must not contain:
handle,my_price,product_id,include,exclude
hydro-flask-32-wide-flex-straw,49.95,671128257113256657,32 oz;wide,40 oz;24 oz
ninja-af101,114.99,13236607309793982184,af101,
Why the keyword filters matter
A Google Shopping product can group offers that are not exactly your item. The Hydro Flask product we picked returned 14 sellers. The cheapest offers were not the 32 oz bottle at all:
| Merchant | Offer title (trimmed) | Price |
|---|---|---|
| Hydro Flask | Wide Mouth Flex Straw Cap - Black | $14.95 |
| Nordstrom Rack | Hydro Flask 40-Ounce Wide Mouth Flex Straw Cap Bottle | $29.97 |
| Target | Hydro Flask 24 fl oz Stainless Steel Wide Mouth Flex Straw | $35.49 |
| Kohl’s | Hydro Flask 32 oz. Wide Flex Straw Cap Trillium | $44.95 |
The first is a replacement cap, the next two are different sizes. Without a filter, the script would report a $14.95 “competitor” for a $49.95 bottle. The include list keeps offers that mention all of its words, and exclude drops offers with any of them. Spaces are ignored when matching, so 32 oz also matches 32oz. Our filter still let through one Zappos offer for the Flex Cap version of the bottle; adding straw to include would have dropped it.
Filters can be too strict too. For the air fryer, af101 dropped offers titled only “Ninja Air Fryer, Black”, which may well be the same model. Start strict, look at what gets dropped in the history file, and loosen if needed.
Step 4: the daily job
monitor.py submits a sellers request for every product on the fast queue, waits for each, filters the offers, appends them to price_history.csv and prints (or posts to Slack) any undercut:
import csv
import datetime as dt
import os
import time
import requests
from crawlerdata import submit, wait
IGNORE = ("your store name", "ebay") # merchants to skip: you, and any you do not compete with
UNDERCUT = 0.02 # alert when a competitor is 2% or more below you
COUNTRY = "us"
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
def norm(s):
return (s or "").lower().replace(" ", "") # "32 oz" and "32oz" compare equal
def matches(offer_title, include, exclude):
t = norm(offer_title)
inc = [norm(w) for w in include.split(";") if w.strip()]
exc = [norm(w) for w in exclude.split(";") if w.strip()]
return all(w in t for w in inc) and not any(w in t for w in exc)
def money(m):
return m["value"] if m else None
def main(mapping_csv="mapping.csv", history_csv="price_history.csv"):
with open(mapping_csv, newline="", encoding="utf-8") as f:
products = list(csv.DictReader(f))
jobs = {}
for p in products: # submit everything first
jobs[p["handle"]] = submit("google-shopping-product",
{"product_id": p["product_id"], "country": COUNTRY})
time.sleep(0.7) # stay under 100 requests a minute
today = dt.date.today().isoformat()
alerts, rows = [], []
for p in products:
data = wait(jobs[p["handle"]])
if not data or data["job"]["status"] != "completed":
print(f"{p['handle']}: no result today ({data and data['job']['error']})")
continue
my_price = float(p["my_price"])
offers = [s for s in data["result"]["sellers"]
if not s["merchant"].lower().startswith(IGNORE)
and matches(s["offer_title"], p["include"], p["exclude"])]
for s in offers:
rows.append([today, p["handle"], s["merchant"], money(s["price"]),
money(s["shipping"]), money(s["total_price"]), s["availability"], s["offer_title"]])
priced = [s for s in offers if s["price"]]
if priced:
low = min(priced, key=lambda s: s["price"]["value"])
if low["price"]["value"] <= my_price * (1 - UNDERCUT):
alerts.append(f"{p['handle']}: {low['merchant']} at {low['price']['value']:.2f} "
f"(you: {my_price:.2f}, {len(offers)} matching offers)")
new_file = not os.path.exists(history_csv)
with open(history_csv, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if new_file:
w.writerow(["date", "handle", "merchant", "price", "shipping", "total_price", "availability", "offer_title"])
w.writerows(rows)
print("\n".join(alerts) or "No undercuts today.")
if alerts and SLACK_WEBHOOK:
requests.post(SLACK_WEBHOOK, json={"text": "Price alerts:\n" + "\n".join(alerts)}, timeout=30)
if __name__ == "__main__":
main()
A few choices in there:
- Submit first, then wait. Each sellers request collects every seller on the product page in one go. Submitting all jobs before waiting lets them run in parallel instead of one after another. The
0.7second pause keeps submissions under the 100 requests per minute limit. - Compare item price. The alert compares each competitor’s item
pricewith yours. If you compete on delivered price, comparetotal_pricewith your price plus your shipping instead.shippingisnullwhen Google shows no figure, which is not the same as free shipping. - Skip merchants you do not compete with.
IGNOREtakes merchant name prefixes. Put your own store name there, as Google Shopping shows it. We also skipped eBay sellers, whose offers are often used or open-box. Whether you should depends on your market. - Failed jobs are logged and skipped. They cost nothing, and tomorrow’s run tries again.
Our run printed:
hydro-flask-32-wide-flex-straw: Backcountry.com at 44.95 (you: 49.95, 6 matching offers)
ninja-af101: Walmart - Seller at 95.99 (you: 114.99, 3 matching offers)
And price_history.csv started like this:
date,handle,merchant,price,shipping,total_price,availability,offer_title
2026-10-13,hydro-flask-32-wide-flex-straw,Backcountry.com,44.95,5.95,50.9,In Stock,"Hydro Flask 32oz Wide Mouth Flex Straw Water Bottle Trillium2, One Size"
2026-10-13,hydro-flask-32-wide-flex-straw,Kohl's,44.95,8.95,53.9,In Stock,Hydro Flask 32 oz. Wide Flex Straw Cap Trillium
2026-10-13,ninja-af101,Walmart - Seller,95.99,0,95.99,In Stock,"Ninja Af101 Air Fryer with 4-Quart Ceramic Coated Basket, Black/Grey, Size: 4 QT"
2026-10-13,ninja-af101,Macy's,119.99,0,119.99,In Stock,Ninja AF101 4-Quart Ceramic Nonstick Electric Air Fryer - Black
Note the Backcountry line: $44.95 is below our $49.95, but with $5.95 shipping the delivered price is $50.90. That is the kind of case where comparing total_price gives a different answer.
Step 5: send it to a Google Sheet (optional)
If your team works in Google Sheets, you can import the CSV by hand, or append rows from the script with the gspread library and a Google service account:
import gspread
gc = gspread.service_account(filename="service-account.json")
sheet = gc.open_by_key("YOUR_SHEET_ID").sheet1
sheet.append_rows(rows, value_input_option="USER_ENTERED")
Share the sheet with the service account’s email address first, or the call will be refused.
Step 6: schedule it
Any scheduler works. On a Linux server or Mac, a cron line runs it every morning at 06:00:
0 6 * * * cd /opt/price-monitor && CRAWLERDATA_API_KEY=... /usr/bin/python3 monitor.py >> run.log 2>&1
On Windows, use Task Scheduler. A scheduled GitHub Actions workflow also works, with the history file committed back or stored elsewhere. Google Shopping prices do not change minute by minute for most products, so once a day is a sensible starting point.
What it costs
Shopping search is 1 credit per request, used only when you match a product. A sellers request costs 1 credit per 10 sellers returned, rounded up, minimum 1. Both of our products had 11 to 20 sellers, so 2 credits per check. Failed jobs are free, every queue costs the same, and credits are valid for 6 months.
Packs from the pricing page: $50 for 50,000 credits, $375 for 500,000, $1,250 for 2.5 million and $3,750 for 12.5 million.
| Catalog | Setup (once) | Per month, daily checks at 2 credits | Pack that covers 6 months | Per month |
|---|---|---|---|---|
| 50 products | 50 credits | 3,000 | One Starter ($50) covers all 6 months, with credits to spare | $8.33 |
| 200 products | 200 credits | 12,000 | One Starter ($50) lasts about 4 months | $12.00 |
| 1,000 products | 1,000 credits | 60,000 | One Standard ($375, 500,000) covers 6 months | $62.50 |
Matching takes a few extra searches when the first query is wrong, so budget 2 to 3 searches per product for setup. Products with more sellers cost more: 21 to 30 sellers is 3 credits per check. Checking more than one country is a separate request per country.
Why build it on the Sellers API
A small script on top of the Google Shopping Sellers API gives you:
- Every seller in one request. Price, shipping and total price for each seller Google Shopping lists, sorted by total price, with no pagination.
- Your own matching rules, which you can correct the moment a match looks wrong.
- The raw history, in a file or database you own, not only a dashboard.
- A cost that scales with what you check: 1 credit per 10 sellers returned, prepaid packs with no subscription, and failed checks at 0 credits.
Running it in production
- Review the first week of history. Look for offers that slipped past your filters in
price_history.csv, and tightenincludeandexclude. - Match your market. Set
COUNTRYto the market your Shopify store sells in.countrytakes one of 31 two-letter codes, and prices come back in that market’s currency. - Identify offers by
merchantandoffer_title. Together they tell you who sells which listing. - Scale with webhooks. For a large catalog, pass a
postbackUrland each result is sent to you when it is ready, at the same price as polling.
Next steps
For a deeper look at seller fields, see how Google Shopping sellers and offers work. If you prefer JavaScript, the Node.js version of this tracker stores history and alerts on price drops. Brands watching resellers should read MAP monitoring on Google Shopping. The competitor price monitoring page has the overview, and the Google Shopping API page has the full parameter list.
Frequently asked questions
- How do I see competitor prices for my Shopify products?
- Match each product to its Google Shopping product ID once, then request that product's sellers every day. The sellers list gives each competitor's price, shipping and total. Filter out your own store, compare the lowest matching price with yours and alert when you are undercut.
- How do I match Shopify products to Google Shopping product IDs?
- Search Google Shopping once per product with the brand plus model number, review the top candidates, and save the right product_id. Use barcodes to confirm a match rather than to find one. After that, every daily check is a single sellers request.
- How much does daily competitor price monitoring cost?
- On CrawlerData, 1 credit per product to find its ID once, then 1 credit per 10 sellers returned per check. 200 products with 11 to 20 sellers each, checked daily, is about 12,000 credits a month, or about $12 a month at the $50 Starter pack rate.
- Do I need a Shopify app for price monitoring?
- No. A script like the one in this guide gives you the raw data, your own matching rules and a cost that scales with the products you check, with no per-product app pricing.
- Does this change my Shopify prices automatically?
- The script reads Google Shopping and writes a price history and alerts, so a person stays in control of every price change. You can add repricing on top once you trust your matches, because a bad match can trigger a wrong price change.
- Shopify
- Competitor price monitoring
- Google Shopping sellers
- Python
- Price monitoring