ScreenshotNeo

BlogHow-to

Google Sheets Integration for Website Monitoring and Change Alerts

Use Google Apps Script and Sheets to poll a website, log status or content changes, and email alerts when your monitoring rule changes.

By the ScreenshotNeo team4 October 202610 min read

Google Sheets can act as a lightweight website monitoring log: a time-driven Google Apps Script fetches a URL, records a timestamped observation, compares it with the previous one, and sends an email when the monitored condition changes. This is scheduled polling, so alerts arrive after a check runs; spreadsheet edit triggers do not watch external websites.

1. How the monitoring workflow works

  1. Put each monitored URL and its rule in a configuration sheet.
  2. Run an Apps Script function on a time-driven trigger.
  3. Fetch the URL with UrlFetchApp and inspect its HTTP response.
  4. Extract a stable value, append it to a history tab, and compare it with the last observation.
  5. Send an email on a transition, and optionally send a recovery notice.

Apps Script time-driven triggers can run as often as every minute, but the actual start time can be randomized. A scheduled check is not a real-time guarantee. Google’s installable trigger documentation describes the schedule behavior. The Apps Script URL Fetch service makes HTTP and HTTPS requests and exposes status codes and response content; see its reference.

2. Create the spreadsheet

Create a Google Sheet with two tabs. Name the first Targets and set row 1 to these headers:

URL | Mode | Match text | Alert email | Enabled

Example row: https://status.example.com/health | status | | you@example.com | TRUE. For a content rule, use text in Mode and put a stable phrase in Match text. Only use URLs you are permitted to monitor. Some sites require JavaScript rendering or block automated requests; Apps Script fetches the HTTP response and does not render a browser page.

Create a second tab named History with these headers:

Checked at | URL | HTTP status | Response ms | State | Changed

3. Add the Apps Script

In the Sheet, open Extensions → Apps Script, replace the editor contents with the code below, and save. It checks every enabled target, records each successful HTTP exchange (including HTTP error statuses), and sends an email when the state changes. Network exceptions are recorded as FETCH_ERROR; their description is kept in the state field.

const TARGETS_SHEET = 'Targets';
const HISTORY_SHEET = 'History';
const STATE_PREFIX = 'monitor_state:';

function checkWebsites() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const targets = ss.getSheetByName(TARGETS_SHEET);
  const history = ss.getSheetByName(HISTORY_SHEET);
  if (!targets || !history) throw new Error('Create Targets and History tabs first.');

  const rows = targets.getDataRange().getValues();
  if (rows.length < 2) return;
  const headers = rows[0].map(String);
  const col = name => headers.indexOf(name);
  for (const name of ['URL', 'Mode', 'Match text', 'Alert email', 'Enabled']) {
    if (col(name) < 0) throw new Error('Missing Targets header: ' + name);
  }

  const props = PropertiesService.getScriptProperties();
  const historyRows = [];
  for (const row of rows.slice(1)) {
    const url = String(row[col('URL')] || '').trim();
    const enabled = String(row[col('Enabled')]).toLowerCase() === 'true';
    if (!enabled || !url) continue;

    const mode = String(row[col('Mode')] || 'status').trim().toLowerCase();
    const matchText = String(row[col('Match text')] || '');
    const email = String(row[col('Alert email')] || '').trim();
    const started = Date.now();
    let status = '';
    let state;
    try {
      const response = UrlFetchApp.fetch(url, {
        method: 'get',
        followRedirects: true,
        muteHttpExceptions: true,
        headers: { 'User-Agent': 'AppsScriptWebsiteMonitor/1.0' }
      });
      status = response.getResponseCode();
      const body = response.getContentText();
      if (mode === 'text') {
        state = body.includes(matchText) ? 'TEXT_PRESENT' : 'TEXT_MISSING';
      } else if (mode === 'status') {
        state = status >= 200 && status < 400 ? 'UP' : 'HTTP_' + status;
      } else {
        state = 'CONFIG_ERROR: unsupported mode ' + mode;
      }
    } catch (err) {
      state = 'FETCH_ERROR: ' + String(err).slice(0, 300);
    }

    const oldState = props.getProperty(STATE_PREFIX + url);
    const changed = oldState !== null && oldState !== state;
    historyRows.push([new Date(), url, status, Date.now() - started, state, changed]);

    // Save state for the next poll. The first run establishes a baseline without alerting.
    props.setProperty(STATE_PREFIX + url, state);
    if (changed && email) {
      MailApp.sendEmail({
        to: email,
        subject: 'Website monitor changed: ' + url,
        body: 'The monitored state changed.\n\nURL: ' + url +
          '\nPrevious: ' + oldState + '\nCurrent: ' + state +
          '\nHTTP status: ' + status + '\nChecked: ' + new Date().toISOString()
      });
    }
  }

  if (historyRows.length) {
    history.getRange(history.getLastRow() + 1, 1, historyRows.length, historyRows[0].length)
      .setValues(historyRows);
  }
}

