Account budget alerts in Google Ads Scripts

Stop unexpected downtime in your Google Ads campaigns with these account budget alerts in Google Ads Scripts.

The problem

Your Google Ads campaigns stop running when an account budget goes missing, spends out, or drops to a few cents.

Nothing warns you. The account keeps looking healthy in the interface right up until the day it stops serving, and the first sign is usually a client asking why their leads dried up over the weekend.

It is worse across an MCC. Nobody checks thirty accounts every morning, and the one you forget is the one that runs out.

Need a hand?

If you need help with something similar to this blog post, then get in touch through my contact page.

Get in touch

The solution

This script emails an alert for every account in your MCC where the budget is missing, spent out, or past a threshold you set.

It runs at the MCC level, checks only the accounts you have labelled as live, and sends one digest email instead of thirty separate ones.

What you need first

  1. An MCC. The script runs from the manager account, not from inside each client account.
  2. Accounts on monthly invoicing. Account budgets — budget orders, in the API — only exist on invoiced accounts. Accounts paying by credit card have none, so the script reports every one of them as missing a budget. More on that in the closing comments.
  3. An account label. Mine is called Live. Apply it in the MCC to every account you actually want alerts for, so paused and archived accounts stay quiet.

To add the label, go to the accounts list in your MCC, tick the accounts you care about, and use the label button above the table.

The script

At the MCC level, go to Tools and settings > Bulk actions > Scripts, then push the plus (+) button to create a new script. Here are Google’s instructions for adding a new script to the MCC.

Paste this in and set the four configuration variables at the top.

// CONFIG - EDIT THESE //

// The MCC account label applied to the accounts you want checked.
const LIVE_ACCOUNT_LABEL = "Live";

// Where the digest goes. Comma separate several addresses.
const ALERT_EMAIL = "[email protected]";

// Alert once this much of the budget is gone. 0.95 is 95%.
const SPEND_THRESHOLD = 0.95;

// Alert once this little is left, in the account's own currency.
// Catches the large budget that is only 80% spent but has two days to run.
const REMAINING_SPEND_THRESHOLD = 100;

// Email even when everything is fine, so you know the script still runs.
const SEND_ALL_CLEAR = false;

// DO NOT TOUCH THE REST UNLESS YOU KNOW WHAT YOU ARE DOING //

const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;

function main() {
  const labels = MccApp.accountLabels()
    .withCondition(`Name = '${LIVE_ACCOUNT_LABEL}'`)
    .get();

  if (!labels.hasNext()) {
    throw new Error(`No MCC account label named '${LIVE_ACCOUNT_LABEL}'.`);
  }

  const labelResourceName = labels.next().getResourceName();

  MccApp.accounts()
    .withCondition(
      `customer_client.applied_labels CONTAINS ANY ('${labelResourceName}')`
    )
    .executeInParallel("processClientAccount", "afterProcessAllClientAccounts");
}

/**
 * Runs inside each labelled account and reports on its budget.
 * Whatever this returns has to be a string, so it goes out as JSON.
 */
function processClientAccount() {
  const account = AdsApp.currentAccount();

  const summary = {
    accountName: account.getName(),
    currencyCode: account.getCurrencyCode(),
    hasActiveBudget: false,
    hasSpendingLimit: false
  };

  const budgets = AdsApp.budgetOrders().withCondition("Status = ACTIVE").get();

  if (!budgets.hasNext()) {
    return JSON.stringify(summary);
  }

  summary.hasActiveBudget = true;

  const budget = budgets.next();
  const spendingLimit = budget.getSpendingLimit();

  // An unlimited budget order has nothing to pace against, and dividing
  // by it gives you nonsense. Report it as healthy and move on.
  if (!(spendingLimit > 0)) {
    return JSON.stringify(summary);
  }

  summary.hasSpendingLimit = true;
  summary.spendingLimit = spendingLimit;

  const today = new Date();
  const todayAsString = Utilities.formatDate(
    today,
    account.getTimeZone(),
    "yyyy-MM-dd"
  );

  const startDateAsString = formatDateForQuery(budget.getStartDateTime());

  const cost = getCostForDateRange(startDateAsString, todayAsString);

  summary.cost = cost;
  summary.remaining = spendingLimit - cost;
  summary.percentSpent = cost / spendingLimit;

  // A budget with no end date has no timeline to pace against.
  const endDateTime = budget.getEndDateTime();

  if (endDateTime) {
    const startDate = new Date(startDateAsString);
    const endDate = new Date(formatDateForQuery(endDateTime));
    const todayDate = new Date(todayAsString);

    const totalDays =
      Math.round(Math.abs(endDate - startDate) / ONE_DAY_IN_MS) || 1;
    const daysElapsed =
      Math.round(Math.abs(todayDate - startDate) / ONE_DAY_IN_MS);

    summary.percentTimePassed = daysElapsed / totalDays;
  }

  return JSON.stringify(summary);
}

/**
 * Collects every account's summary and sends one digest.
 */
function afterProcessAllClientAccounts(results) {
  const missingBudget = [];
  const almostSpent = [];
  const failures = [];

  for (const result of results) {
    if (result.getStatus() !== "OK") {
      failures.push(`${result.getCustomerId()}: ${result.getError()}`);
      continue;
    }

    const data = JSON.parse(result.getReturnValue());

    if (!data.hasActiveBudget) {
      missingBudget.push(`${data.accountName} is live but has no active budget.`);
      continue;
    }

    if (!data.hasSpendingLimit) {
      continue;
    }

    const overThreshold = data.percentSpent >= SPEND_THRESHOLD;
    const nearlyEmpty = data.remaining <= REMAINING_SPEND_THRESHOLD;

    if (overThreshold || nearlyEmpty) {
      almostSpent.push(buildSpendMessage(data));
    }
  }

  const sections = [
    buildSection("Accounts with no active budget", missingBudget),
    buildSection("Budgets nearly spent", almostSpent),
    buildSection("Accounts the script could not check", failures)
  ].filter(Boolean);

  if (sections.length === 0) {
    Logger.log("No budget issues found.");

    if (SEND_ALL_CLEAR) {
      sendEmail("Google Ads budgets: all clear", "No budget issues found.");
    }

    return;
  }

  const body = sections.join("\n\n");

  Logger.log(body);

  sendEmail(
    `Google Ads budget alerts: ${missingBudget.length + almostSpent.length} to look at`,
    body
  );
}

// =================== HELPERS ===================

function buildSpendMessage(data) {
  const percentSpent = Math.floor(data.percentSpent * 100);

  let message =
    `${data.accountName}: ${percentSpent}% spent, ` +
    `${formatMoney(data.remaining, data.currencyCode)} left ` +
    `of ${formatMoney(data.spendingLimit, data.currencyCode)}.`;

  if (data.percentTimePassed !== undefined) {
    message += ` ${Math.floor(data.percentTimePassed * 100)}% of the way through the budget period.`;
  } else {
    message += " The budget has no end date.";
  }

  return message;
}

function buildSection(title, lines) {
  if (lines.length === 0) {
    return null;
  }

  return `${title}\n${lines.map((line) => `- ${line}`).join("\n")}`;
}

function formatMoney(amount, currencyCode) {
  return `${currencyCode} ${amount.toFixed(2)}`;
}

function sendEmail(subject, body) {
  MailApp.sendEmail({
    to: ALERT_EMAIL,
    subject: subject,
    body: body
  });
}

/**
 * Totals the account's cost between two dates.
 * @param {string} startDate - 'YYYY-MM-DD'.
 * @param {string} endDate - 'YYYY-MM-DD'.
 * @returns {number} Cost in the account's currency.
 */
