PPC & Paid Search

Google Ads Script to Check Landing Pages for 404s

A broken-link checker and 404 alert system built for Google Ads — scans every enabled ad's final and mobile URLs on your schedule, labels broken landing pages, logs the details, and emails you an alert the moment a landing page breaks.

  • Google Ads
What it does

What it does

Works as a scheduled broken link checker across all enabled ads in enabled ad groups and campaigns, fetching each final URL (and mobile final URL if configured) and checking the HTTP response code. Any URL returning a 404 is logged with its campaign, ad group, ad ID and URL, labelled with a configurable label (LP_404), and collected into an HTML email report that's sent to your configured recipients.

The code

google-ads-404-checker.js
/**
 * Google Ads Script: 404 Checker with Email Alerts
 * Scans all ENABLED ads in ENABLED ad groups and campaigns.
 * Flags 404 on final URLs, labels them, logs them, and sends an email summary.
 */

var CONFIG = {
  includeMobileFinalUrl: true,
  labelName404: "LP_404",
  userAgent: "Mozilla/5.0 (Google Ads 404 Checker)",
  fetchTimeoutMs: 10000,
  throttleMsBetweenFetches: 75,
  maxAdsToProcess: 5000,
  // Set to a URL to test one-off, or leave empty to skip the debug fetch.
  DEBUG_TEST_URL: "", // e.g. "https://example.com/definitely-not-found-404"

  // --- EMAIL SETTINGS ---
  emailRecipients: [
    "you@example.com"  // <-- replace with your notification email(s)
  ],
  emailSubject: "Google Ads 404 Alert - Broken Landing Pages Detected",
  emailSenderName: "Google Ads 404 Checker"
};

function main() {
  var start = new Date();
  Logger.log("=== 404 CHECKER START === " + start.toISOString());

  ensureLabel(CONFIG.labelName404);

  var processed = 0;
  var hits404 = 0;
  var hitDetails = [];

  try {
    // Debug test URL
    if (CONFIG.DEBUG_TEST_URL) {
      var testStatus = getStatus(CONFIG.DEBUG_TEST_URL);
      Logger.log("[DEBUG] Fetched " + CONFIG.DEBUG_TEST_URL + " -> " + testStatus);
    }

    // Select all enabled ads in enabled ad groups & campaigns
    var adSelector = AdsApp.ads()
      .withCondition("CampaignStatus = ENABLED")
      .withCondition("AdGroupStatus = ENABLED")
      .withCondition("Status = ENABLED")
      .forDateRange("ALL_TIME")
      .orderBy("CampaignName ASC")
      .orderBy("AdGroupName ASC")
      .orderBy("Id ASC")
      .withLimit(CONFIG.maxAdsToProcess);

    var it = adSelector.get();

    while (it.hasNext()) {
      var ad = it.next();

      var finalUrl = safeGetFinalUrl(ad);
      var mobileUrl = CONFIG.includeMobileFinalUrl ? safeGetMobileUrl(ad) : null;

      // FINAL URL
      if (finalUrl) {
        var status = getStatus(finalUrl);
        if (status === 404) {
          hits404++;
          hitDetails.push(recordHit(ad, "FINAL", finalUrl, status));
          label404(ad);
        }
        politeSleep();
      }

      // MOBILE URL (if present and different)
      if (mobileUrl && mobileUrl !== finalUrl) {
        var mStatus = getStatus(mobileUrl);
        if (mStatus === 404) {
          hits404++;
          hitDetails.push(recordHit(ad, "MOBILE", mobileUrl, mStatus));
          label404(ad);
        }
        politeSleep();
      }

      processed++;
    }

  } catch (e) {
    Logger.log("!! ERROR: " + e + "\n" + (e && e.stack ? e.stack : ""));
  } finally {
    logSummary(processed, hits404, start);

    // Send email if 404s were found
    if (hits404 > 0) {
      sendEmailReport(hitDetails, processed, hits404, start);
    }
  }
}

/************** Helper Functions **************/

function ensureLabel(name) {
  var it = AdsApp.labels().withCondition('Name = "' + name + '"').get();
  if (!it.hasNext()) AdsApp.createLabel(name);
}

function safeGetFinalUrl(ad) {
  try { return ad.urls().getFinalUrl(); } catch (e) { return null; }
}

function safeGetMobileUrl(ad) {
  try { return ad.urls().getMobileFinalUrl(); } catch (e) { return null; }
}