function createHourlyTrigger() {
  // Run this manually once. Remove duplicate triggers before creating another.
  ScriptApp.newTrigger('checkWebsites').timeBased().everyHours(1).create();
}

function removeMonitorTriggers() {
  ScriptApp.getProjectTriggers().forEach(trigger => {
    if (trigger.getHandlerFunction() === 'checkWebsites') {
      ScriptApp.deleteTrigger(trigger);
    }
  });
}

The script treats HTTP 2xx and 3xx as UP; change the rule if redirects should count as unhealthy. In text mode, it checks whether the exact phrase appears in the returned response body. The first poll stores a baseline without sending mail. State is keyed by URL in Script Properties, so keep URLs unique in Targets; if you need different rules for the same URL, give each target a separate stable identifier and use it as the property key.

4. Authorize it and schedule the poll

  1. Choose checkWebsites in the Apps Script function selector and click Run. Review the requested permissions for external requests, spreadsheet access, and email, then authorize with the account that should own the monitor.
  2. Inspect the History tab and confirm the result for one target. A timeout or blocked request will usually appear as FETCH_ERROR; a server response such as 404 appears as HTTP_404.
  3. Run createHourlyTrigger once and authorize it. In Apps Script, open Triggers to review the installed trigger. Do not run the setup function repeatedly unless you first delete duplicate triggers.
  4. Keep ownership clear. Installable triggers execute with the authorization of the account that created them. That account must retain access and authorization.

Google documents that script executions and API requests do not themselves invoke installable triggers. An edit to the spreadsheet is not what runs this polling job. Trigger ownership and restrictions are documented by Google.

5. Choose a useful monitoring rule

Availability

For a health endpoint, record the status code and optionally response time. A 200 response proves only that the server returned that status; if the endpoint returns a structured body, check a meaningful field as well.

Page or content change

Compare a short, stable phrase or extracted value. Whole-page comparison is usually noisy because pages can include rotating promotions, timestamps, personalized content, and other routine changes. The sample uses literal text matching for clarity; use a parser or a dedicated endpoint when markup changes frequently.

Failure and recovery policy

For business-critical checks, decide how many consecutive failed observations should trigger an alert, whether recovery should send a separate message, and who receives escalation. The sample alerts on every state transition, which can produce repeated notices if a target flaps between states. A production variation should track a failure count and alert only after a chosen threshold.

6. Improve the script for a real monitor

  • Validate configuration: check that each URL is HTTP or HTTPS, mode is supported, and text mode has a non-empty match value. Avoid accepting arbitrary values from untrusted editors.
  • Reduce noisy alerts: add a consecutive-failure counter, a cooldown timestamp, and a recovery message. Store these separately per target.
  • Prevent overlapping runs: use LockService around the check if one run might still be working when the next starts.
  • Bound work: keep target counts and response bodies small, and consider truncating content inspection to what the rule needs. Apps Script executions and service quotas apply; consult current Apps Script quotas before depending on a particular schedule or volume.
  • Log operational errors: review Apps Script Executions and trigger failure notifications. A row in History cannot be appended when the script fails before it reaches the write step, so script execution history matters too.
  • Protect sensitive data: do not store passwords, private tokens, or personal data in broadly shared tabs. If a site requires authentication, use carefully scoped credentials and do not email secrets.
  • Account for execution identity: the request originates from Google’s URL Fetch infrastructure, not a browser at your desk. The target can reject that traffic or return different content.

7. cURL, Python, and Node.js equivalents

These standalone snippets show the same basic HTTP poll outside Sheets. They do not create schedules or send alerts by themselves; run them from an appropriate scheduler and persist the last state to implement change detection.

cURL

curl -sS -o /tmp/site.html -w 'HTTP %{http_code} in %{time_total}s\n' https://example.com/

Python

import time
import requests

