The Scripts Desk
Disapproval Sentinel: know the morning an ad gets knocked back
A read-only Google Ads Script that checks every serving ad for a disapproval or a limited-serving status each day, lists the policy topics behind each one, and optionally emails you the digest.
A disapproved ad does not announce itself. It quietly stops serving, or serves to a fraction of the queries it should, and the first sign is often a dip in impressions that you notice days later while looking for something else. By then the ad has been dark for a week. The information was there the whole time, sitting in the policy column nobody opens every morning.
This script opens that column for you. It reads every ad that is not removed, keeps the ones whose approval status is disapproved or limited, and lists the policy topics attached to each. It logs the digest, and if you give it an email address it sends the same digest to you. It touches nothing: no ads are edited, resubmitted or appealed. It is a smoke alarm, not a firefighter.
Before you start
- You need access to the Google Ads account and permission to authorise scripts.
- The email is optional. With no address set, the script writes only to the script log.
- "Limited" is not the same as "disapproved": a limited ad still serves, just not everywhere. The script reports both because both cost you reach, but read the status column to tell them apart.
The script
/**
* Disapproval Sentinel
* The SEM Dispatch Vault, semdispatch.com/vault
*
* Lists every ad whose policy approval status is disapproved or limited,
* with the campaign, ad group and policy topics behind it, and optionally
* emails the digest. Read-only: this script never edits or resubmits ads.
*/
var CONFIG = {
// Optional. An email address for the daily digest, e.g. "you@example.com".
// Leave empty to log only.
EMAIL: "",
// Send an email even when nothing is wrong, as a daily "all clear".
// Leave false to email only when there is something to report.
ALWAYS_EMAIL: false
};
function main() {
var query =
"SELECT campaign.name, ad_group.name, ad_group_ad.ad.id, " +
"ad_group_ad.policy_summary.approval_status, " +
"ad_group_ad.policy_summary.policy_topic_entries " +
"FROM ad_group_ad " +
"WHERE ad_group_ad.policy_summary.approval_status " +
"IN ('DISAPPROVED', 'APPROVED_LIMITED', 'AREA_OF_INTEREST_ONLY') " +
"AND ad_group_ad.status != 'REMOVED'";
var rows = AdsApp.report(query).rows();
var offenders = [];
while (rows.hasNext()) {
var row = rows.next();
var status = row["ad_group_ad.policy_summary.approval_status"];
offenders.push({
campaign: row["campaign.name"],
adGroup: row["ad_group.name"],
adId: row["ad_group_ad.ad.id"],
status: status === "DISAPPROVED" ? "DISAPPROVED" : "LIMITED",
topics: describeTopics(
row["ad_group_ad.policy_summary.policy_topic_entries"]
)
});
}
var account = AdsApp.currentAccount();
var lines = [
"Disapproval Sentinel, " + account.getName(),
"Checked at " +
Utilities.formatDate(new Date(), account.getTimeZone(), "yyyy-MM-dd HH:mm")
];
if (offenders.length === 0) {
lines.push("");
lines.push("All clear: no disapproved or limited ads.");
} else {
lines.push("");
lines.push(offenders.length + " ad(s) need attention:");
for (var i = 0; i < offenders.length; i++) {
var o = offenders[i];
lines.push("");
lines.push((i + 1) + ". [" + o.status + "] ad " + o.adId);
lines.push(" Campaign: " + o.campaign);
lines.push(" Ad group: " + o.adGroup);
lines.push(" Policy topics: " + o.topics);
}
}
var report = lines.join("\n");
Logger.log(report);
var shouldEmail =
CONFIG.EMAIL !== "" && (offenders.length > 0 || CONFIG.ALWAYS_EMAIL);
if (shouldEmail) {
var subject =
"[Policy] " + account.getName() + ": " +
(offenders.length > 0
? offenders.length + " ad(s) disapproved or limited"
: "all clear");
MailApp.sendEmail(CONFIG.EMAIL, subject, report);
}
}
/**
* The policy_topic_entries field comes back as a repeated message. Across
* accounts it may arrive as an array of objects or as a JSON string, so we
* read it defensively and fall back to the raw value rather than guessing.
*/
function describeTopics(raw) {
if (!raw) return "none reported";
var entries = raw;
if (typeof raw === "string") {
try {
entries = JSON.parse(raw);
} catch (e) {
return raw;
}
}
if (!entries || typeof entries.length !== "number" || entries.length === 0) {
return "none reported";
}
var names = [];
for (var i = 0; i < entries.length; i++) {
var entry = entries[i] || {};
var topic = entry.topic || entry.policyTopic || "policy";
var type = entry.type || entry.policyTopicType;
names.push(type ? topic + " (" + type + ")" : topic);
}
return names.join("; ");
}
Set it up
- In Google Ads, open Tools, then Bulk actions, then Scripts.
- Create a new script, delete the placeholder, and paste the code above.
- Set
EMAILif you want the digest sent, or leave it empty to log only. - Authorise the script when prompted, then use Preview and read the log.
- Schedule it daily, early, so a knock-back is on your desk the morning it happens rather than the week after.
How to read the digest
Group the fixes by policy topic, not by ad. A run that flags eight ads under the same topic usually points at one shared cause: a landing page claim, a trademark term, a restricted phrase in a shared asset, and fixing that one thing clears the lot. A limited ad (shown as LIMITED) is still earning, so a disapproved ad in a live campaign is the more urgent line to read first.
Two honest limitations. First, the policy topic detail is the one soft spot: Google returns it as a nested repeated field, and different accounts and API versions surface it as an array or as a JSON string, so the script reads it defensively and, when it cannot parse the shape, prints the raw value rather than inventing a tidy label. You always get the campaign, ad group, ad id and status cleanly; treat the topic string as a lead to the exact reason in the interface. Second, this reports status only; it never appeals or resubmits, because an automated resubmission of a genuinely non-compliant ad is how accounts get suspended. The fix stays a human decision.