Competitor Price Tracking with Screenshots

Track competitor prices with daily element screenshots of each price block, a text tripwire that flags changes for free, and per-market locale settings.

By Simon Chiu · Updated

You need to know when a competitor changes a price, and you need proof of what the price was on a given day: for a pricing review, a sales call, or a price-match promise. A spreadsheet that somebody updates by hand falls behind within a month. Capture only the price block of each competitor page every day, add a text check that fails when the known price disappears, and keep the dated images and a one-line log per run. The check turns "did anything change?" into a free request, and the images answer "what did it say on 3 March?".

This page builds on website monitoring with screenshots, which covers schedules and pixel diffs for your own pages. Here the pages belong to someone else, so the job is narrower: one element, one market at a time, and a history you can show.

What the API gives you, and what it does not

RenderScreenshot returns pixels, not text. There is no option that returns the rendered HTML or the text of an element. Three features carry the workflow instead:

Need Feature Notes
Only the price, not the whole page capture.mode: "element" with selector A small image that is easy to compare and to read
Know the layout changed capture.selector_required: true A missing element fails with selector_not_found, at no cost, and does not fall back to a viewport capture
Know the price changed options.fail_if_missing: ["$49/mo"] Fails with content_check_failed, at no cost, when the text is not in the page's visible text

The text check runs against the whole page's visible text, not only the element. Choose a string that appears once, such as "$49/mo" or "€45 pro Monat", not a bare "49" that also matches a review count. The element screenshots guide covers selectors, sticky headers and shadow DOM.

When you need the new price as a number, you have two honest routes. If the site renders prices on the server, fetch the HTML with your own HTTP client and parse it. If the price is drawn by JavaScript, read it from the image, by a person or with OCR. Either way, the dated image stays the record.

Target Request body Purpose
Pricing table, one plan "capture": {"mode": "element", "selector": "#plan-pro .price", "selector_required": true}, "options": {"fail_if_missing": ["$49/mo"]} Daily tripwire and image
Product page price box "capture": {"mode": "element", "selector": "[data-testid=\"price-box\"]", "selector_required": true}, "block": {"cookie_banners": true, "chat_widgets": true} A shop page with consent and chat overlays
German store the same, plus "browser": {"locale": "de-DE", "timezone": "Europe/Berlin"} and the store's /de/ URL Prices in the market's language and currency
Store finder that prices by location "browser": {"geolocation": {"latitude": 48.137, "longitude": 11.575, "accuracy": 100}} For sites that ask the browser for its position
Full pricing page for the quarterly review "capture": {"mode": "full_page", "scroll": {"simulate": true}}, "output": {"format": "pdf"} One readable document per competitor

Shops add overlays that hide the price: region dialogs, newsletter pop-ups, promotion bars. The e-commerce screenshots guide shows how to remove each one before the element capture.

Markets and locations

Locale settings change what the browser says about itself. They do not move the browser. The localized screenshots guide shows which signal reaches which part of a site:

  • browser.locale sets the Accept-Language header and navigator.language. Sites that choose the language from those show the requested one.
  • browser.timezone changes the clock that page scripts see, which matters for "sale ends tonight" banners.
  • browser.geolocation answers the browser location API with your coordinates, without a permission prompt.
  • The IP address stays on Cloudflare's network. A site that sets the currency by IP shows the currency for that network location. Use the country URL, or send the site's own country cookie in network.cookies.

How it works

flowchart LR
  C[Daily cron] --> W[price-watch.mjs]
  W -->|element capture +<br/>fail_if_missing| R[RenderScreenshot]
  R -->|200 PNG| U[unchanged:<br/>save dated image]
  R -->|422 content_check_failed| X[changed:<br/>capture again without check]
  R -->|422 selector_not_found| L[layout changed:<br/>fix the selector]
  X --> S[(captures/name/date.png<br/>history.jsonl)]
  U --> S

The script

competitors.json holds one entry per price you track:

[
  { "name": "acme-pro", "url": "https://example.com/pricing", "selector": "#plan-pro .price", "expect": ["$49"] },
  { "name": "acme-de", "url": "https://example.com/de/preise", "selector": "#plan-pro .price", "expect": ["$59"], "locale": "de-DE", "timezone": "Europe/Berlin" },
  { "name": "globex", "url": "https://example.com/plans", "selector": "#gone", "expect": ["$19"] }
]
// price-watch.mjs: daily element captures of competitor price blocks, with a text tripwire.
// Node 22+, no dependencies.  RS_API_KEY=... node price-watch.mjs
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const targets = JSON.parse(await readFile('competitors.json', 'utf8'));
const today = new Date().toISOString().slice(0, 10);

