★ Community Edition ★Price: Free



The Scripts Desk

Final URL Health Checker: catch the broken landing pages before your spend does

A read-only Google Ads Script that gathers the final URLs from your enabled keywords and ads, requests each one, and flags any answering 4xx or 5xx into a Google Sheet and optional email.

You can be paying full price for a page that no longer exists. A product goes out of stock and its URL 404s, a CMS migration quietly changes a path, a server hiccups under load: the ad keeps serving, the click keeps costing, and the visitor lands on an error. Google's own broken-URL checks are periodic and easy to miss, and by the time a disapproval catches up you have bought a stack of clicks that went nowhere.

This script does the plain, boring check that pays for itself. It collects the final URLs from your enabled keywords and enabled ads, removes the duplicates, and requests each one, following redirects and recording the status code it gets back. Anything that answers 4xx or 5xx is written to a Google Sheet tab, and emailed if you set an address. It fetches pages and reports; it changes nothing in the account and pauses nothing.

Before you start

  • You need access to the Google Ads account and permission to authorise scripts.
  • The Sheet is optional to pre-create. Leave SHEET_URL empty and the script makes a new spreadsheet on the first run and logs its URL; paste that URL back in to keep appending tabs.
  • MAX_URLS caps how many pages a single run fetches (default 400). This is a real limit, not a suggestion: URL fetching draws on a daily quota (see the limitation below), so on a large account you check a capped slice per run rather than everything at once.
  • A URL answering 200 is not proof the page is right, only that the server replied. This catches broken pages, not wrong ones.

The script

JavaScript
/**
 * Final URL Health Checker
 * The SEM Dispatch Vault, semdispatch.com/vault
 *
 * Collects final URLs from enabled keywords and ads, de-duplicates them,
 * requests each one, and flags any 4xx/5xx into a Google Sheet and an
 * optional email. Read-only: this script never changes the account.
 */

var CONFIG = {
  // URL of a Google Sheet to write to. Leave empty to create a new one
  // on the first run; its URL is logged so you can paste it back here.
  SHEET_URL: "",

  // Maximum URLs to fetch per run. Keeps a single run inside the daily
  // URL-fetch quota; the rest are checked on the next run.
  MAX_URLS: 400,

  // Optional. An email address for the broken-URL digest.
  // Leave empty to log only.
  EMAIL: ""
};

function main() {
  var urls = collectUrls();
  Logger.log("Collected " + urls.length + " distinct final URL(s).");

  var toCheck = urls.slice(0, CONFIG.MAX_URLS);
  if (urls.length > CONFIG.MAX_URLS) {
    Logger.log(
      "Capped at " + CONFIG.MAX_URLS + " this run; " +
        (urls.length - CONFIG.MAX_URLS) + " left for next time."
    );
  }

  var broken = [];
  for (var i = 0; i < toCheck.length; i++) {
    var result = checkUrl(toCheck[i]);
    if (result.broken) broken.push(result);
  }

  Logger.log("Checked " + toCheck.length + ", " + broken.length + " broken.");
  for (var b = 0; b < broken.length; b++) {
    Logger.log("  " + broken[b].status + "  " + broken[b].url);
  }

  writeSheet(broken, toCheck.length);
  maybeEmail(broken, toCheck.length);
}

function collectUrls() {
  var seen = {};
  var urls = [];

  function add(url) {
    if (!url) return;
    if (seen[url]) return;
    seen[url] = true;
    urls.push(url);
  }

  // Enabled keywords in enabled ad groups and campaigns.
  var keywords = AdsApp.keywords()
    .withCondition("Status = ENABLED")
    .withCondition("CampaignStatus = ENABLED")
    .withCondition("AdGroupStatus = ENABLED")
    .get();
  while (keywords.hasNext()) {
    add(keywords.next().urls().getFinalUrl());
  }

  // Enabled ads in enabled ad groups and campaigns.
  var ads = AdsApp.ads()
    .withCondition("Status = ENABLED")
    .withCondition("CampaignStatus = ENABLED")
    .withCondition("AdGroupStatus = ENABLED")
    .get();
  while (ads.hasNext()) {
    add(ads.next().urls().getFinalUrl());
  }

  return urls;
}

