Track competitor prices across every Google Shopping seller (Node.js)
A daily Node.js job that checks every Google Shopping seller for your products, stores price history, diffs it and alerts you when a price drops.
CrawlerData teamPublished 8 min read
Contents
To track competitor prices on Google Shopping you need the full list of sellers for each of your products, checked on a schedule, with every check saved so you can compare today with yesterday. This guide builds that in Node.js with no dependencies: a daily job that submits every product, waits for the results, stores a history file per product, diffs it against the last run and sends an alert when a competitor drops their price.
It uses the CrawlerData Google Shopping Sellers API, which returns every seller for a product in one request, with no pagination, for 1 credit per 10 sellers returned (22 sellers = 3 credits). The responses quoted below come from the live API on 12 October 2026, trimmed.
How the job works
- Read a list of products, each with a Google Shopping
product_idand a country. - Submit one
google-shopping-productjob per product on thefastqueue. Each submission returns a job ID immediately. - Poll each job until it is
completedorfailed. - Turn the result into a compact snapshot: one entry per seller with price, shipping and total.
- Compare with the previous snapshot for that product, append the new one, and alert on changes.
You need Node.js 18 or newer for the built-in fetch, and your API key in CRAWLERDATA_API_KEY.
Step 1: list your products
The job reads products.json. The product_id comes from a Google Shopping search: run google-shopping-search for each product once, check that the result is the listing you mean, and save its ID. Keep IDs as strings. Some are 20 digits long, which is beyond what a JavaScript number can hold exactly.
[
{ "product_id": "11726664398501807381", "country": "us", "label": "Sony WH-1000XM5" },
{ "product_id": "13793769760861274890", "country": "us", "label": "Sony WH-1000XM5 (second listing)" }
]
Each product and country pair is a separate request. Results differ by country, and an ID found in one country’s results may not return the same sellers elsewhere, so look IDs up in the country you will monitor.
Step 2: submit and poll
A sellers request collects every seller on the Google Shopping product page in one go. Rather than wait on each one in turn, the job submits everything on the fast queue first, so the jobs run in parallel on our side, and polls afterwards.
// track-prices.mjs: check every seller for a list of products, store history, alert on drops.
// Node 18+ (global fetch). Run once a day from cron.
import fs from 'node:fs/promises';
import path from 'node:path';
const API = 'https://api.crawlerdata.com/v1/jobs';
const HEADERS = {
Authorization: `Bearer ${process.env.CRAWLERDATA_API_KEY}`,
'Content-Type': 'application/json',
};
const POLL_MS = Number(process.env.POLL_MS ?? 15_000);
const MAX_WAIT_MS = 10 * 60_000;
const DROP_THRESHOLD = 0.05; // alert when a seller's total price falls 5% or more
const HISTORY_DIR = 'history';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(url, init = {}) {
const res = await fetch(url, { ...init, headers: HEADERS });
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${body.error?.message ?? 'request failed'}`);
return body;
}
// Submit on the fast queue: the API answers 202 with a job id straight away.
async function submit({ product_id, country }) {
const { job } = await api(API, {
method: 'POST',
body: JSON.stringify({ endpoint: 'google-shopping-product', params: { product_id, country }, queue: 'fast' }),
});
return job.id;
}
async function waitFor(jobId) {
const started = Date.now();
while (Date.now() - started < MAX_WAIT_MS) {
await sleep(POLL_MS);
const { job, result } = await api(`${API}/${jobId}`);
if (job.status === 'completed') return { job, result };
if (job.status === 'failed') throw new Error(`job ${jobId} failed: ${job.error}`);
}
throw new Error(`job ${jobId} still running after ${MAX_WAIT_MS / 1000}s`);
}
A submission on the fast queue returns HTTP 202 with the job in pending state. Polling GET /v1/jobs/{id} returns the same job object, with status moving through pending and processing to completed or failed, and a result once it has completed.
A job that does not complete comes back from polling with "status": "failed", a readable error such as "Google temporarily blocked the request. Please retry.", "credits": 0 and "result": null. You are not charged, so the script logs it and moves on. You can resubmit failed products at the end of the run, or let the next day’s run pick them up.
Step 3: reduce each result to a snapshot
The sellers response has a lot you do not need for price tracking. The snapshot keeps one entry per merchant with the numbers that matter, plus the job’s completedAt timestamp and ID so you can trace any row back to the request that produced it.
// One row per seller. Keyed by merchant; if a merchant appears twice, keep its cheapest offer.
function snapshot(job, result) {
const sellers = {};
for (const s of result.sellers) {
if (!s.total_price) continue;
const prev = sellers[s.merchant];
if (prev && prev.total <= s.total_price.value) continue;
sellers[s.merchant] = {
price: s.price?.value ?? null,
shipping: s.shipping?.value ?? null,
total: s.total_price.value,
currency: s.total_price.currency,
availability: s.availability,
};
}
return { checked_at: job.completedAt, job_id: job.id, sellers_count: result.sellers_count, sellers };
}
Here is a real snapshot line from our test product, trimmed to three of its 22 sellers:
{"checked_at":"2026-10-12T05:11:43.090Z","job_id":"6acc6befe93571bd329d8652","sellers_count":22,"sellers":{"Best Buy":{"price":198,"shipping":0,"total":198,"currency":"USD","availability":"In Stock"},"eBay":{"price":215,"shipping":20,"total":235,"currency":"USD","availability":"In Stock"},"Verizon":{"price":399.99,"shipping":null,"total":399.99,"currency":"USD","availability":"In Stock"}}}
Notes on the choices here:
- Merchant as the key. The merchant name identifies each seller. Marketplace sellers show up with the marketplace in the name, such as
eBay - firesale-deals, so they stay distinct. shipping: nullstays null. Verizon had no shipping value, so its total equals its price. Storingnullrather than 0 keeps that distinction for later.- Snapshot plus raw response. If you may need to show exactly what Google listed, also save the full JSON response alongside the snapshot.
Step 4: diff against the last run
function diff(before, after) {
const changes = [];
for (const [merchant, now] of Object.entries(after.sellers)) {
const was = before.sellers[merchant];
if (!was) { changes.push({ type: 'new_seller', merchant, now: now.total }); continue; }
const drop = (was.total - now.total) / was.total;
if (drop >= DROP_THRESHOLD) changes.push({ type: 'price_drop', merchant, was: was.total, now: now.total, pct: Math.round(drop * 100) });
}
for (const merchant of Object.keys(before.sellers)) {
if (!after.sellers[merchant]) changes.push({ type: 'seller_gone', merchant, was: before.sellers[merchant].total });
}
return changes;
}
Three kinds of change are reported: a seller’s total dropped by at least the threshold, a new seller appeared, or a seller disappeared. The comparison is on total_price, which is what a shopper pays before tax. If your pricing rules are about the item price alone, compare price instead.
Expect some noise. The seller list Google shows can change between two checks without any seller changing a price, so a “seller gone” alert is a prompt to look, not proof. If that is too noisy, only alert when a seller has been missing for two runs in a row.
Step 5: store history and send alerts
History is a JSON Lines file per product and country: one line per check, appended, never rewritten.
async function lastSnapshot(file) {
try {
const lines = (await fs.readFile(file, 'utf8')).trim().split('\n');
return JSON.parse(lines.at(-1));
} catch {
return null; // first run for this product
}
}
async function alert(product, changes) {
const text = [`Price changes for ${product.label} (${product.country}):`,
...changes.map((c) => c.type === 'price_drop'
? `- ${c.merchant}: ${c.was} -> ${c.now} (-${c.pct}%)`
: `- ${c.type.replace('_', ' ')}: ${c.merchant} ${c.now ?? c.was}`)].join('\n');
console.log(text);
if (process.env.SLACK_WEBHOOK_URL) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }),
});
}
}
async function main() {
const products = JSON.parse(await fs.readFile('products.json', 'utf8'));
await fs.mkdir(HISTORY_DIR, { recursive: true });
// Submit everything first, then wait: the jobs run in parallel on our side.
const jobs = await Promise.all(products.map(async (p) => ({ p, jobId: await submit(p) })));
for (const { p, jobId } of jobs) {
try {
const { job, result } = await waitFor(jobId);
if (result.error) { console.warn(`${p.label}: ${result.error}`); continue; }
const file = path.join(HISTORY_DIR, `${p.product_id}_${p.country}.jsonl`);
const before = await lastSnapshot(file);
const after = snapshot(job, result);
await fs.appendFile(file, JSON.stringify(after) + '\n');
const changes = before ? diff(before, after) : [];
if (changes.length) await alert(p, changes);
else console.log(`${p.label}: ${after.sellers_count} sellers, no changes worth an alert`);
} catch (err) {
console.error(`${p.label}: ${err.message}`); // failed jobs are not charged; tomorrow's run retries
}
}
}
main();
The result.error check covers product IDs Google no longer recognises. Those jobs complete with "error": "Product not found" and an empty seller list, and they are charged like any completed request, so remove dead IDs from products.json when you see them.
To check the diff logic, we edited a stored snapshot by hand (raised one price, removed one seller, added a made-up one) and ran the comparison against the real result. The alert looked like this:
Price changes for Sony WH-1000XM5 (us):
- Macy's: 259.99 -> 232.16 (-11%)
- new seller: Verizon 399.99
- seller gone: Gone Store 250
Alerts go to the console and, if SLACK_WEBHOOK_URL is set, to a Slack incoming webhook. Swap in email or your own tool as needed.
When a file is not enough
JSON Lines works for a few hundred products. Past that, or when several people query the data, move it to a table with one row per seller per check:
CREATE TABLE price_history (
checked_at timestamptz NOT NULL,
product_id text NOT NULL,
country char(2) NOT NULL,
merchant text NOT NULL,
price numeric,
shipping numeric,
total_price numeric NOT NULL,
currency char(3) NOT NULL,
job_id text NOT NULL,
PRIMARY KEY (product_id, country, merchant, checked_at)
);
Keep product_id as text, keep shipping nullable, and keep job_id so you can trace any row back to its request.
Step 6: schedule it
Run the job once a day from cron (or any scheduler):
# 06:00 every day
0 6 * * * cd /opt/price-tracker && CRAWLERDATA_API_KEY=YOUR_API_KEY node track-prices.mjs >> run.log 2>&1
Polling is the simplest way to collect results. If you would rather not poll, pass a postbackUrl with each job and the result is sent to that URL when the job finishes, signed with an X-CrawlerData-Signature header (HMAC-SHA256 of the body). Keep polling as a fallback for any job whose webhook you have not received.
What it costs
A sellers check returns every seller in one request and costs 1 credit per 10 sellers returned, rounded up, with a minimum of 1. The product above, with 22 sellers, costs 3 credits per check; a product with 8 sellers costs 1. Failed jobs cost nothing, and the queue does not change the price. The examples below assume about 21 to 30 sellers per product, so 3 credits per check.
- 500 products, one country, once a day: 500 × 30 × 3 = 45,000 credits a month. At the Starter pack rate of $1.00 per 1,000 that is $45 a month, and one $50 Starter pack (50,000 credits) covers a month.
- 2,000 products in two countries, once a day: 2,000 × 2 × 30 × 3 = 360,000 credits a month, which one Standard pack (500,000 credits, $375) covers. Over 6 months that is 2,160,000 credits, which one Scale pack (2,500,000 credits, $1,250) covers inside its 6-month validity, about $208 a month.
- Setup: one search per product to find its ID, once.
Polling requests do not use credits. The four packs and their per-1,000 rates are on the pricing page.
Running it in production
- Track every listing you compete on. The same model can appear on Google Shopping under several product IDs, so add each listing to
products.json, as the example does with the second WH-1000XM5 listing. - Check fast movers more often. Once a day suits most catalogs. Add extra checks for the products where prices move fast; every queue costs the same per check.
- Resubmit failures at the end of the run. Failed jobs cost 0 credits, so a second pass only costs credits when it succeeds.
For the business side of this workflow, see competitor price monitoring. If you want to understand the search results you pick IDs from, the market research page covers the search endpoint’s fields. And for how cost per product compares across providers, read Google Shopping API pricing compared.
Frequently asked questions
- How often should I check competitor prices on Google Shopping?
- Once a day is a common starting point for a catalog. Each check of a product is one request that returns every seller, charged 1 credit per 10 sellers returned (rounded up, minimum 1), so the cost is your number of products times your number of checks times the credits per check. Check more often only for the products where prices move fast.
- Should I compare price or total_price?
- For what a shopper pays, compare total_price, which is price plus shipping. For advertised-price rules, compare price. The script in this post alerts on total_price and stores both.
- Do I need to wait on each request?
- No. Submit every product on the fast queue, which returns a job ID straight away, then poll each job or receive results by webhook. Async queues cost the same as live requests.
- What happens if a check fails?
- The job comes back with status failed, an error message and credits 0, so you are not charged. The script logs it and the product is checked again on the next run, or you can resubmit it straight away.
- How should I store price history?
- Append one snapshot per product per check with a timestamp, and never overwrite old rows. A JSON Lines file per product is enough to start; a database table keyed by product, country, merchant and time scales better.
- Google Shopping API
- Node.js
- Price monitoring
- Google Shopping sellers
- Tutorial