Want to know when a competitor changes its pricing page, a supplier updates a terms page, or your own homepage breaks after a deploy? This guide builds a small website change monitor: a Node.js script that screenshots each page on a schedule, compares it with the last snapshot, and when something changed keeps a dated copy, writes a diff image and posts an alert to Slack or Discord. It runs from cron or free on GitHub Actions, with the snapshot history stored in your repository.
selector.snapshots/<name>/latest.png, ignoring anti-aliasing noise.latest.png, and a dated copy plus a -diff.png (changes in red) are saved next to it. That folder is your visual history.WEBHOOK_URL is set, one message per changed page is posted to it.Node 18 or newer (for the built-in fetch), then the two image libraries:
npm install pixelmatch pngjs
Get an API key with 25 free renders a month at /pricing (no signup) and export it as SHOTMINT_KEY. Every check of every page is one render.
Save as monitor.mjs (it uses top-level await, so keep the .mjs extension) and edit WATCHES. Each entry has a name (its snapshot folder) and a url, plus an optional selector to watch one element and hide for parts that change on every load.
// monitor.mjs: screenshot each watched page, compare it with the last snapshot,
// and when it changed, keep a dated copy plus a diff image and post to a webhook.
// Run it on a schedule (cron or GitHub Actions). Node 18+, npm i pixelmatch pngjs
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const WATCHES = [
{ name: "example-home", url: "https://example.com" },
// watch one element only, e.g. a price or a status box:
// { name: "pricing-table", url: "https://example.com/pricing", selector: "#plans" },
// hide parts that change on every load:
// { name: "blog", url: "https://example.com/blog", hide: ".date, .ad" },
];
const MIN_CHANGED_PIXELS = 50; // ignore smaller changes (a changed word is a few hundred)
const WEBHOOK_URL = process.env.WEBHOOK_URL; // optional: Slack or Discord incoming webhook
async function screenshot(w) {
const params = new URLSearchParams({
url: w.url,
width: "1280",
height: "800",
block_ads: "true",
});
if (w.selector) params.set("selector", w.selector);
else params.set("full_page", "true");
if (w.hide) params.set("hide_selectors", w.hide);
const res = await fetch("https://shotmint.creasework.com/v1/screenshot?" + params, {
headers: { "X-API-Key": process.env.SHOTMINT_KEY },
});
if (!res.ok) throw new Error(`${w.name}: ${res.status} ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
// Pad to a common size so a page that grew or shrank counts as changed.
function pad(img, width, height) {
if (img.width === width && img.height === height) return img;
const out = new PNG({ width, height });
PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0);
return out;
}
async function notify(text) {
console.log(text);
if (!WEBHOOK_URL) return;
// Slack reads "text", Discord reads "content"
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, content: text }),
});
if (!res.ok) console.error(`webhook: ${res.status} ${await res.text()}`);
}
const stamp = new Date().toISOString().slice(0, 16).replace(":", "-"); // 2026-10-20T08-15
let errors = 0;
for (const w of WATCHES) {
const dir = `snapshots/${w.name}`;
await mkdir(dir, { recursive: true });
let png;
try {
png = await screenshot(w);
} catch (e) {
errors++;
console.error(e.message);
continue;
}
const latest = `${dir}/latest.png`;
if (!existsSync(latest)) {
await writeFile(latest, png);
await copyFile(latest, `${dir}/${stamp}.png`);
console.log(`${w.name}: first snapshot saved`);
continue;
}
const before = PNG.sync.read(await readFile(latest));
const after = PNG.sync.read(png);
const width = Math.max(before.width, after.width);
const height = Math.max(before.height, after.height);
const diff = new PNG({ width, height });
const changed = pixelmatch(pad(before, width, height).data, pad(after, width, height).data,
diff.data, width, height, { threshold: 0.1 });
if (changed < MIN_CHANGED_PIXELS) {
console.log(`${w.name}: no change (${changed} pixels differ)`);
continue;
}
await writeFile(latest, png);
await writeFile(`${dir}/${stamp}.png`, png);
await writeFile(`${dir}/${stamp}-diff.png`, PNG.sync.write(diff));
await notify(`${w.name} changed (${changed} pixels): ${w.url}`);
}
process.exit(errors ? 1 : 0);
Run it twice. The first run saves a snapshot per page; later runs print no change or save the change and alert:
export SHOTMINT_KEY=your_key
node monitor.mjs # example-home: first snapshot saved
node monitor.mjs # example-home: no change (0 pixels differ)
# after the page changed:
# example-home changed (1958 pixels): https://example.com
The exit code is 1 only when a capture failed (a page was down, or a selector matched nothing, which the API reports as a 400 with the reason), so a scheduler can flag broken watches. A failed capture costs no credit.
Create an incoming webhook (Slack: Apps → Incoming Webhooks; Discord: Channel settings → Integrations → Webhooks) and set its URL as WEBHOOK_URL. The script posts {"text": ..., "content": ...}: Slack reads text, Discord reads content. For anything else (email, ntfy, a ticket), replace the body of notify().
Put monitor.mjs in a repository (a private one is fine), add SHOTMINT_KEY and optionally WEBHOOK_URL as repository secrets (Settings → Secrets and variables → Actions), and save this as .github/workflows/monitor.yml:
name: Website change monitor
on:
schedule:
- cron: "0 */6 * * *" # every 6 hours (UTC)
workflow_dispatch:
permissions:
contents: write # to commit new snapshots
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install pixelmatch pngjs
- name: Check pages
env:
SHOTMINT_KEY: ${{ secrets.SHOTMINT_KEY }}
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
run: node monitor.mjs
- name: Commit snapshots
if: always()
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add snapshots
git diff --cached --quiet || git commit -m "Website snapshots $(date -u '+%F %H:%M')"
git push
Each run that finds a change commits the new snapshots, so the repository's history shows exactly when each page changed, and GitHub renders the PNGs (and an image diff view) in the commit. Scheduled runs can start a few minutes late, and GitHub disables schedules in public repositories after 60 days without activity; run it once from the Actions tab (Run workflow) to test it. More workflows: screenshots in GitHub Actions.
# crontab -e: check every hour, log output
0 * * * * cd /home/me/monitor && SHOTMINT_KEY=your_key WEBHOOK_URL=https://hooks.slack.com/services/... node monitor.mjs >> monitor.log 2>&1
| Watching | Renders a month |
|---|---|
| 3 pages, every 6 hours | about 360 25 are free each month; a 2,000-credit Starter pack (5 EUR) covers the other 335 for about 6 months |
| 10 pages, daily | about 300 |
| 10 pages, hourly | about 7,200 a 10,000-credit Growth pack (19 EUR) lasts about 6 weeks |
Credits are prepaid packs, not a subscription. See pricing.
| Cause | Fix |
|---|---|
| Dates, counters, rotating banners Anything that changes on every load | List their selectors in hide, e.g. hide: ".date, .carousel" |
| Cookie banners and ads | block_ads is already on in the script |
| Changes elsewhere on the page you don't care about | Watch only the part you care about with selector, e.g. "#pricing" |
| Tiny real changes | Raise MIN_CHANGED_PIXELS (a changed word is a few hundred pixels, a new paragraph thousands) |
| Rendering differences between runs | None needed: every capture runs on the same server Chrome, so an unchanged page gives 0 differing pixels |
Visual monitoring catches what a text diff misses: a changed image, a broken layout, a hidden button, a price restyled as "sold out". It also works on pages that build their content with JavaScript, because the page is rendered in a real browser first. A text diff (fetch the HTML, strip tags, compare) is cheaper and tells you exactly which words changed, but it breaks on JavaScript-rendered pages and on markup churn. Hosted services such as Visualping or Distill do both with a dashboard and per-check pricing; this script is the do-it-yourself version: your storage, your alerts, one render per check. To compare any two screenshots by hand, with a slider and onion skin, use the free image comparison tool; to catch layout regressions before deploying, see visual regression testing.
We ran the script above against the live API on Node 22 with pixelmatch 8 and pngjs 7: the first run saved snapshots; a second run reported 0 differing pixels for example.com, an element-only watch (selector: "p") and a full-page Wikipedia article; changing the text on a watched image page was reported as 1958 changed pixels, wrote the dated screenshot and diff (1280×800) and posted the JSON above to a test webhook; a watch with a selector that matched nothing printed the API's 400 and made the script exit 1. The GitHub Actions workflow and the real Slack and Discord endpoints were not run from here.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons