Integration Guide

How to Track Amazon Competitor Prices

Build a price-tracking pipeline for Amazon competitor products: batch price checks, marketplace and location pitfalls, MAP-violation alerts, and handling partial failures without breaking the run.

Written by Alex P.

  • Amazon price tracking
  • competitor pricing
  • price monitoring
  • MAP monitoring
  • ecommerce

Checking a competitor’s Amazon listing by hand works fine for one product. It stops working the moment you have twenty products across a handful of competitors and want to know the same day a price moves, not the week you happen to remember to look.

This is a batch problem, not a scraping problem: you already know which ASINs you care about, you want their current price on a schedule, and you want to be told when something changes — a price drop, a price hike, or a reseller quietly going below the price you told them to hold.


The architecture

Cron → price-check up to 20 ASINs at once → diff against last known price → alert on change

The only real design decision is which mode to poll with, because it decides both your cost per check and what you can detect.


fast vs. full: pick fast for polling

Amazon’s price-check route has two modes, and they answer different questions:

  • fast (the default) prices the whole batch from a single listing per product, at low cost. You get price and whether an Add to Cart offer exists.
  • full reads every product’s page individually for seller and stock status too, at a much higher cost per product.

For a recurring poll, fast is the right default — it returns the same price either way. Reach for full only when you specifically need to know who is selling at that price or whether it’s in stock, not just what the price is.

const res = await fetch('https://api.fetchlayer.dev/amazon/price-check', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ss-your-api-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    asins: ['B08N5WRWNW', 'B07XJ8C8F5'],
    mode: 'fast',
    marketplace: 'com',
    postalCode: '10001',
  }),
});

const { results } = await res.json();

Prices depend on where the order ships — pin it down

Every priced route takes a marketplace (the domain suffix — com, co.uk, de, and so on) and a postalCode, and the response reports which deliveryLocation the prices apply to. This isn’t a formality: Amazon prices by delivery location, so if you poll without pinning a postal code you can end up comparing today’s price at one default location against yesterday’s price at a different one and conclude something changed when it didn’t.

Pick one marketplace + postalCode pair per tracked product and keep it fixed across every poll. If a product doesn’t ship to that location at all, it comes back with no offer rather than a price — check for that explicitly rather than treating a missing price as a scrape failure.


A working price monitor

import { readFile, writeFile } from 'node:fs/promises';

const API_KEY = process.env.FETCHLAYER_API_KEY;
const STATE_FILE = './price-history.json';
const MARKETPLACE = 'com';
const POSTAL_CODE = '10001';

// Group your own ASINs against the competitors you're tracking them against.
const WATCHLIST = [
  { label: 'our-product', asin: 'B08N5WRWNW' },
  { label: 'competitor-a', asin: 'B07XJ8C8F5' },
  { label: 'competitor-b', asin: 'B09FZJ7QQK' },
];

async function checkPrices(asins) {
  const res = await fetch('https://api.fetchlayer.dev/amazon/price-check', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      asins,
      mode: 'fast',
      marketplace: MARKETPLACE,
      postalCode: POSTAL_CODE,
    }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return (await res.json()).results;
}

async function run() {
  let history = {};
  try {
    history = JSON.parse(await readFile(STATE_FILE, 'utf8'));
  } catch {}

  const results = await checkPrices(WATCHLIST.map((w) => w.asin));
  const changes = [];

  for (const item of results) {
    const watched = WATCHLIST.find((w) => w.asin === item.asin);
    const label = watched?.label ?? item.asin;

    // One bad ASIN shouldn't block reading the rest of the batch.
    if (item.status !== 'ok') {
      console.warn(`${label}: ${item.status}, skipping this run`);
      continue;
    }

    const previous = history[item.asin]?.price;
    if (previous !== undefined && previous !== item.price.amount) {
      changes.push({ label, asin: item.asin, from: previous, to: item.price.amount });
    }

    history[item.asin] = { price: item.price.amount, currency: item.price.currency, checkedAt: new Date().toISOString() };
  }

  await writeFile(STATE_FILE, JSON.stringify(history, null, 2));

  if (changes.length) await alert(changes);
  console.log(`Checked ${results.length}, ${changes.length} price change(s).`);
}

run().catch(console.error);

status is one of ok, unavailable, not_found, or failed per product — a batch of 20 where one ASIN is temporarily unavailable still returns the other 19 as ok. Skip the bad one and retry it next run rather than discarding the whole batch.


Catching MAP violations

If you sell through resellers and enforce a minimum advertised price, the same pipeline catches violations by comparing against a floor instead of a previous value:

const MAP_FLOORS = {
  B08N5WRWNW: 49.99,
};

function checkMapViolations(results) {
  return results
    .filter((r) => r.status === 'ok' && MAP_FLOORS[r.asin] !== undefined)
    .filter((r) => r.price.amount < MAP_FLOORS[r.asin])
    .map((r) => ({ asin: r.asin, floor: MAP_FLOORS[r.asin], actual: r.price.amount }));
}

Run this against every poll alongside the price-change diff — a MAP violation is worth a same-day alert regardless of whether the price also moved from the last check.


Alerting

async function alert(changes) {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `${changes.length} price change(s) detected`,
      blocks: changes.map((c) => ({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*${c.label}* (${c.asin})\n$${c.from} → $${c.to}`,
        },
      })),
    }),
  });
}

Scaling past 20 ASINs

price-check takes up to 20 products per call, all on the same marketplace. Past that, batch:

function chunk(arr, size) {
  const out = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

async function checkAllPrices(asins) {
  const batches = chunk(asins, 20);
  const all = [];
  for (const batch of batches) {
    all.push(...(await checkPrices(batch)));
  }
  return all;
}

Run batches sequentially rather than in parallel unless you’ve confirmed your plan’s concurrency limits — a burst of simultaneous batches gains you little on a poll that only needs to run hourly or daily.


Practical notes

  • There’s no reviews route, by design. price-check and product-details return the aggregate star rating and count, not review text. If you need review content, that’s a different lookup entirely.
  • Search caps at 20 pages. If you’re building the watchlist itself from a category search rather than a hand-picked ASIN list, search-products won’t walk past Amazon’s own 20-page ceiling — narrow the query rather than requesting more pages.
  • Read the notes array. Every response carries one, and it’s the only place a partial or substituted answer announces itself — a price quoted for a different location than you asked for, for instance.

Next Steps