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:
submit.mjs sends the week's list as one batch request.
webhook.mjs receives the signed "batch finished" event and saves every capture.
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.
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.
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.