Blog 6 min read

Competitor Pricing Page Monitoring in Node.js: Weekly Captures and Price Diffs

Track competitor pricing pages with a weekly batch of screenshots, a signed webhook that files them into a dated history, and a price extractor that tells you exactly which number changed. Runnable Node.js 18+ code.

Competitors change pricing quietly: a tier gets renamed, a limit drops, an annual discount appears. This guide builds a small Node.js system that captures every competitor pricing page once a week, files the screenshots into a dated history, and sends a short digest that names exactly which prices moved.

It has three parts, each a single file:

  1. submit.mjs sends the week's list as one batch request.
  2. webhook.mjs receives the signed "batch finished" event and saves every capture.
  3. prices.mjs extracts the prices from each page's rendered text and diffs them against last week.

Rendering happens in SnapRender: real Chromium, cookie banners and ads removed, fresh on every request. Node 18 or later is enough; the only dependency is Express for the webhook.

Try it on your URL

Live capture, no signup. You get the screenshot and the exact API call that produced it.

Register a webhook once

Webhooks are configured per account. Create one for the screenshot.completed event, which fires when a batch job finishes:

curl -s -X POST "https://app.snap-render.com/v1/webhooks" \
  -H "X-API-Key: $SNAPRENDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-server.example/snaprender", "events": ["screenshot.completed"]}'

The response contains a secret. Store it as SNAPRENDER_WEBHOOK_SECRET; the receiver uses it to check that each delivery really came from SnapRender.

Or skip the setup entirely

Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.

200 screenshots a month free. First render in under a minute.

1. Submit the weekly batch

// submit.mjs: run weekly from cron, a cloud scheduler or GitHub Actions
const COMPETITORS = [
  'https://competitor-one.example/pricing',
  'https://competitor-two.example/pricing',
  'https://competitor-two.example/enterprise',
];

const res = await fetch('https://app.snap-render.com/v1/screenshot/batch', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ urls: COMPETITORS, format: 'png', full_page: true }),
});
if (!res.ok) throw new Error(`batch rejected: ${res.status} ${await res.text()}`);
const job = await res.json();
console.log(`job ${job.jobId}: ${job.total} pages queued`);

One request covers up to 50 URLs. The call returns at once with a job id; rendering happens on SnapRender's side.

2. Receive the webhook and file the captures

// webhook.mjs
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import express from 'express';

const API = 'https://app.snap-render.com';
const app = express();

function signatureOk(raw, header) {
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.SNAPRENDER_WEBHOOK_SECRET).update(raw).digest('hex');
  return header && header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

app.post('/snaprender', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!signatureOk(req.body, req.get('X-SnapRender-Signature'))) return res.status(401).end();
  res.status(200).end(); // acknowledge first, then do the work

  const { jobId } = JSON.parse(req.body.toString());
  const job = await (await fetch(`${API}/v1/screenshot/batch/${jobId}`, {
    headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY },
  })).json();

  const week = new Date().toISOString().slice(0, 10);
  for (const item of job.items) {
    if (item.status !== 'completed') { console.warn('failed', item.url, item.error); continue; }
    const { hostname, pathname } = new URL(item.url);
    const dir = `history/${hostname}${pathname.replace(/\/+$/, '')}`;
    await fs.mkdir(dir, { recursive: true });
    const png = Buffer.from(await (await fetch(item.downloadUrl)).arrayBuffer());
    await fs.writeFile(`${dir}/${week}.png`, png);
  }
});

app.listen(3000, () => console.log('listening on :3000'));

Two details matter. The signature is an HMAC-SHA256 of the raw request body, so the route uses express.raw instead of a JSON parser; parsing first and re-serializing would change the bytes. And download links last 24 hours, so the handler saves everything immediately. Failed items are listed with an error and refunded to your quota.

After a few weeks, history/competitor-one.example/pricing/ holds one image per week, which is the side-by-side record people ask for in planning meetings.

3. Find out which prices moved