function getStatus(url) {
  try {
    var resp = UrlFetchApp.fetch(url, {
      method: "get",
      followRedirects: true,
      muteHttpExceptions: true,
      validateHttpsCertificates: true,
      headers: { "User-Agent": CONFIG.userAgent },
      timeout: CONFIG.fetchTimeoutMs
    });
    return resp.getResponseCode();
  } catch (e) {
    Logger.log("Fetch error for " + url + " -> " + e);
    return null;
  }
}

function label404(ad) {
  try { ad.applyLabel(CONFIG.labelName404); } catch (e) {}
}

function recordHit(ad, type, url, status) {
  var campaign = ad.getCampaign().getName();
  var adGroup = ad.getAdGroup().getName();
  var adId = ad.getId();
  var adType = "";
  try { adType = ad.getType(); } catch (e) {}
  var msg = "[404] " + type + " | Campaign: " + campaign +
            " | AdGroup: " + adGroup +
            " | AdID: " + adId +
            " | Type: " + adType +
            " | URL: " + url +
            " | Status: " + status;
  Logger.log(msg);
  return { campaign: campaign, adGroup: adGroup, adId: adId, type: type, url: url, status: status };
}

function politeSleep() {
  if (CONFIG.throttleMsBetweenFetches > 0) Utilities.sleep(CONFIG.throttleMsBetweenFetches);
}

function logSummary(processed, hits, start) {
  var end = new Date();
  Logger.log("=== 404 CHECKER END === " + end.toISOString());
  Logger.log("Runtime (s): " + Math.round((end - start) / 1000));
  Logger.log("Ads processed: " + processed);
  Logger.log("404 hits: " + hits);
  Logger.log("Label: " + CONFIG.labelName404);
}

/************** EMAIL REPORT **************/

function sendEmailReport(hitDetails, processed, hits404, start) {
  var end = new Date();
  var runtime = Math.round((end - start) / 1000);

  var html = `
    <h2>Google Ads 404 Checker Report</h2>
    <p><strong>Date:</strong> ${start.toISOString()}</p>
    <p><strong>Runtime:</strong> ${runtime} seconds</p>
    <p><strong>Total Ads Processed:</strong> ${processed}</p>
    <p><strong>404 Errors Found:</strong> ${hits404}</p>
    <hr>
    <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;">
      <tr><th>Campaign</th><th>Ad Group</th><th>Ad ID</th><th>Type</th><th>Status</th><th>URL</th></tr>
      ${hitDetails.map(function(h) {
        return `<tr>
          <td>${h.campaign}</td>
          <td>${h.adGroup}</td>
          <td>${h.adId}</td>
          <td>${h.type}</td>
          <td>${h.status}</td>
          <td><a href="${h.url}" target="_blank">${h.url}</a></td>
        </tr>`;
      }).join("")}
    </table>
    <p>All affected ads have been labeled <strong>${CONFIG.labelName404}</strong>.</p>
  `;

  MailApp.sendEmail({
    to: CONFIG.emailRecipients.join(","),
    subject: CONFIG.emailSubject,
    htmlBody: html,
    name: CONFIG.emailSenderName
  });

  Logger.log("Email sent to: " + CONFIG.emailRecipients.join(", "));
}

When to use this

Run this as a scheduled alert when you can't watch landing pages manually — after migrations, redesigns, or whenever pages change. Schedule it daily during a migration window so you hear about breakage within a day, weekly as a standing health check. Broken final URLs burn spend and hurt Quality Score silently; this catches them before they do.

Prerequisites

  • Script access at the account or MCC level (Tools & Settings > Bulk Actions > Scripts).
  • Ability to authorize MailApp for the notification email.
  • Set CONFIG.emailRecipients to your email(s).

How to implement

  1. 1

    Create a new script

    Tools & Settings > Bulk Actions > Scripts > + New Script. Paste the full file.

  2. 2

    Configure the CONFIG block

    Set CONFIG.emailRecipients to your notification email(s). Optionally set a DEBUG_TEST_URL to verify it catches a 404 before running for real.

  3. 3

    Run once to test

    Run the script manually and check the Logs (View > Logs) for the summary and any 404 hits. Confirm the email arrives.

  4. 4

    Schedule it

    Set a schedule with the clock icon on the script. Daily while a migration or redesign is live — you get an email alert only when something's actually broken. Weekly is enough for stable accounts.

Caveats

Checks the final URL response code, so a page that redirects to a 404 will be caught, but a soft-404 page (returns 200 with 'not found' content) will not be flagged. Fetching thousands of URLs on a large account can take a while and count against Google's execution quota, so maxAdsToProcess is capped at 5000 by default. Mobile final URLs are only checked when they differ from the desktop final URL.

PPC & Paid Search
Say hi?

Questions, ideas, or need help with something?

Start a conversation