Guide ·

Website Visual Change Detection on a Schedule

A page can stay online for weeks and still quietly change underneath you — a competitor drops their price, a supplier edits their terms, a partner restyles a landing page that your integration depends on. None of that trips an uptime check, and none of it shows up in your own CI. This guide builds a scheduled visual change detection loop: capture a page on a fixed cadence, compare each run to the previous baseline with one /diff call, and get alerted only when the pixels actually move. Cron-ready, self-host-ready, and callable by an AI agent over MCP.

Scheduled website change detection with SnapshotFlow — capture a page, diff it against the rolling baseline, and alert on visual change

TL;DR — the loop

# Every run: compare the live page to the last stored baseline
curl -G "https://api.snapshotflow.com/diff" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "before=https://my-bucket.r2.dev/baseline/competitor-pricing.png" \
  --data-urlencode "after=https://competitor.com/pricing" \
  --data-urlencode "threshold=0.2" \
  --data-urlencode "response_type=json"

# { "changed_pixels": 9210, "diff_percent": 0.90, "has_changes": true, ... }
# diff_percent over budget -> alert + roll the baseline forward
What you skip: a headless-browser fleet, a screenshot scheduler, and a hand-written pixel-diff. What you get: a single scheduled HTTP job that watches any URL — yours or someone else's — and pings you only when it visibly changes. New here? Start with the pillar: What Is a Screenshot API?.

Change detection vs uptime monitoring vs CI testing

These three jobs sound similar and are constantly confused. They answer different questions, fire on different triggers, and belong on different pages. Here is the clean split so you pick the right tool:

JobQuestion it answersTriggerBaseline
Uptime monitoringIs the page reachable and returning 200?Continuous pingHTTP status / latency
Visual regression (CI)Did my next deploy change the UI vs known-good?Pull request / pre-deployFrozen, approved snapshot
Change detection (this guide)Did a live page change since I last looked?Schedule (hourly/daily)The previous scheduled capture

If you want the uptime + reporting angle, see Webpage Screenshot API for SaaS. If you want the pre-deploy CI angle with GitHub Actions, see Visual Regression Testing. This guide is strictly the third row: watching a live URL change over time.

Why visual change detection matters

Most of the changes that actually affect a business never touch your codebase. A competitor quietly cuts a plan from $49 to $39 and you find out a quarter later from a churn report. A payment provider edits the legal copy on a page your contract references. A government portal you scrape moves a button, and your automation breaks at 2 a.m. with no warning. These are other people's changes, on pages you do not deploy, and they are invisible to every test you can write in your own repository.

Plain HTML diffing is the naive first attempt, and it fails fast: modern pages are a soup of hydration markers, randomized class names, CSRF tokens, and analytics blobs that change on every request without anything visible moving. You drown in diffs that mean nothing. The reliable signal is the rendered result — what a human would actually see — and the reliable way to compare two renders is a pixel diff, not a string diff.

That is the whole idea here: render the page on a schedule, compare each render to the last one, and surface only the runs where the visible page changed by more than a budget you set. The rendering, the warm browser pool, and the pixel comparison are operated by the API; your job is a few lines of glue and a scheduler.

How the scheduled loop works

The pattern is four moving parts and one stored file (the baseline):

1
Capture. On each scheduled run, render the target URL with /screenshot at a pinned viewport so width and height never drift between runs.
2
Compare. Call /diff with before = the previous stored capture and after = the live page. The endpoint renders, runs pixelmatch, and returns diff_percent, changed_pixels and has_changes.
3
Decide. If diff_percent is over your change budget, alert — and attach the diff PNG so a human instantly sees what moved.
4
Roll forward. Store the new capture as the next baseline, so the job always compares against the most recent confirmed state instead of a frozen one.

That fourth step is what separates change detection from regression testing. In CI you keep a frozen baseline you approved. Here the baseline moves: once you have seen and accepted a change, the new look becomes the reference for the next comparison, so you only ever get alerted about the next change, not the same one forever.

Your first scheduled check

Grab a free API key at dashboard.snapshotflow.com (free tier: 200 screenshots for the lifetime of the account, no credit card), export it, and seed a baseline once:

export SNAPSHOTFLOW_KEY=sk_live_xxxxxxxxxxxx