function getCostForDateRange(startDate, endDate) {
  const query =
    `SELECT metrics.cost_micros FROM customer ` +
    `WHERE segments.date >= '${startDate}' AND segments.date <= '${endDate}'`;

  const rows = AdsApp.report(query).rows();

  let costMicros = 0;

  while (rows.hasNext()) {
    costMicros += Number(rows.next()["metrics.cost_micros"]);
  }

  return costMicros / 1000000;
}

/**
 * Turns the date object the budget order hands back into a GAQL date.
 * @param {{year: number, month: number, day: number}} dateObject
 * @returns {string} 'YYYY-MM-DD'.
 */
function formatDateForQuery(dateObject) {
  const year = dateObject.year;
  const month = String(dateObject.month).padStart(2, "0");
  const day = String(dateObject.day).padStart(2, "0");

  return `${year}-${month}-${day}`;
}

Save it, run it once, and read the logs before you schedule it. The first run is the interesting one: it usually finds two or three accounts you had forgotten about.

How it works

Picking the accounts to check

MccApp.accounts() on its own returns every account under the manager, including the dead ones. Filtering on a label keeps the alerts about accounts you actually care about.

The condition takes a label resource name, not the label text, which is why the script looks the label up first:

const labelResourceName = labels.next().getResourceName();

MccApp.accounts()
  .withCondition(
    `customer_client.applied_labels CONTAINS ANY ('${labelResourceName}')`
  )

If the label does not exist, the script throws instead of quietly checking nothing. A silent alerting script is worse than no alerting script.

executeInParallel then runs processClientAccount inside each account at the same time and hands every return value to afterProcessAllClientAccounts.

Is there a budget at all?

This is the check that catches the real outages:

const budgets = AdsApp.budgetOrders().withCondition("Status = ACTIVE").get();

if (!budgets.hasNext()) {
  return JSON.stringify(summary);
}

An account with no active budget order is not serving. It does not matter how well the campaigns are built.

This normally happens because the last budget ended and nobody raised the next one, so it tends to fire on the first of the month, on the accounts nobody was watching.

Is the budget nearly spent?

Cost comes from a GAQL query against the customer resource, from the budget start date to today in the account’s own time zone:

const query =
  `SELECT metrics.cost_micros FROM customer ` +
  `WHERE segments.date >= '${startDate}' AND segments.date <= '${endDate}'`;

The script adds up every row it gets back rather than reading the first one. One row is what you expect here, but summing costs nothing and is correct either way.

Then two separate tests decide whether to alert:

const overThreshold = data.percentSpent >= SPEND_THRESHOLD;
const nearlyEmpty = data.remaining <= REMAINING_SPEND_THRESHOLD;

You need both. A percentage alone misses the $50,000 budget sitting at 90% with $5,000 left and three days to go, and an absolute amount alone misses the $500 budget that is 95% gone.

The alert also reports how far through the budget period the account is:

Client Co: 96% spent, NZD 41.20 left of NZD 1000.00. 71% of the way through the budget period.

96% spent at 71% through the month is a problem. 96% spent at 97% through the month is Tuesday. The message gives you enough to tell them apart without opening the account.

One email instead of twenty

Every alert goes into a list, the lists get grouped under headings, and one email goes out at the end. The version I first wrote sent one email per problem, which meant a bad morning produced fifteen emails and I started ignoring all of them.

Accounts that failed to process get their own section. Otherwise a broken account looks exactly like a healthy one.

Need a hand?

If you need help with something similar to this blog post, then get in touch through my contact page.

Get in touch

Scheduling it

Set it to run daily, early enough that you can act before the day’s spend does any damage. Mine runs at 6am.

Hourly is tempting and wrong. The same budget stays over 95% for days, so an hourly schedule sends the same alert until somebody fixes it and you learn to filter the whole lot into a folder.

Routing alerts to whoever owns the account

On a team, the person who reads the alert is often not the person who fixes it. If you name accounts with the owner in brackets — Client Co (Curtis) — you can pull the owner out and email them directly.

