Website Change Monitoring with a Screenshot API
A competitor changes their pricing page overnight. A client's terms of service drops a refund clause between Tuesday and Thursday. Your own marketing site breaks after a CMS plugin update and nobody notices for a week. These aren't hypothetical scenarios — they're the kind of thing that costs real money when you catch it too late, and the kind of thing that's trivially cheap to catch if you're watching.
Most website change monitoring tools charge per check and lock you into a dashboard you didn't ask for. Visualping starts at $10/month for 65 checks. ChangeTower runs $30/month for basic access. If you're a developer who just wants an API call, a cron job, and a diff script, those tools are solving the wrong problem at the wrong price point.
A website change monitoring API turns the whole process into a three-step loop: capture a page, store the image, compare it against the previous one. If the pixels changed beyond a threshold, something happened. The whole pipeline is about 30 lines of code and a scheduled task — enough to monitor website changes automatically without paying for another SaaS dashboard.
The pattern: capture, store, compare, alert
Every website change detection system follows the same logic, whether it's a $100/month SaaS or a script on your laptop. You take a snapshot of the page, wait some time, take another snapshot, and see if they differ. The difference between tools is how you capture and how you compare.
One thing worth clarifying up front: a screenshot API and a change detection system are two separate things. The API does one job — it opens a URL in a real browser and returns a screenshot. That's it. It doesn't compare images, doesn't track history, doesn't send alerts. The monitoring logic lives in your code: you write a script that calls the API, saves the result, and runs a pixel comparison against the previous capture. The screenshot API is the eyes. Your script is the brain.
For the "eyes" part, a screenshot API handles the browser. You send a URL, you get a PNG. No Chromium to install, no memory leaks to chase, no zombie processes eating your server's RAM at 3 AM. I covered the full build vs. buy tradeoff for Puppeteer in a separate comparison — the short version is that self-hosted headless browsers work fine until you need them to run reliably on a schedule, which is exactly what monitoring requires.
For the "brain" part, a pixel-diff library like pixelmatch (Node.js) or Pillow (Python) does the comparison. These are free, open-source tools that take two images of the same dimensions, walk through every pixel, and report how many differ. If you've set up visual regression testing in CI/CD before, you already know the approach. Here the target is different: you're comparing external pages over time rather than your own code across deploys.
Capturing screenshots optimized for change detection
Not every screenshot works for comparison. If your viewport shifts by a pixel between runs, if a cookie banner appears one time and not the next, if an ad rotates — the diff lights up with false positives and the monitoring becomes noise. Consistent capture parameters are the foundation.
Here's a capture request tuned for monitoring:
curl -X POST https://screenshotrun.com/api/v1/screenshots \
-H "Authorization: Bearer sr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://competitor.com/pricing",
"width": 1280,
"height": 800,
"full_page": true,
"format": "png",
"block_cookies": true,
"block_ads": true,
"hide_selectors": [".live-chat", ".cookie-notice", ".timestamp"],
"delay": 2,
"cache_ttl": 0
}'
Every parameter here matters for consistent monitoring. format: png because pixel comparison needs lossless images — JPEG compression creates phantom diffs where two identical pages produce slightly different pixel data. cache_ttl: 0 forces a fresh capture every time, because monitoring only works if you're comparing actual current state. And the blocking parameters — block_cookies, block_ads, hide_selectors — strip out everything that changes between loads without representing a real page change.
The hide_selectors array is where you handle site-specific noise. Live chat widgets reposition themselves, timestamps update every second, and notification badges show different counts on each load. None of those are the changes you care about. Cookie banner blocking alone eliminates the single biggest source of false positives — roughly four out of ten European sites display a consent overlay that obscures most of the visible area, and whether it shows up depends on IP geolocation and prior cookie state. On a monitoring run, you want the actual page content, not a GDPR dialog.
API parameters that matter for monitoring consistency
| Parameter | Why it matters for monitoring |
|---|---|
width, height | Locks viewport dimensions. A 1px shift between runs invalidates the entire comparison. |
full_page: true | Changes often happen below the fold. Pricing tiers, footer links, fine print. Full-page capture catches all of it. |
block_cookies: true | Removes GDPR banners that differ per session. Without it, 40% of captures have a consent overlay. |
block_ads: true | Ad rotations create diffs on every run. Block them unless ad changes are what you're monitoring. |
hide_selectors[] | Suppress timestamps, chat widgets, counters — anything dynamic that isn't a real content change. |
format: png | Lossless. JPEG compression artifacts cause phantom diffs. PNG vs JPEG for this use case isn't even a contest. |
cache_ttl: 0 | Forces a fresh render. Cached screenshots defeat the purpose of monitoring. |
delay: 2 | Gives JavaScript-heavy pages time to finish rendering. Prevents capturing loading spinners. |
If the page you're monitoring is a single-page app or loads content dynamically, swap delay for wait_for_selector with a CSS selector that only exists after the real content renders. I wrote about wait_for_selector strategies on the feature page — the patterns apply directly here.
A Node.js monitoring script: capture, compare, alert
This script captures a URL, compares it to the previous capture using pixelmatch, and logs whether the page changed. Run it on a cron schedule and wire the alert to Slack, email, or whatever notification system you use.
const fs = require('fs');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const API_KEY = process.env.SCREENSHOTRUN_API_KEY;
const API_BASE = 'https://screenshotrun.com/api/v1';
const SNAPSHOT_DIR = './snapshots';
async function captureAndCompare(url, label) {
// Capture fresh screenshot
const res = await fetch(`${API_BASE}/screenshots`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
width: 1280,
height: 800,
full_page: true,
format: 'png',
block_cookies: true,
block_ads: true,
cache_ttl: 0,
delay: 2,
}),
});
const { data } = await res.json();
// Poll until ready
let screenshot;
while (true) {
const poll = await fetch(`${API_BASE}/screenshots/${data.id}`, {
headers: { 'Authorization': `Bearer ${API_KEY}` },
});
screenshot = (await poll.json()).data;
if (screenshot.status === 'completed') break;
if (screenshot.status === 'failed') {
console.error(`Capture failed: ${url}`);
return;
}
await new Promise(r => setTimeout(r, 2000));
}
// Download image
const imgRes = await fetch(screenshot.links.image, {
headers: { 'Authorization': `Bearer ${API_KEY}` },
});
const currentBuffer = Buffer.from(await imgRes.arrayBuffer());
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
const previousPath = `${SNAPSHOT_DIR}/${label}-previous.png`;
const currentPath = `${SNAPSHOT_DIR}/${label}-current.png`;
// Compare with previous if it exists
if (fs.existsSync(previousPath)) {
const previous = PNG.sync.read(fs.readFileSync(previousPath));
const current = PNG.sync.read(currentBuffer);
if (previous.width === current.width && previous.height === current.height) {
const diff = new PNG({ width: current.width, height: current.height });
const mismatched = pixelmatch(
previous.data, current.data, diff.data,
current.width, current.height,
{ threshold: 0.1 }
);
const percent = ((mismatched / (current.width * current.height)) * 100).toFixed(2);
if (parseFloat(percent) > 0.5) {
console.log(`CHANGED: ${label} — ${percent}% pixels differ`);
fs.writeFileSync(`${SNAPSHOT_DIR}/${label}-diff.png`, PNG.sync.write(diff));
// Send alert here: Slack webhook, email, PagerDuty, etc.
} else {
console.log(`No change: ${label} (${percent}%)`);
}
} else {
console.log(`CHANGED: ${label} — page dimensions shifted`);
}
} else {
console.log(`First capture: ${label} — baseline saved`);
}
// Rotate: current becomes previous
fs.writeFileSync(currentPath, currentBuffer);
fs.copyFileSync(currentPath, previousPath);
}
// Monitor these URLs
const targets = [
{ url: 'https://competitor.com/pricing', label: 'competitor-pricing' },
{ url: 'https://example.com/terms', label: 'client-terms' },
{ url: 'https://yoursite.com', label: 'own-homepage' },
];
(async () => {
for (const { url, label } of targets) {
await captureAndCompare(url, label);
}
})();
Add it to a crontab — 0 */6 * * * node monitor.js runs it every 6 hours. For rate limiting on larger URL lists, I covered production rate-limiting strategies that apply directly to monitoring pipelines.
Python alternative with Pillow
If Python is your stack, the same pattern works with requests and Pillow. The comparison logic is simpler since Pillow has built-in image arithmetic:
import os, requests
from PIL import Image, ImageChops
from io import BytesIO
API_KEY = os.environ['SCREENSHOTRUN_API_KEY']
API_BASE = 'https://screenshotrun.com/api/v1'
def capture(url):
res = requests.post(f'{API_BASE}/screenshots', json={
'url': url, 'width': 1280, 'height': 800,
'full_page': True, 'format': 'png',
'block_cookies': True, 'block_ads': True,
'cache_ttl': 0, 'delay': 2,
}, headers={'Authorization': f'Bearer {API_KEY}'})
screenshot_id = res.json()['data']['id']
while True:
poll = requests.get(f'{API_BASE}/screenshots/{screenshot_id}',
headers={'Authorization': f'Bearer {API_KEY}'})
data = poll.json()['data']
if data['status'] == 'completed':
img_res = requests.get(data['links']['image'],
headers={'Authorization': f'Bearer {API_KEY}'})
return Image.open(BytesIO(img_res.content))
if data['status'] == 'failed':
return None
import time; time.sleep(2)
def compare_images(img_a, img_b):
if img_a.size != img_b.size:
return 100.0 # dimensions changed — definitely different
diff = ImageChops.difference(img_a, img_b)
changed_pixels = sum(1 for px in diff.getdata() if sum(px[:3]) > 30)
return (changed_pixels / (img_a.width * img_a.height)) * 100
# Usage
current = capture('https://competitor.com/pricing')
previous_path = 'snapshots/competitor-pricing.png'
if current and os.path.exists(previous_path):
previous = Image.open(previous_path)
diff_pct = compare_images(previous, current)
if diff_pct > 0.5:
print(f'Page changed: {diff_pct:.2f}% different')
current.save(previous_path)
elif current:
os.makedirs('snapshots', exist_ok=True)
current.save(previous_path)
print('Baseline saved')
The Pillow approach uses ImageChops.difference to subtract one image from the other. Pixels that are identical produce black (0,0,0). Anything that changed produces a non-zero value. The sum(px[:3]) > 30 threshold ignores anti-aliasing noise — sub-pixel rendering differences between captures that don't represent real changes.
Pricing: screenshot API vs. dedicated monitoring tools
Here's what monitoring 50 URLs every 6 hours actually costs across different approaches. That's about 6,000 captures per month — a moderate monitoring pipeline.
| Solution | Monthly cost | API access | Capture control |
|---|---|---|---|
| screenshotrun ($29/mo plan) | $29 | Full REST API | All parameters: viewport, blocking, selectors, format |
| Visualping | $80–100 | Limited | Basic viewport only |
| ChangeTower | $60–90 | No | Dashboard only |
| Self-hosted Puppeteer | $20–50 + time | Custom | Full, but you maintain it |
The screenshotrun free tier (200 captures/month) handles about 6 URLs monitored daily — enough to validate the pipeline before committing. The $9/month plan covers 3,000 captures, which handles 100 URLs checked daily with room to spare. And every plan includes every parameter. No "upgrade to Pro for cookie blocking." No enterprise gate on API access.
What monitoring can't catch: the honest limits
Screenshot-based monitoring detects visual changes. It tells you that something changed, not what changed. If a competitor adds a new pricing tier, you'll see the diff. If they change a single number in a paragraph of text, the diff might be too subtle to flag at your threshold. For text-level monitoring, you'd pair screenshots with HTML diffing — scrape the page source and run a text diff alongside the visual comparison.
Pages behind authentication are another limit. The API captures public URLs. If the page you need to monitor requires a login, you can pass cookies to inject a session, but maintaining authenticated sessions across monitoring runs adds complexity. For most use cases — competitor pricing, public terms of service, marketing pages, your own site — the pages are public by design.
Anti-bot protection affects a small percentage of sites. Most public-facing pages render fine through the API's Chromium instance. Aggressive bot detection (Cloudflare's challenge mode, DataDome, PerimeterX) can block captures. The stealth parameter helps with basic detection, but there's no universal bypass for every anti-bot system. If a specific competitor site blocks you, that's a data point about how protective they are of their content.
And dynamic content is the perennial noise source. If the page you're monitoring has a news feed, a stock ticker, or a "last updated 2 minutes ago" timestamp, every capture will show a diff. hide_selectors handles known dynamic elements, but you can't hide what you don't know about in advance. Start with a few test captures to identify the volatile elements before setting up alerts.
A website change monitoring API doesn't need a dedicated platform around it. A screenshot API, a diff library, and a cron job give you the same result at a fraction of the cost — and you own the entire pipeline. If you're already doing visual regression testing on your own code, the monitoring setup reuses the same tools against external URLs. For storing and managing the captured screenshots efficiently, the patterns from screenshot caching strategies apply here too, and error handling for production pipelines covers what to do when a capture fails mid-run. If you want the images to double as thumbnails for a directory or dashboard, add resize_width to the capture request and you get both monitoring and display from one API call. Good luck catching the changes before they catch you.