Skip to content
CrawlerData
Menu
Guides

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.7 second pause keeps submissions under the 100 requests per minute limit.
  • Compare item price. The alert compares each competitor’s item price with yours. If you compete on delivered price, compare total_price with your price plus your shipping instead. shipping is null when Google shows no figure, which is not the same as free shipping.
  • Skip merchants you do not compete with. IGNORE takes 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 tighten include and exclude.
  • Match your market. Set COUNTRY to the market your Shopify store sells in. country takes one of 31 two-letter codes, and prices come back in that market’s currency.
  • Identify offers by merchant and offer_title. Together they tell you who sells which listing.
  • Scale with webhooks. For a large catalog, pass a postbackUrl and 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

Guides · 9 min read

Monitor MAP violations on Google Shopping

Find every Google Shopping seller advertising your products below MAP, keep timestamped evidence of each offer and build a daily report, in Python.