async function captureBlock(t, expect) {
  const body = {
    url: t.url,
    viewport: { width: 1280, height: 800, scale: 2 },
    capture: { mode: 'element', selector: t.selector, selector_required: true },
    wait: { until: 'networkidle', timeout: 30 },
    block: { cookie_banners: true, chat_widgets: true },
    browser: { locale: t.locale ?? 'en-US', timezone: t.timezone ?? 'UTC' },
    output: { format: 'png' },
  };
  if (expect) body.options = { fail_if_missing: expect }; // the tripwire: fails, for free, when the text is gone
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(60_000),
  });
  if (res.ok) return { ok: true, png: Buffer.from(await res.arrayBuffer()), requestId: res.headers.get('x-request-id') };
  const data = await res.json().catch(() => ({}));
  return { ok: false, code: data.error?.code, status: res.status, requestId: data.request_id };
}

let errors = 0;
for (const t of targets) {
  let status = 'unchanged';
  let shot = await captureBlock(t, t.expect);
  if (!shot.ok && shot.code === 'content_check_failed') {
    status = 'changed'; // expected text missing: take the evidence capture without the check
    shot = await captureBlock(t, null);
  }
  if (!shot.ok) {
    status = shot.code === 'selector_not_found' ? 'layout-changed' : 'error';
    if (status === 'error') errors++;
  }
  if (shot.ok) {
    await mkdir(`captures/${t.name}`, { recursive: true });
    await writeFile(`captures/${t.name}/${today}.png`, shot.png);
  }
  const line = { date: today, name: t.name, status, request_id: shot.requestId ?? null, error: shot.ok ? null : shot.code };
  await appendFile('history.jsonl', JSON.stringify(line) + '\n');
  if (status !== 'unchanged') console.log(`${t.name}: ${status}${shot.ok ? '' : ` (${shot.code})`}`);
}
process.exitCode = errors ? 1 : 0;

Tested on 2026-10-18 with Node.js 22.18.0 against a local stub that returns the API's documented success and error responses. The three sample targets produced unchanged (image saved), changed (check failed, evidence image saved) and layout-changed (selector_not_found, no image), and the script exited 0. With a bad API key every target logged missing_credentials and the script exited 1. We did not call the production API for this page.

When a target reports changed, open the new image, update expect in competitors.json, and the next run is quiet again. Each line of history.jsonl carries the X-Request-Id, so any saved image can be matched to the API's request log.

Run it once a day from crontab or a scheduled CI job. Prices rarely change more often than that, and a daily rate is polite to the sites you watch. The schedule options are compared on the website monitoring page.

Keeping a history you can trust

For an internal pricing review, dated PNGs and the log are enough. If the images may support a dispute, such as a price-match claim or an advertising complaint, add a hash, the full request and the response headers to each capture, and store them where they cannot be edited. The compliance archiving page shows that manifest. For a long-term record of whole pages, see website archiving.

What it costs

An unchanged target costs 1 credit for its dated image. A changed target costs 0 for the failed check and 1 for the evidence capture. A layout change costs nothing.

Watch list Renders per month Plan that covers it
4 competitors, 1 price each, daily about 124 Free, 150 credits a month
20 competitors, 3 plans each, daily about 1,860 Starter, 2,500 credits for $10
300 product pages, daily about 9,300 Growth, 12,000 credits for $40

At 300 pages a day, each tracked price costs about $0.0043 per day. The pricing page has current numbers.

Next steps

Get a free API key, add your first three competitors to competitors.json, and run the script once by hand to check each selector before you schedule it.

Frequently asked questions

Does the API return the price as text or the page HTML?
No. It returns an image or a PDF (or, with Accept application/json, a JSON object with its CDN link), plus the page title, description and favicon. Use a content check to learn whether a known price is still there, and read a new price from the image or from HTML that you fetch yourself.
Can I see the prices a shopper in Germany sees?
Partly. browser.locale, browser.timezone and browser.geolocation change what the browser reports. They do not change the IP address, which belongs to Cloudflare's network. For sites that price by IP, use the site's country URL or a country cookie.
What does a run cost when nothing changed?
One credit per target, for the dated image of the price block. When the price text is gone, the first request fails for free and the second request captures the evidence for one credit.
Is it legal to screenshot competitor pricing pages?
Viewing public pages is usual practice, but terms of service and local law vary, and this is not legal advice. Capture public pages only, at a low rate, and do not try to get past logins, paywalls or bot challenges.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.