# 1. Seed the baseline once and ask SnapshotFlow for a reusable URL
BASELINE_URL=$(curl -sG "https://api.snapshotflow.com/screenshot" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "url=https://competitor.com/pricing" \
  --data-urlencode "width=1280" \
  --data-urlencode "height=800" \
  --data-urlencode "response_type=url")

# 2. On each run, diff the live page against that baseline
curl -G "https://api.snapshotflow.com/diff" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "before=$BASELINE_URL" \
  --data-urlencode "after=https://competitor.com/pricing" \
  --data-urlencode "width=1280" \
  --data-urlencode "height=800" \
  --data-urlencode "threshold=0.2" \
  --data-urlencode "response_type=json"
# {
#   "before": "https://storage.snapshotflow.com/...",
#   "after":  "https://competitor.com/pricing",
#   "width": 1280, "height": 800,
#   "changed_pixels": 9210,
#   "total_pixels":  1024000,
#   "diff_percent":  0.90,
#   "has_changes":   true
# }

/diff accepts exactly six parameters: before, after, width, height, threshold, and response_type. Full reference is in the API docs.

API examples for this use case

In production you normally use three SnapshotFlow calls around your scheduler: one /screenshot call to create or refresh the baseline, one /diff call to compare the baseline with the live page, and one more /screenshot call to promote the new accepted state.

1. Create the first baseline with /screenshot

TARGET_URL="https://competitor.com/pricing"

BASELINE_URL=$(curl -sG "https://api.snapshotflow.com/screenshot" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "url=$TARGET_URL" \
  --data-urlencode "width=1280" \
  --data-urlencode "height=800" \
  --data-urlencode "wait_until=networkidle2" \
  --data-urlencode "response_type=url")

echo "$BASELINE_URL"
# Save this URL in your database, KV store, GitHub Actions variable, or bucket metadata.

2. Compare baseline vs live page with /diff

curl -sG "https://api.snapshotflow.com/diff" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "before=$BASELINE_URL" \
  --data-urlencode "after=$TARGET_URL" \
  --data-urlencode "width=1280" \
  --data-urlencode "height=800" \
  --data-urlencode "threshold=0.2" \
  --data-urlencode "response_type=base64" \
  -o diff.json

jq '{diff_percent, changed_pixels, has_changes}' diff.json
# {
#   "diff_percent": 0.90,
#   "changed_pixels": 9210,
#   "has_changes": true
# }

3. Alert only when the change is meaningful

DIFF=$(jq -r '.diff_percent' diff.json)

