Dan Antony

Google Ads Script to Check Landing Pages for 404s

PPC & Paid Search·Google Ads Script·Google Ads

What it does

Iterates over all enabled ads in enabled ad groups and campaigns, fetches each final URL (and mobile final URL if configured), and checks 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 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 when landing pages change or are retired, after a site migration or redesign, when you suspect broken final URLs are hurting Quality Score and conversion rate, or as a scheduled weekly health check so broken landing pages are caught before they waste spend.

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 daily or weekly schedule (clock icon on the script). Weekly is usually enough unless landing pages change often.

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.