Monitor a website for changes with screenshots

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.

How it works

  1. Capture: each watched URL is screenshotted, either the full page or just one element (a price, a table, a status box) via a CSS selector.
  2. Compare: pixelmatch counts the pixels that differ from snapshots/<name>/latest.png, ignoring anti-aliasing noise.
  3. Record: on a change, the new screenshot becomes latest.png, and a dated copy plus a -diff.png (changes in red) are saved next to it. That folder is your visual history.
  4. Alert: if WEBHOOK_URL is set, one message per changed page is posted to it.

1. Install

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.

2. The script

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.

3. Alerts in Slack or Discord

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().

4. Run it on a schedule

Option A: GitHub Actions (free, history in git)

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.

Option B: cron on any server

# 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

How many renders does it use?

WatchingRenders a month
3 pages, every 6 hoursabout 360
25 are free each month; a 2,000-credit Starter pack (5 EUR) covers the other 335 for about 6 months
10 pages, dailyabout 300
10 pages, hourlyabout 7,200
a 10,000-credit Growth pack (19 EUR) lasts about 6 weeks

Credits are prepaid packs, not a subscription. See pricing.

Avoiding false alerts

CauseFix
Dates, counters, rotating banners
Anything that changes on every load
List their selectors in hide, e.g. hide: ".date, .carousel"
Cookie banners and adsblock_ads is already on in the script
Changes elsewhere on the page you don't care aboutWatch only the part you care about with selector, e.g. "#pricing"
Tiny real changesRaise MIN_CHANGED_PIXELS (a changed word is a few hundred pixels, a new paragraph thousands)
Rendering differences between runsNone needed: every capture runs on the same server Chrome, so an unchanged page gives 0 differing pixels

Screenshots or text diffs?

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.

What we tested

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.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: Use cases

All guides: guides · integrations · comparisons