if awk "BEGIN { exit !($DIFF > 1.0) }"; then
  jq -r '.image' diff.json | sed 's/^data:image\/png;base64,//' | base64 -d > diff.png
  curl -X POST "$SLACK_WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -d "{\"text\":\"Pricing page changed by ${DIFF}% — review diff.png\"}"
fi

4. Promote the accepted state as the next baseline

if awk "BEGIN { exit !($DIFF > 1.0) }"; then
  NEXT_BASELINE_URL=$(curl -sG "https://api.snapshotflow.com/screenshot" \
    -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
    --data-urlencode "url=$TARGET_URL" \
    --data-urlencode "width=1280" \
    --data-urlencode "height=800" \
    --data-urlencode "response_type=url")

  # Store NEXT_BASELINE_URL as the baseline for the next scheduled run.
fi

Use response_type=json when you only need numbers for automation. Use response_type=base64 when the alert should include the highlighted diff PNG. Use /screenshot?response_type=url when your job needs a stable URL to store as the next baseline.

The rolling-baseline pattern

The trick that makes scheduled monitoring usable is rolling the baseline forward on every confirmed change. Without it, a one-time change keeps re-firing the alert on every run forever. With it, each alert represents a new delta. Use response_type=base64 so a single call returns both the stats and the diff PNG you archive:

# One call returns diff stats AND the highlighted diff image
curl -G "https://api.snapshotflow.com/diff" \
  -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
  --data-urlencode "before=$BASELINE_URL" \
  --data-urlencode "after=$TARGET_URL" \
  --data-urlencode "width=1280" --data-urlencode "height=800" \
  --data-urlencode "threshold=0.2" \
  --data-urlencode "response_type=base64" -o diff.json

DIFF=$(jq -r .diff_percent diff.json)

# Over budget? alert, archive the diff, and promote the new capture to baseline
if awk "BEGIN { exit !($DIFF > 1.0) }"; then
  jq -r '.image' diff.json | sed 's/^data:image\/png;base64,//' | base64 -d > diff.png
  # 1) notify (Slack/email/ticket) with diff.png attached
  # 2) re-capture the target and store the returned URL as the next baseline
  NEXT_BASELINE_URL=$(curl -sG "https://api.snapshotflow.com/screenshot" -H "X-Api-Key: $SNAPSHOTFLOW_KEY" \
    --data-urlencode "url=$TARGET_URL" --data-urlencode "width=1280" --data-urlencode "height=800" \
    --data-urlencode "response_type=url")
  # save NEXT_BASELINE_URL over the old BASELINE_URL in your database / KV / CI variable
fi

Keep the dated diff PNGs and you also get a free visual changelog of the page — handy for "when exactly did they change their pricing?" questions months later.

Keeping false alerts down

A change monitor that cries wolf gets muted within a week. On /diff the single knob you control is threshold (0–1, default 0.1) — it sets how different two pixels must be before they count as changed. Three habits keep the signal clean:

  • Absorb anti-aliasing. Font rendering wobbles by a hair between captures even with identical content. Raise threshold to 0.20.3 so sub-pixel noise stops counting as change.
  • Pin the viewport. Always pass identical width and height on every run so both renders share a viewport and a reflow can't masquerade as a change.
  • Gate on diff_percent, not raw pixels. Alert above a percentage budget (e.g. 1 %). Pages with rotating banners or live counters need a looser budget; static legal or pricing pages can run tight.
Good to know: /diff captures both URLs with default settings and accepts only before, after, width, height, threshold and response_type. So on this endpoint your noise control is threshold plus a viewport pin. If a target is dominated by volatile regions (ad-heavy pages, live tickers), point the monitor at a more stable variant of the URL or raise the budget rather than chasing every flicker.

A drop-in scheduled job (GitHub Actions)

Any scheduler works — plain cron, a serverless cron trigger, or the GitHub Actions schedule below, which runs every hour, alerts when diff_percent exceeds 1 %, and uploads the diff PNG as an artifact.

name: change-detection
on:
  schedule:
    - cron: '0 * * * *'   # hourly
  workflow_dispatch: {}

jobs:
  watch:
    runs-on: ubuntu-latest
    steps:
      - name: Diff live page vs stored baseline
        env:
          KEY: ${{ secrets.SNAPSHOTFLOW_KEY }}
          BASELINE: ${{ vars.BASELINE_URL }}      # e.g. https://my-bucket.r2.dev/baseline/pricing.png
          TARGET: https://competitor.com/pricing
        run: |
          curl -sS -G "https://api.snapshotflow.com/diff" \
            -H "X-Api-Key: $KEY" \
            --data-urlencode "before=$BASELINE" \
            --data-urlencode "after=$TARGET" \
            --data-urlencode "width=1280" --data-urlencode "height=800" \
            --data-urlencode "threshold=0.2" \
            --data-urlencode "response_type=base64" -o diff.json

          jq -r '.image' diff.json | sed 's/^data:image\/png;base64,//' | base64 -d > diff.png
          DIFF=$(jq -r .diff_percent diff.json)
          echo "diff_percent=$DIFF"
          awk "BEGIN { exit !($DIFF > 1.0) }" \
            && echo "::warning::Page changed: $DIFF% — review diff.png and roll baseline" \
            || echo "No meaningful change ($DIFF%)"

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: change-diff
          path: diff.png
          retention-days: 30

Swap the ::warning:: line for a Slack/webhook call and a baseline re-upload to close the loop. Wall time on a warm pool is a few seconds per run.

Tracking competitor and supplier pages

The highest-value version of this loop watches pages you do not own: competitor pricing and feature pages, supplier terms, marketplace listings, status pages, regulatory notices. You cannot add tests to those pages — but you can render and diff them on a schedule like any other URL.

Run one job per watched URL, each with its own baseline file and its own change budget. Keeping them separate makes every alert attributable to a specific page, and lets you tune threshold and the diff_percent gate per target — a busy marketing page can run loose while a pricing or terms page runs tight.

Because /diff renders at the viewport you pin (default 1280 × 800), it watches the part of the page that loads into that frame. For most pricing, terms and status pages the meaningful content sits high on the page, so a single pinned viewport catches the changes that matter without extra moving parts.

Monitoring internal pages (self-host)

For dashboards behind a VPN, staging sites, or data-residency rules, the hosted API cannot reach the URL. Drop the Docker stack on a runner inside your network — the same /screenshot and /diff endpoints answer on localhost, and no traffic leaves your perimeter.

git clone https://github.com/snapshotflow/snapshotflow.git
cd snapshotflow
cp .env.example .env   # set INTERNAL_KEY=...
docker compose up -d

# Same scheduled diff, localhost
curl -G "http://localhost:8080/diff" \
  -H "X-Api-Key: $INTERNAL_KEY" \
  --data-urlencode "before=http://baseline-store/internal-dashboard.png" \
  --data-urlencode "after=https://internal-dashboard.corp/" \
  --data-urlencode "response_type=json"

A dedicated self-hosted-vs-SaaS cost breakdown is on the roadmap; until then the pricing explained guide covers per-shot economics and self-host break-even math.

From an AI agent (MCP)

The same comparison is registered as the visual_diff MCP tool. Point any MCP-aware client (Claude Desktop, Cursor, Goose) at the server and the monitoring loop becomes a natural-language instruction:

{
  "mcpServers": {
    "snapshotflow": {
      "url": "https://api.snapshotflow.com/mcp",
      "headers": { "X-Api-Key": "sk_live_xxxxxxxx" }
    }
  }
}

Ask the agent: "Every morning, compare our competitor's pricing page to yesterday's snapshot and open a Linear ticket if anything changed." The model captures, calls visual_diff, reads diff_percent, and chains into your ticket tool — no bespoke HTTP plumbing on your side.

Why this matters: change detection is a patient, repetitive triage task — exactly where an agent beats a human. It will diff a hundred watched pages at 3 a.m. and only wake you for the one that moved. The endpoint is identical to the cron version; only the caller changes.

Production checklist

  • Pin the viewport. Pass width and height on every capture and diff so a server-side default can never shift your baseline.
  • Roll the baseline forward. After an accepted change, promote the new capture to baseline so you only get alerted about the next delta.
  • Set a change budget. Gate on diff_percent — a flat 1 % is a sane default; tighten to 0.3 % for legal/pricing pages, loosen to 3 % for busy dashboards.
  • Tune threshold for anti-aliasing. Raise it to 0.20.3 so sub-pixel font noise doesn't trip alerts.
  • Archive dated diffs. Keep each diff PNG keyed by date for a visual changelog of the watched page.
  • One job per URL. Separate baselines keep alerts attributable and budgets tunable per page.

FAQ

How is change detection different from uptime monitoring?

Uptime monitoring answers "is the page reachable and returning 200?". Visual change detection answers "did what the page looks like change since last time?". A page can be up all week and still silently change its pricing, hero copy, or layout. Change detection catches that by comparing rendered pixels over time, not HTTP status. For the uptime angle, see Webpage Screenshot API for SaaS.

How is this different from visual regression testing in CI?

Visual regression testing runs before a deploy and compares your build to a known-good baseline inside a pull request. Change detection runs on a schedule against a live URL — often a page you don't control — and the baseline is simply the previous scheduled capture. Same /diff engine, different trigger and intent. The CI version is covered in Visual Regression Testing.

Where do I store the baseline image?

Anywhere /diff can reach by URL — S3, R2, a public bucket, or your own server. The job passes the previous capture's URL as before and the live page as after, then overwrites the baseline with the new capture so comparisons always run against the most recent confirmed state.

How do I keep false alerts down?

On /diff the noise control is threshold. Raise it to 0.2–0.3 to absorb anti-aliasing, gate on diff_percent (for example >1 %) rather than raw pixels, and always pin width and height so both renders share a viewport. /diff captures both URLs with default settings and accepts only before, after, width, height, threshold and response_type.

Can I track a page behind a login or VPN?

Self-host the SnapshotFlow Docker stack inside your network. The same /screenshot and /diff endpoints run on localhost, so a scheduled job can monitor internal dashboards or staging without sending traffic outside your perimeter.

Can an AI agent run the monitoring loop?

Yes. The same operation is exposed as the visual_diff MCP tool at https://api.snapshotflow.com/mcp. An MCP-aware agent can capture a page, compare it to the last baseline, read diff_percent, and decide whether to open a ticket or notify you — without bespoke HTTP plumbing.

Try SnapshotFlow free

200 screenshots free (lifetime), MCP-ready, Docker self-host available. No credit card.