Add a map and a lookup:

const ACCOUNT_OWNERS = {
  "Curtis": "[email protected]",
  "Sam": "[email protected]"
};

/**
 * Pulls "Curtis" out of "Client Co (Curtis)".
 * @returns {string|null} The owner label, or null if the name has no brackets.
 */
function getOwnerLabel(accountName) {
  const match = accountName.match(/\(([^)]+)\)/);

  return match ? match[1].trim() : null;
}

Put summary.ownerLabel = getOwnerLabel(account.getName()); in processClientAccount, then group the alerts by owner in afterProcessAllClientAccounts and send each owner their own digest, with yours as the fallback for anything unmatched.

Use a regular expression rather than splitting on brackets. Splitting on ( and then ) throws the moment somebody renames an account without brackets, and it takes the whole account’s results down with it.

For Slack instead of email, swap the sendEmail helper for a webhook post:

function sendSlack(message) {
  UrlFetchApp.fetch(SLACK_URL, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify({ text: message })
  });
}

Append <@MEMBER_ID> to the message to tag someone. Keep the webhook URL in the config block, and remember anyone who can read the script can post to that channel.

Warning you before the budget ends

The checks above tell you a budget is nearly gone. This one tells you it is about to expire, which is the more common cause of an account going dark.

Add the days remaining to the summary, next to the pacing maths:

summary.daysUntilEnd = Math.round((endDate - todayDate) / ONE_DAY_IN_MS);

Then collect a third group of alerts for anything inside your notice period:

const ENDING_SOON_DAYS = 7;

if (data.daysUntilEnd !== undefined && data.daysUntilEnd <= ENDING_SOON_DAYS) {
  endingSoon.push(
    `${data.accountName}: budget ends in ${data.daysUntilEnd} day(s).`
  );
}

Seven days is usually enough to get a new budget approved. If your finance team is slower than that, make it longer.

Closing comments

Budget orders only exist on invoiced accounts

AdsApp.budgetOrders() returns nothing for accounts paying by credit card, because those accounts have no account budgets at all. They have a payment method and campaign budgets instead.

If your MCC mixes both, the script reports every card account as missing a budget. Either keep the Live label to invoiced accounts only, or add a second label for card accounts and skip the missing-budget check for them.

It reads one active budget order

The script takes the first active budget order and ignores any others. That matches how most accounts are set up, but if you overlap budget orders, the pacing maths only describes one of them.

Budgets with no end date

An open-ended budget order has no timeline, so there is no meaningful percentage of time passed. The script still checks spend against the limit and says so in the message.

The threshold gets noisy near the end

Once an account crosses 95%, it stays crossed. You get the same alert every morning until the new budget starts.

The original version of this only alerted when the account was under 90% through its budget period, on the theory that a budget spending out on the last day is working as intended. I took that out, because a budget that spends out at 5pm on the 30th still means no ads on the 31st, and I would rather know.

If you want it quieter, gate the alert on the pacing figure the script already calculates:

if (overThreshold && data.percentTimePassed < 0.9) {

executeInParallel has limits

It processes a limited number of accounts per call — 50 at the time of writing — and each account can only hand back a small string. That is why processClientAccount returns a summary object rather than the rows behind it. Google’s docs on parallel execution have the current numbers.

Past that many live accounts, split the run by label and schedule each one separately.

Currencies do not add up

REMAINING_SPEND_THRESHOLD is a bare number compared against each account’s own currency. NZD 100 and USD 100 are not the same alert. Across one market it does not matter. Across several, either set the threshold for the currency you care most about or convert before comparing.

Time zones

The MCC script runs in the manager account’s time zone, and each client account has its own. The script formats today’s date with account.getTimeZone() inside each account, so the cost query lines up with what that account’s reporting shows.

Around midnight the two disagree by a day. It has never mattered for a threshold this coarse, but it is why the dates are built that way rather than with a plain new Date().