Screenshots answer "what did it look like". For "what changed", extract the page text and compare the prices in it. Run this after the captures land, or on its own schedule:

// prices.mjs
import fs from 'node:fs/promises';

const API = 'https://app.snap-render.com';
const PAGES = ['https://competitor-one.example/pricing', 'https://competitor-two.example/pricing'];
const PRICE = /(?:[$€£]\s?\d[\d,]*(?:\.\d{2})?|\d[\d,]*(?:\.\d{2})?\s?(?:USD|EUR|GBP))(?:\s?(?:\/|per)\s?(?:mo|month|yr|year|user|seat))?/gi;

const state = JSON.parse(await fs.readFile('prices.json', 'utf8').catch(() => '{}'));
const report = [];

for (const url of PAGES) {
  const params = new URLSearchParams({ url, type: 'text', selector: 'main' });
  const res = await fetch(`${API}/v1/extract?${params}`, { headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY } });
  if (!res.ok) { report.push(`${url}: extract failed (${res.status})`); continue; }
  const { content } = await res.json();
  const prices = [...new Set(content.match(PRICE) ?? [])].sort();

  const before = state[url];
  if (before) {
    const added = prices.filter((p) => !before.includes(p));
    const removed = before.filter((p) => !prices.includes(p));
    if (added.length || removed.length) report.push(`${url}\n  removed: ${removed.join(', ') || 'none'}\n  added:   ${added.join(', ') || 'none'}`);
  }
  state[url] = prices;
}

await fs.writeFile('prices.json', JSON.stringify(state, null, 2));
console.log(report.length ? report.join('\n\n') : 'No price changes this week.');

The output reads like "removed: $49/month, added: $59/month", which is the line worth forwarding. If a page renders its table outside main, change the selector to the table's own element; scoping keeps navigation and footer numbers out of the comparison.

Pricing pages with a monthly and annual toggle usually render one of the two by default. That default is what both your capture and your extract see, so the comparison stays consistent week to week.

Scheduling and cost

Run submit.mjs weekly, for example Monday at 06:00, and prices.mjs a little later. Each page is one render for the screenshot and one for the text extract.

Pages Frequency Renders a month SnapRender plan
20 weekly about 175 Free
60 weekly about 520 Starter, $9
100 daily about 6,000 Growth, $29

Every row above is one GET request away. 200 free renders a month, no card.

Start free

Agencies watching competitors for several clients at once tend to land on Business at $79 for 50,000 renders a month.

Things that will trip you up

  • Bot protection. A few sites block automated browsers outright. Their items fail with an error and are not billed; check the failure list once and drop or replace those URLs.
  • Region-specific prices. Some sites price by visitor location. Captures come from SnapRender's servers, and geo-targeted rendering is not offered, so record which currency you see and compare like with like.
  • Price patterns. The regex above covers common formats. Prices written as "49 dollars" or split across elements need a page-specific tweak; run it once and eyeball the extracted list.

Where this fits

This is the developer version of competitive intelligence tools: fewer dashboards, full control of the data, and a cost measured in renders rather than monitored pages. The competitor monitoring guide has the same pattern in Python with a volume calculator, and change detection without false alarms covers making alerts quiet enough to trust. To see a capture before writing code, paste a pricing page into the free screenshot tool.

Frequently asked questions

How do I monitor competitor pricing pages automatically?

Send the list of pricing page URLs to a screenshot API once a week, store each capture in a folder named for the date, and extract the rendered text to compare prices with the previous week. A webhook tells your code when the captures are ready.

Why extract prices from rendered text instead of scraping the HTML?

Many pricing tables are built in the browser with JavaScript, and annual or monthly toggles change the numbers on the client. The rendered text is what a buyer sees; the raw HTML often has no prices in it at all.

Is monitoring competitor websites legal?

Viewing and capturing publicly available pages for internal analysis is common practice, but terms of service and local law vary. Stay on public pages, keep request volume low, and check with counsel before publishing captures.

Or skip the setup entirely

200 screenshots a month free. First render in under a minute.

Grab a free API key