function checkUrl(url) {
  try {
    var response = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      followRedirects: true,
      validateHttpsCertificates: true
    });
    var status = response.getResponseCode();
    return {
      url: url,
      status: status,
      broken: status >= 400
    };
  } catch (e) {
    // A fetch that throws (bad host, timeout, refused connection) is as
    // broken to a visitor as a 5xx, so record it rather than skip it.
    return {
      url: url,
      status: "ERROR: " + e.message,
      broken: true
    };
  }
}

function writeSheet(broken, checkedCount) {
  var ss;
  if (CONFIG.SHEET_URL) {
    ss = SpreadsheetApp.openByUrl(CONFIG.SHEET_URL);
  } else {
    ss = SpreadsheetApp.create("SEM Dispatch: Final URL Health");
    Logger.log("Created a new sheet. Paste this URL into CONFIG.SHEET_URL:");
    Logger.log(ss.getUrl());
  }

  var stamp = Utilities.formatDate(
    new Date(),
    AdsApp.currentAccount().getTimeZone(),
    "yyyy-MM-dd HHmm"
  );
  var sheet = ss.insertSheet("broken " + stamp);

  var header = ["Status", "Final URL"];
  var data = [header];
  for (var i = 0; i < broken.length; i++) {
    data.push([broken[i].status, broken[i].url]);
  }
  if (broken.length === 0) {
    data.push(["OK", "No broken URLs found in " + checkedCount + " checked."]);
  }

  sheet.getRange(1, 1, data.length, header.length).setValues(data);
  sheet.getRange(1, 1, 1, header.length).setFontWeight("bold");
  sheet.setFrozenRows(1);

  Logger.log("Wrote " + broken.length + " broken URL(s) to the Sheet.");
}

function maybeEmail(broken, checkedCount) {
  if (CONFIG.EMAIL === "" || broken.length === 0) return;

  var account = AdsApp.currentAccount();
  var lines = [
    "Final URL Health Checker, " + account.getName(),
    checkedCount + " URLs checked, " + broken.length + " broken:",
    ""
  ];
  for (var i = 0; i < broken.length; i++) {
    lines.push(broken[i].status + "  " + broken[i].url);
  }

  MailApp.sendEmail(
    CONFIG.EMAIL,
    "[URLs] " + account.getName() + ": " + broken.length + " broken landing page(s)",
    lines.join("\n")
  );
}

Set it up

  1. In Google Ads, open Tools, then Bulk actions, then Scripts.
  2. Create a new script, delete the placeholder, and paste the code above.
  3. Leave SHEET_URL empty for the first run, or paste in a Sheet URL you have already made. Set EMAIL if you want the digest.
  4. Authorise the script when prompted (it will ask for external request and spreadsheet access), then use Preview and read the log.
  5. If it created a Sheet, copy the logged URL into SHEET_URL. Schedule it daily.

How to read the results

Read the status code, not just the fact of a flag. A 404 or 410 is a page that is genuinely gone and needs the keyword or ad repointed. A 500 or 503 can be a momentary server wobble, so a URL that flagged 503 today and answers 200 tomorrow was probably a blip, not a dead page: re-run before you rewrite anything. An ERROR: row is a request that never completed at all (a bad host, a refused connection, a timeout), which to a visitor is just as broken as a 5xx.

Two honest limitations. First, UrlFetchApp draws on a daily quota that varies by account and is not guaranteed to be large; a big account can exhaust it, which is exactly why MAX_URLS caps each run at a slice rather than promising to check everything every day. If you have thousands of URLs, expect to cover them across several scheduled runs, and do not read a single clean run as "every page is fine". Second, this checks final URLs only, not tracking templates, suffixes or the redirect chains a click actually travels through, and a page that returns 200 can still be the wrong page, out of stock, or slow. It proves the server answered, nothing more.

  • Landing Pages
  • Broken URLs
  • Alerts