url = 'https://example.com/'
started = time.monotonic()
r = requests.get(url, timeout=30)
print({'status': r.status_code, 'seconds': time.monotonic() - started,
       'phrase_present': 'Expected phrase' in r.text})

Node.js

const url = 'https://example.com/';
const started = Date.now();
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
const body = await res.text();
console.log({ status: res.status, milliseconds: Date.now() - started,
              phrasePresent: body.includes('Expected phrase') });

8. Troubleshooting

Symptom Likely cause Fix
No checks run No installable trigger, a disabled trigger, or an account/authorization issue. Run the function manually, inspect Apps Script Triggers and Executions, and recreate the trigger under an account that will remain available.
Authorization required The owner has not granted a service scope, or authorization was revoked. Run the function interactively and complete the authorization flow. Reauthorize after changing services or scopes.
HTTP 403, 429, or unusual HTML The website limits automated requests, rate limits, or serves a challenge page. Check the site’s published access rules, reduce frequency, or use an approved monitoring method. Do not interpret a challenge page as normal content.
Fetch exception or timeout DNS, TLS, connection, endpoint, or service quota issue. Verify the URL and endpoint independently, check Apps Script Executions, and retry with a less frequent schedule. Treat the failed observation distinctly from a confirmed site outage.
False content-change alerts The selected page text is dynamic or too broad. Monitor a stable fragment, structured health endpoint, or value specific to the condition that matters.
Mail not received Empty/incorrect recipient, mail quota, or a condition that did not transition. Check the Alert email cell, inspect execution logs and mail quotas, and remember the initial check only establishes the baseline.
Duplicate alert messages Multiple triggers or a flapping target. Remove duplicate triggers with removeMonitorTriggers and add a consecutive-failure threshold or cooldown.
Changes in the Sheet do not start a check Spreadsheet triggers respond to spreadsheet events, not external page changes; script/API writes do not invoke them. Use the time-driven trigger for polling. Google notes that simple onEdit(e) runs when a user changes a spreadsheet value; see simple trigger behavior.

9. Performance, reliability, and cost

For a small list, sequential requests are easy to reason about and keep writes straightforward. Runtime grows with the number of targets and their response times. For many independent URLs, Apps Script offers UrlFetchApp.fetchAll, but batching requests does not remove execution quotas, target rate limits, or the need to handle each response separately. Batch spreadsheet writes, as the sample does, rather than writing cells one at a time.

Polling detects changes only on a later successful run. Google says time-driven triggers can run as frequently as every minute, with timing caveats; design for delayed checks and transient failures rather than promising exact alert latency. The workflow has no per-check vendor fee in this example, but Apps Script has quotas and ongoing ownership costs: someone must maintain authorization, rules, recipient addresses, and failure handling. Google Cloud Monitoring documents managed notification channels and alerting, including constraints for webhook endpoints, but it is a separate monitoring approach rather than a verified direct Sheets integration. See Google Cloud Monitoring notification options.

10. Or skip the browser setup

If your check depends on what a rendered page looks like, Apps Script URL Fetch is not a browser. ScreenshotNeo is a website screenshot API and MCP server: it can return a page screenshot or PDF, with options such as full-page capture, selector capture, waits, custom headers, cookies, and JavaScript. Its consent handling removes supported cookie banners, newsletter popups, and chat widgets before capture, and those steps can be turned off. Only clean shots are billed: bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, with verdict and billing details in response headers. Its MCP tools let AI agents take screenshots, get page information, and capture PDFs. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

One request returns the screenshot; use the image response in your own workflow. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

11. FAQ

Can Google Sheets detect a website change by itself?

No. A script or external monitor must fetch or otherwise observe the page. Sheets stores the observations and can help drive the workflow.

Does this check JavaScript-rendered content?

No. UrlFetchApp fetches the HTTP response body; it does not run the page in a browser. Monitor a server-rendered endpoint or use a browser-based capture method when rendered content is essential.

Will alerts arrive at an exact time?

No. Time-driven triggers are scheduled polling, and their execution time may vary. Treat the configured interval as an intended cadence, not a delivery deadline.

Can this monitor hundreds of sites?

It may be possible to fetch many targets, but execution quotas, run duration, mail limits, target policies, and the operational burden determine whether this setup is suitable. Review current Apps Script quotas before scaling.

What is the difference between HTTP status and page correctness?

Status records the server response class. It does not prove that the returned content is the expected content, so use a specific body or endpoint check when correctness matters.