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.
The request gallery
| 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.localesets theAccept-Languageheader andnavigator.language. Sites that choose the language from those show the requested one.browser.timezonechanges the clock that page scripts see, which matters for "sale ends tonight" banners.browser.geolocationanswers 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.