[Updated Google Ads Script] Fetch Google Shopping Feeds Every Hour of the Day

Today, I worked from one of the oldest rooms in the Boston Public Library — Bates Hall in the McKim building.

Designed by Charles Follen McKim, it was the first large free municipal library in the United States. It features stunning Renaissance Revival architecture and murals by John Singer Sargent.

There’s also an inscription carved over the entrance: “Free to All.”

The whole experience got me thinking. 

While traveling, as a non-US citizen, I was able to get access to a stunning working place like this, for free.

The space is beautifully preserved, but quietly modernized. 

Behind the antique wood, coffered ceiling, arched windows, and long rows of green-shaded lamps, there’s fast WiFi and fresh wiring. 

That balance between historic charm and free up-to-date infrastructure made me decide to update one of my scripts and share the MCC version for free:

Fetch Google Shopping Feeds Every Hour of the Day

URL: https://nilsrooijmans.com/google-ads-script-increase-shopping-feed-update-frequency/

What it does:

This script fetches your shopping feed every hour. This way, all the data in your Merchant Center and Shopping Ads is always up-to-date.

Why you care:

The Google Merchant Center interface only supports once-per-day updates of the product data in your shopping feed. Because of this fetch rate limitation, your shopping campaigns may show outdated product listing ads on the Google SERP.

If you’re interested in the MCC version for manager accounts, just hit reply. 

I am sharing it for free for the next 24 hours. (It normally costs $170.)

– Nils

PS: I am in Boston for SMX Advanced, where I will present some of my latest scripts for PMax. 

Next week, I will release the script that got the most votes (for free). Check yesterday’s email to cast your vote:

https://nilsrooijmans.com/daily/on-my-way-to-smx-advanced-boston-what-script-do-you-want-me-to-release

On my way to SMX Advanced (Boston) – what script do you want me to release?

Good day!

I am on the KL0617 flight to Boston for my speaking gig at SMX Advanced.

After years of being “online only,” I absolutely love that SMX is returning to IRL events in the US.

The title of my session is “Mastering Performance Max using Scripts – the 2025 edition.”

I’ll be presenting some of the new scripts I’ve developed last year to tame the PMax beast.

Here’s a short list:

  1. PMax Low-Performing Landing Page Alerts
  2. PMax Campaign Settings Alerts
  3. ChoppyMax Monitor
  4. PMax Ad Schedule Suggestions
  5. PMax Disapproved Entities Alerts
  6. PMax Trending Products
  7. PMax Bad Placements Exclusion Suggestions
  8. PMax Product Price Alerts

If you are interested in any of these scripts:

Hit reply and name the one script you are most interested in (or simply reply with the number).

Next week, I’ll share the script that got the most votes.

In the meantime, I’ll use this flight to have some LLM cook me another script 🙂

Happy scripting!

– Nils

Sales Promotion and Smart Bidding – how to figure it out?

A fellow member of the list, Arnaud Adam, wrote in with this question the other day (name shared with permission):

“Sometimes, we run Sales promotions (e-commerce). CVR is increasing, Google is happily spending money. Then the promotion stops but it takes a while for Google to figure it out. What are the options to limit the spending after the promotion?”

Great question! Here’s how I like to approach it (for e-commerce with short conversion delays).

FIRST:

  • If I expect the sales promotion to have a significant impact on the conversion rate of a significant portion of the clicks in one or more campaigns -> use a seasonality adjustment (for one or more campaigns).
  • If I expect only a small change in conversion rates or a significant change in only a small portion of the clicks -> do nothing.

SECOND:

  • If I have applied a seasonality adjustment, I’ve seen a significant impact on conversion rates for a significant portion of the clicks during the sales promotion, and if I also see smart bidding not scaling back the bids after the promotion -> use Data exclusions.

A little bit of background from the horse’s mouth:

“Seasonality adjustments are an advanced tool that can be used to inform Smart Bidding of expected changes in conversion rates for future events like promotions or sales.

If you’re expecting conversion rates to increase by 50% during a 3-day sale, you can create a seasonality adjustment that increases the conversion rate by up to 50% for those 3 days. Your campaigns will optimize their bids during the events and return to their pre-adjust performance after the event is finished. No negative adjustment is needed once the promotion is over.”

Source: https://support.google.com/google-ads/answer/10369906?hl=en

NOTE: I think Google made a mistake in the documentation. I think they meant this: “You can create a seasonality adjustment that increases the CPC bid by up to 50% for those 3 days.” 

Use with care:

– Nils

[PPC Hack] Add a negative keyword list to PMax campaigns straight from the Google Ads UI

Ever tried to add a negative keyword list to your Performance Max campaigns?

It’s painful, right? 

Yes, we can finally add negative keywords to our PMax campaigns, but for negative keyword lists, you have to download a sheet, fill in all kinds of boring details, and then email the sheet to a Google Ads support rep. 

Then wait for DAYS, and hopefully, if the stars align and your support rep admits PMax isn’t always the answer, Google will be kind enough to add the negative keyword list to your PMAX campaign. 

But then you’ll have to do it all over again if you want to add new lists to new campaigns. 

A frustrating and tiresome process, to say the least.

Now here’s a HACK I discovered on Reddit:

(Disclaimer: It really is a hack. It doesn’t always work. I got it to work 8 out of 10 times. YMMV.)

Follow these steps to create and attach a negative keyword list to your PMax campaigns:

1) Create a negative keyword list ‘* PMax Negative Keywords’ in the shared library

2) Apply the list to a standard search campaign (just pick one)

3) Navigate to All Campaigns → Content, then select the checkbox next to ‘* PMax Negative Keywords’

4) Click Edit → Copy

5) Go to your PMax campaign → Content, click on the three-dot menu (More), then select Paste and select the campaigns you want to add your list to

6) Remove the negative keyword list from the campaign used in step 2

Again, it really is a hack — it doesn’t always work. I got it to work 8 out of 10 times, YMMV. Test with care.

Source on Reddit: 
https://www.reddit.com/r/PPC/comments/1j8ezy0/pmax_negative_list_you_can_apply_yourself/

– Nils

P.S. If you want to know why I added the little asterisk (*) ​in front of the name for my negative keyword list, read this: https://nilsrooijmans.com/daily/little-trick-quickly-see-what-negative-keyword-lists-are-attached

The Google Idealist, Realist, and Cynic. Which one are you?

When it comes to beliefs and opinions about Google, I see roughly three archetypes in the PPC community.

The first is what I call the Google Idealist.

This person believes what Google says to be true. 

Google does no evil and always has the interest of every stakeholder in mind.

Every new feature and recommendation from Google can benefit the advertiser, and should be enabled without questioning.

The advantage of being a Google Idealist is that you don’t have to work that hard; just follow Google’s advice, and you’re done. The downside of being a Google Idealist is that you might get disappointed by Google not delivering on its promises.

Opposite of the Google Idealist is the Google Cynic.

The Cynic doesn’t trust anything Google says and believes Google is only motivated by pleasing Wall Street and the value of its shares.

Google’s recommendations are there to make Google money, and black boxes are only there to keep us from seeing the truth.

New features introduce problems rather than solutions.

The advantage of being a Google Cynic is that you won’t get disappointed if things don’t work out as Google wanted you to believe. The downside of being a Google Cynic is that you sort of blindfold yourself from seeing potential opportunities for growth. 

The third type of reaction comes from what I call Google Realists.

The Realist looks at Google as what the company and its ad platform actually are. 

This person accepts practical constraints and focuses on what is achievable.

Every claim or new feature is viewed as a learning opportunity; something worth testing.

The advantage of being a Google Realist is that you make practical decisions based on objective facts, rather than wishful thinking or dogmatic beliefs. You’re prepared for various outcomes. As a Realist, you quickly adjust to changing conditions, increasing your resilience. The downside of being a Google Realist is that it is hard work chasing the facts, and that the facts might hold you back in “trusting” new opportunities for growth.

Personally, I like to see myself as a Google Realist, slightly leaning towards the Cynical side; I want to trust Google, but due to its behavior, I can only do so by verifying.

My motto when it comes to claims and new features from Google:
“Don’t Trust. Verify!”

You’ve got to believe in something.

What is it you believe?

– Nils

[Google Ads Script] Change History Alerts — YOU NEED TO UPDATE

This morning, I woke up early at my houseboat to get some stuff done.

I got myself a coffee, watched the sun rise over the Amsterdam canals, and opened my laptop.

My first thought was, “WTF?!”

My inbox was flooded with questions about one of my most popular scripts, the Change History Alert script.

If you don’t know the script, check out the description below.

If you know the script and are running it in your account(s), you need to update to version 1.2!

Why?

Apparently, Google made some undocumented changes in the reporting API that broke my script.

Don’t worry. I’ve fixed it. 

(So much for my productive start of the morning…)

GOOD NEWS: The only thing you need to do is install the latest version. Installing the update only takes 2 minutes.

You can find it here.

Be in the know (again) when someone makes changes to your account!

SCRIPT:
Change History Alerts (V1.2)

URL:
https://nilsrooijmans.com/google-ads-script-change-history-alerts/

WHAT IT DOES:

The script checks all the entries in the Google Ads change history of your account, and if there is a change by a user outside of your list of ‘recognized’ users, you will get an alert via email. The alert mail contains the number of changes and a link to the Google Sheet that lists all changes by unrecognized users.

WHY YOU CARE:

It is all too often people outside your team (or Google) make changes to the Google Ads accounts you manage. You want to make sure these changes are in line with your strategy ASAP. This script ensures you do.

– Nils

conversation about AI

I had an interesting conversation with a prospect for an account audit today:

Advertiser: “There’s so much amazing AI stuff going on right now! But how do I know what work is done by an actual person inside the agency I hire to manage my account?”

Me: “What difference does it make?”

Advertiser: “{silence}”

Here’s the thing: ultimately, if the work delivers the result the advertiser is looking for, then I don’t think the advertiser should care who – or what – did the work.

– Nils

Join me for SMX Advanced Boston next month?

Next month, I’ll be speaking at SMX Advanced in Boston! 

My session is “Mastering PMax with Scripts – the 2025 edition.”

Learn more here: https://searchengineland.com/smx/advanced/agenda/?sessId=2985

I’ll present 10 new scripts that have helped me boost my clients` profits in the last 12 months.

Use the promo code SMXSpeakerFriend to save 15% off registration! Here’s the URL: https://events.searchengineland.com/smx-advanced-2025/?ref=rooijmans

See you there?
 
– Nils

client question – my answer might surprise you

A client asked this question today:

“Hey Nils, you’ve always been very critical of Performance Max campaigns, and now you’re suggesting we add PMax campaigns to our existing strategy?”

Yes. 

Two reasons:

1) Performance Max campaigns are part of my current go-to strategy for scaling e-commerce accounts.

2) ABT

Let me explain:

1. Here’s my current campaign setup for predictably scaling profits in e-commerce accounts:

      Phase 1: Standard Shopping + DSA campaigns
      Phase 2: Standard Text Ads
      Phase 3: Dynamic Remarketing via Display
      Phase 4: YouTube / Demand Gen (remarketing + prospecting)
      Phase 5: Performance Max (for “Hero”  products)

I start with “standard” campaign types because they allow me to control spend and scale the account in a predictable manner that allows for reliable and steady profit growth. Something my clients (and I) value greatly.  

I add PMax in the last phase because, when all else has matured and growth has plateaued, PMax can find new pockets of growth by targeting inventory that was not available before. Think Discovery, Gmail, Maps, and more inventory from the other channels that weren’t targeted by the standard campaigns.

2. Always Be Testing (ABT)

Every day in PPC is different, and Google Ads (especially PMax) is always changing. For this reason, I recommend to always be testing existing features, new features, new ideas, and yes, even completely new setups.

How I test PMax is the topic for tomorrow’s newsletter.

– Nils

saved by the script

Does this mistake sound familiar?

I have a client who runs ads in two different accounts. (We’ll not discuss “why two accounts?” today.) Then, I had to move some ads from account A to account B for portfolio bid purposes.

So yesterday, I migrated some ads and keywords containing the term “vw”.

This morning, I woke up with zero impressions for new ads — and fortunately, an email alert in my inbox that linked to a Google Sheet:

(click image to enlarge)

It turns out that the term “vw” was negated via a Negative Keyword List that is attached to my campaign. I didn’t ​see it ​yesterday.

Without the script, it would have probably taken me half an hour to figure out what was wrong, OR even worse, I wouldn’t have noticed the issue until much later!

Thank you, scripts!

Here’s the script:

Google Ads Script – Negative Keywords Conflicts – Updated version

URL: https://nilsrooijmans.com/negative-keyword-conflicts-alert-script-update/

What it does:
Email alert if an account has positive keywords that are blocked by negative keywords. The script saves all such conflicts to a spreadsheet and sends an email alert.

Why you’d care: 
Negative keywords are intended to prevent ads from showing on irrelevant search queries, but they may inadvertently block normal keywords from matching relevant search queries, making your campaigns less effective.  

– Nils

PS: This script is one of the golden nuggets from my eBook ‘21 Must-Have Scripts to Automate & Optimize Your Google Ads‘. If you think today’s script is of value to you, I am 100% sure you’ll want to have the other 20 running in no time. Get the full list of scripts and my step-by-step guide for only $47. That’s only $2 per script with a huge ROI.

Why Google might be overbidding (and how to stop it)

Did you know you can set a max bid for target ROAS bidding?

You can, and here’s why you should.

Let’s say you’re Google, and you have a smart advertiser who uses target ROAS bidding strategy.

The campaign is running smoothly, ROAS targets are crushed, and the campaign budget leaves room for a lot more spending.

What’s keeping you from setting a crazy high bid for that click in the next auction? Not much, right? 

According to Google’s documentation, target ROAS bidding will:

“…set maximum cost-per-click (max. CPC) bids to maximize your conversion value, while trying to achieve an average return on ad spend (ROAS) equal to your target.”

(Source: https://support.google.com/google-ads/answer/6268637?hl=en)

So if the average ROAS is above target and the budget is not yet spent, guess what? Extremely high CPCs are not an exception. We are all in it for the money, right?

Luckily for us, there is a slightly hidden secret to prevent this from happening: you can set a max CPC bid if you are using a portfolio version of the target ROAS (or target CPA) smart bidding!

It’s under “Settings > Advanced options” in your Portfolio bid strategy.

Keep the profit with you!

– Nils

Why You Should Keep an Eye on Google’s SERP changes

If you are like me, you are probably laser-focused on Google’s changes inside the Google Ads platform.

However, the performance of your ads is also highly influenced by how and where your ads appear on the Google SERP.

Even small changes can significantly impact your impressions, click-through rates, and (ultimately) conversions.

New features like expanded snippets, video carousels, or AI-powered overviews can quickly and drastically change how people view and interact with your ads.

Here’s the thing: Google is continuously experimenting with new layouts and features. The Google SERP changes constantly! There are already 11 documented changes this month.

You absolutely need to monitor these changes. Keeping an eye on these SERP changes provides important insights for your PPC ads. And, ACTING on the insights can give you a real competitive edge.

Here’s your 5-minute homework for today:

  1. Bookmark this regularly-updated resource: The latest Google SERP feature tests and launches –> https://brodieclark.com/notes/
  2. Schedule a weekly task in your calendar to check this list and spot changes before your competitors do.

Stay sharp,

– Nils

phrase match close variants & why your brand CPCs go through the roof

I came across a post on LinkedIn today.

It made me laugh and cry at the same time:

It’s an example of a trend I’ve spotted in many accounts:

Google is increasingly matching our non-brand phrase match keywords to competitor brand searches! 

This also explains why so many of us see an increase in CPCs for our branded keywords. Google matches user queries for our brand to non-brand keywords of our competitors, thus increasing competition in the auction -> higher CPCs, higher spend.

What can we do about it?

Not much, I am afraid. It’s Google being Google and trying to make some more money.

One thing we can do: monitor the auction insights for our brand keywords and ask each and every competitor that shows up to add our brand to their account negatives. (Good luck with that.)

What a Google S-show…

– Nils

AI in PPC: what to think, what to do

“We tend to overestimate the effect of a technology in the short run and underestimate the effect in the long run.”

That’s Amara’s Law, coined by futurist Roy Amara.

In simple terms:
– When a new technology appears, people often get overly excited, expect immediate results and instant revolutions, then get disappointed.
– However, over time, the technology reshapes society in ways much bigger than anyone expected.

For example, in the ’90s, people thought the web would kill all physical stores overnight. It didn’t happen. But in the long run, the internet changed everything about how we work, shop, learn, and connect — way more profoundly than most people had imagined.

The short-term expectations of a new technology are fueled by the hype during its initial phase. This hype is often created by actors in the space who benefit from it. (Think tool creators, course creators, influencers, journalists, book writers, etc.)

 It’s all the same in AI for PPC today.

Many actors in the space want us to believe AI is going to take over our jobs — in three years, if not earlier. Others want us to believe we can already completely outsource the task of creating the best campaign setup for our clients, without any human in the loop. And then, there are a lot of AI promises for easy quick wins, that, when seriously tested, only produce marginal gains when it comes to efficiency.

Here’s the thing: don’t let the current hype around AI misinform your expectations for the short-term gains, nor the long-term implications.

Keep your eye on AI, and create your own perspective. Here’s how you can do this today:

  1. List one AI promise you’ve heard last week
  2. Define a razor-sharp goal (KPI+target) that it must beat (e.g., conv/impr, CPA, hours saved)
  3. Set up an experiment and run a 30-day test 
  4. Document results — good, bad, or “meh.”
  5. Decide: scale, refine, or scrap.

Repeat the test that failed today again, 6 months from now. (AI tech moves fast!)

That’s how you bypass hype and build your own perspective while preparing yourself and your job for the AI future.

– Nils

[Free Online Event] Up your scripting game during Script Day

Want to get more out of your PPC automation efforts?

If so, join me and other scripting fanatics at the FREE online event “Script Day” (April 29th).

It’s only 7 days away!

This day will be jam-packed with Google Ads Scripts knowledge.

Here is the line-up and timetable (all times in CET):

10:00 – 11:00 | Mike Rhodes: Multiple or Die
“From Australia, the creator of the world’s most widely-used PMAX campaign script introduces AI agents and how to integrate them into your workflow to solve YOUR issues. (Top 20 PPC Influencer)”

11:00 – 12:00 | Nils Rooijmans: Scripts for e-Commerce and how I use them
“From the Netherlands, the most prolific and renowned script author shares practical insights ideal for anyone managing e-commerce campaigns. (Top 10 PPC Influencer)”

12:00 – 13:00 | Martin Röttgerding: Using Keyword Quality Scores to Diagnose and Improve Search Campaigns
“From Germany, we’re honored to host the expert who introduced the world to the 3-level Google Shopping campaign structure. A respected authority reminding us to master the basics of Google Ads. Martin will share an essential script for tracking Quality Scores.”

15:00 – 16:00 | Mike Ryan: Is PMax Eating Your Brand Search?
“From Austria, Mike Ryan, one of the most influential and passionate voices in international Google Ads, returns to WMF stages. The day’s first session dedicated to the crucial topic of brand traffic in PMAX campaigns. (Top 20 PPC Influencer)”

16:00 – 17:00 | Charles Bannister: Auto Negative Keywords Script
“From Chicago, Charles presents the second session focused on brand traffic, showcasing a script to regain control over your Brand Protection campaigns.”

17:00 – 18:00 | Frederick Vallaeys: Supercharge Google Ads Scripts with ChatGPT
“From Silicon Valley, we welcome Fred, long-recognized as the leading voice in Google Ads. His session will explore the cutting-edge yet simple integration of AI into Google Ads. (#1 PPC Influencer 2025)”

(Event descriptions from here.)

Totally free. Recordings will be made available to the registrants.

Grab your free spot today >>

And if you are serious about attending… 

Got a scripting topic you’re struggling with? Hit reply and tell me—I might cover it during my session!

– Nils

PS: In case you missed it, I recently launched my first eBook “21 Must Have Scripts” that includes some of the scripts I will be talking about. It’s not too late to grab your copy!

a rewarding, multi-step optimization ritual

A Google Ads campaign type with all the functions (performance data transparency,  targeting, exclusions, etc.) not only makes for better performance, it also provides a rewarding, multi-step optimization ritual to start your week with. 

For example, bid limits.

Bid limits have many use cases. Here’s one: they can prevent wasted ad spend on high CPC clicks and allow for slow ramp-ups during the initial learning period of your smart bidding campaigns.

If a positive short-term ROI is important to you, bid limits can help you prevent costly “mistakes” by the bidding algorithm during the trial-and-error learning phase.

After the ramp-up, when your optimizations are in full effect, and smart bidding has modelled the predicted conversion value of your ad impressions, you can then increase the bid limit on a weekly basis to increase volume (and monitor its impact on ROI).

– Nils

Here’s Ed Leake and me talking about… scripts and AI

Quick FYI: Ed Leake invited me inside his God Tier Ads community to talk about our favorite topics: scripts and AI 🙂

Watch us discuss 8 of the 21 must-have scripts for every account, and how we use AI in PPC (wait for the third part of the video):
https://www.youtube.com/watch?v=qettsLjFM8c

– Nils

PS:

If you’re serious about upping your PPC skills and still don’t have access to Ed’s God Tier Ads, consider using my referral link.  

Use my discount code “NILS100” at checkout for $100 off. Pay once, get lifetime access to everything.

I’d still share these tips without the kickback… Ed’s pro tips have made me way more money than I’ll ever see from commissions, but a few extra bucks is nice!

Here’s the link again: https://app.godtierads.com/gta-bonus-bundle/apnnu

Automatically pause campaigns when spend is met

Here’s a question I read on Reddit today:

“Can anyone help on how to setup a campaign / account in Google Ads to automatically pause once it reaches a certain spend daily / monthly limit? For example if I want X campaign to pause once it spends $100 / day?”

Source: https://www.reddit.com/r/PPC/comments/1jvp276/automatically_pause_campaigns_when_spend_is_met/

These kinds of scripts can easily be created by LLMs these days. Here’s the prompt I used and the code it generated. I have not tried previewing the script myself, but it looks like a good start.
  
Hope this helps!

Here’s the LLM prompt:

You are a senior Google Ads specialist with excellent Google Ads Scripts skills. You are also a JavaScript teacher and explain the code you create to a 14 year old. You always add comments to your code that describe its workings, you add logger output to the most important parts of the scripts and you also explain the code you create.

I need you to create a script that reads the campaign names and associated daily spend limits from a Google Sheet. The script checks the spend of the campaigns and if today’s spend exceeds the spend limit, the script will pause the campaign and send an email. Every night at 1AM the script will re-enable all the campaigns it has paused the day before. Here’s a place for you to enter some engaging and enticing text. Be sure to check for typos, and don’t forget — real people will be reading this!

Here’s the code it generated: 

    
    // --- Configuration ---
    // Hey! These are the settings you need to change.
    
    // 1. Put the URL of your Google Sheet here.
    //    It should look something like "https://docs.google.com/spreadsheets/d/ SPREADSHEET_ID /edit"
    var SPREADSHEET_URL = 'YOUR_SPREADSHEET_URL_HERE';
    
    // 2. Put the name of the sheet tab within your spreadsheet that has the campaign names and limits.
    //    Usually, it's 'Sheet1' unless you renamed it.
    var SHEET_NAME = 'Sheet1';
    
    // 3. Enter the email address where you want to receive notifications.
    //    You can add more emails separated by commas, like 'me@example.com,boss@example.com'
    var EMAIL_ADDRESS = 'YOUR_EMAIL_HERE';
    
    // 4. This is a label we'll use to mark campaigns paused by this script.
    //    You can change the name if you like, but it's usually fine like this.
    var PAUSED_BY_SCRIPT_LABEL = 'PausedBySpendScript';
    
    // --- End of Configuration ---
    
    /**
     * Main function - This is where the script starts.
     * It decides whether to check campaign spending or re-enable campaigns based on the time.
     */
    function main() {
      // Get the current hour (0-23) in the account's timezone.
      var currentHour = parseInt(Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'H'), 10);
      Logger.log('Current hour in account timezone: ' + currentHour);
    
      // Check if the label we use exists, and create it if it doesn't.
      // It's like making sure we have the right sticker before we start using it.
      ensureLabelExists(PAUSED_BY_SCRIPT_LABEL);
    
      // If it's currently 1 AM (hour 1), run the function to re-enable campaigns.
      if (currentHour === 1) {
        Logger.log('It is 1 AM. Running the re-enable function.');
        reenablePausedCampaigns();
      } else {
        // Otherwise (any other hour), run the function to check spending and pause if needed.
        Logger.log('It is not 1 AM. Running the check and pause function.');
        checkAndPauseCampaigns();
      }
    
      Logger.log('Script finished.');
    }
    
    /**
     * Reads the campaign names and their daily spend limits from the Google Sheet.
     * It's like the robot reading your instruction list.
     * Returns: An object where keys are campaign names and values are their spend limits.
     *          Example: { 'Summer Sale Campaign': 50, 'Winter Promo': 100 }
     */
    function readSpendLimitsFromSheet() {
      Logger.log('Reading campaign limits from Google Sheet: ' + SPREADSHEET_URL);
      try {
        // Open the spreadsheet using the URL.
        var spreadsheet = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
        // Get the specific sheet (tab) by its name.
        var sheet = spreadsheet.getSheetByName(SHEET_NAME);
    
        if (!sheet) {
          Logger.log('Error: Sheet "' + SHEET_NAME + '" not found in the spreadsheet. Make sure the name is correct.');
          return {}; // Return an empty object if sheet not found
        }
    
        // Get all the data from the sheet, starting from the second row (A2)
        // down to the last row with data, and across two columns (A and B).
        // sheet.getLastRow() - 1 means "get all rows except the header".
        var data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
    
        // Create an empty object to store the limits. Think of it as an empty dictionary.
        var campaignLimits = {};
    
        // Loop through each row of data we got from the sheet.
        for (var i = 0; i < data.length; i++) {
          var campaignName = data[i][0]; // First column (index 0) is the campaign name.
          var spendLimit = data[i][1];   // Second column (index 1) is the spend limit.
    
          // Check if the campaign name and limit look valid.
          if (campaignName && typeof campaignName === 'string' && campaignName.trim() !== '' && typeof spendLimit === 'number' && spendLimit >= 0) {
            // If they are valid, add them to our dictionary.
            // .trim() removes any accidental spaces before or after the name.
            campaignLimits[campaignName.trim()] = spendLimit;
          } else {
            Logger.log('Warning: Skipping row ' + (i + 2) + ' in the sheet. Invalid data. Campaign Name: "' + campaignName + '", Spend Limit: "' + spendLimit + '"');
          }
        }
    
        Logger.log('Successfully read ' + Object.keys(campaignLimits).length + ' campaign limits from the sheet.');
        // Return the dictionary of campaign names and their limits.
        return campaignLimits;
    
      } catch (e) {
        // If anything goes wrong (like the URL is bad or we don't have permission), log the error.
        Logger.log('Error reading spreadsheet: ' + e);
        // Send an email alert about the sheet reading error
        sendEmailNotification('Error Reading Spreadsheet', 'Failed to read data from Google Sheet: ' + SPREADSHEET_URL + '\nSheet Name: ' + SHEET_NAME + '\nError: ' + e);
        return {}; // Return an empty object on error
      }
    }
    
    /**
     * Checks the spending of campaigns listed in the sheet.
     * Pauses campaigns that have spent over their limit for TODAY.
     * Sends an email notification if any campaigns are paused.
     */
    function checkAndPauseCampaigns() {
      // Get the campaign limits from the Google Sheet first.
      var campaignLimits = readSpendLimitsFromSheet();
    
      // If the campaignLimits object is empty (maybe the sheet was empty or couldn't be read), stop here.
      if (Object.keys(campaignLimits).length === 0) {
        Logger.log('No campaign limits found or sheet could not be read. Exiting check function.');
        return;
      }
    
      // Get all the campaign names from our dictionary (the keys).
      var campaignNames = Object.keys(campaignLimits);
      Logger.log('Checking spend for ' + campaignNames.length + ' campaigns listed in the sheet.');
    
      // Keep track of campaigns we actually pause to put them in the email later.
      var pausedCampaignsInfo = []; // An empty list to start.
    
      // Find all campaigns in the Google Ads account whose names match the ones from our sheet
      // AND are currently enabled (we don't need to check paused or removed ones).
      var campaignIterator = AdsApp.campaigns()
        .withCondition("CampaignName IN ['" + campaignNames.join("','") + "']") // Efficiently filter by multiple names
        .withCondition("CampaignStatus = ENABLED")
        .get();
    
      Logger.log('Found ' + campaignIterator.totalNumEntities() + ' enabled campaigns matching names in the sheet.');
    
      // Loop through each matching campaign found in Google Ads.
      while (campaignIterator.hasNext()) {
        var campaign = campaignIterator.next();
        var campaignName = campaign.getName();
    
        // Double-check if this campaign name is actually in our limits dictionary
        // (it should be, because of the filter, but it's good practice).
        if (campaignLimits.hasOwnProperty(campaignName)) {
          var limit = campaignLimits[campaignName]; // Get the limit for this specific campaign.
    
          // Get the stats (like cost) for this campaign for TODAY.
          var stats = campaign.getStatsFor('TODAY');
          var cost = stats.getCost(); // How much has it spent today?
    
          Logger.log('Campaign: "' + campaignName + '" | Todays Spend: ' + cost + ' | Limit: ' + limit);
    
          // THE BIG CHECK: Is today's cost GREATER than the limit we set?
          if (cost > limit) {
            Logger.log('ALERT: Campaign "' + campaignName + '" spent ' + cost + ', exceeding limit of ' + limit + '. Pausing campaign.');
    
            // PAUSE the campaign! This stops it from spending more today.
            campaign.pause();
    
            // Apply our special label so we know this script paused it.
            campaign.applyLabel(PAUSED_BY_SCRIPT_LABEL);
    
            // Add the details to our list for the email report.
            pausedCampaignsInfo.push({ name: campaignName, spend: cost, limit: limit });
          }
        } else {
            // This shouldn't happen often with the IN condition, but good to log if it does.
            Logger.log('Warning: Campaign "' + campaignName + '" found in Ads but not in the limits dictionary? Skipping.');
        }
      }
    
      // After checking all campaigns, did we pause any?
      if (pausedCampaignsInfo.length > 0) {
        Logger.log(pausedCampaignsInfo.length + ' campaign(s) were paused due to exceeding spend limits.');
        // If yes, build and send the email notification.
        var emailSubject = 'Google Ads Alert: Campaigns Paused Due to Overspending';
        var emailBody = 'The following Google Ads campaigns were automatically paused today because their spending exceeded the daily limit set in the Google Sheet:\n\n';
    
        // Add each paused campaign's details to the email body.
        for (var i = 0; i < pausedCampaignsInfo.length; i++) {
          var info = pausedCampaignsInfo[i];
          emailBody += '- Campaign: "' + info.name + '"\n';
          emailBody += '  - Todays Spend: ' + formatCurrency(info.spend) + '\n'; // Format spend nicely
          emailBody += '  - Daily Limit: ' + formatCurrency(info.limit) + '\n\n'; // Format limit nicely
        }
    
        emailBody += '\nThese campaigns have been labeled "' + PAUSED_BY_SCRIPT_LABEL + '" and will be automatically re-enabled around 1 AM account time.';
        emailBody += '\n\nSpreadsheet Used: ' + SPREADSHEET_URL;
    
        // Send the email!
        sendEmailNotification(emailSubject, emailBody);
      } else {
        Logger.log('No campaigns needed pausing today.');
      }
    }
    
    /**
     * Re-enables campaigns that were paused by this script (identified by the label).
     * This function is intended to run around 1 AM.
     */
    function reenablePausedCampaigns() {
      Logger.log('Searching for campaigns paused by this script (label: "' + PAUSED_BY_SCRIPT_LABEL + '") to re-enable them.');
    
      // Find all campaigns that are currently PAUSED *and* have our special label.
      var campaignIterator = AdsApp.campaigns()
        .withCondition("LabelNames CONTAINS ANY ['" + PAUSED_BY_SCRIPT_LABEL + "']")
        .withCondition("CampaignStatus = PAUSED")
        .get();
    
      Logger.log('Found ' + campaignIterator.totalNumEntities() + ' campaign(s) to re-enable.');
    
      var reenabledCount = 0;
      // Loop through each campaign found.
      while (campaignIterator.hasNext()) {
        var campaign = campaignIterator.next();
        var campaignName = campaign.getName();
    
        Logger.log('Re-enabling campaign: "' + campaignName + '"');
        // ENABLE the campaign, making it active again.
        campaign.enable();
        // REMOVE the label, as it's no longer paused by the script.
        campaign.removeLabel(PAUSED_BY_SCRIPT_LABEL);
        reenabledCount++;
      }
    
      if (reenabledCount > 0) {
          Logger.log('Successfully re-enabled ' + reenabledCount + ' campaign(s).');
          // Optional: Send a confirmation email that campaigns were re-enabled.
          // sendEmailNotification('Campaigns Re-enabled', reenabledCount + ' campaign(s) paused by the spend script yesterday have been re-enabled.');
      } else {
          Logger.log('No campaigns needed re-enabling.');
      }
    }
    
    /**
     * Sends an email notification.
     * A helper function to keep the email sending code in one place.
     * @param {string} subject The subject line of the email.
     * @param {string} body The main content (body) of the email.
     */
    function sendEmailNotification(subject, body) {
      // Check if an email address has actually been provided in the configuration.
      if (EMAIL_ADDRESS && EMAIL_ADDRESS.trim() !== '' && EMAIL_ADDRESS.toLowerCase() !== 'your_email_here') {
        Logger.log('Sending email notification to: ' + EMAIL_ADDRESS + ' | Subject: ' + subject);
        // Use the MailApp service to send the email.
        MailApp.sendEmail({
          to: EMAIL_ADDRESS,
          subject: subject,
          body: body,
        });
      } else {
        // If no email address is set, just log a warning.
        Logger.log('Warning: Email notification not sent because EMAIL_ADDRESS is not configured.');
        Logger.log('Email Subject: ' + subject);
        Logger.log('Email Body:\n' + body);
      }
    }
    
    /**
     * Checks if a label with the given name exists. If not, it creates it.
     * This prevents errors if the script tries to apply a label that doesn't exist yet.
     * @param {string} labelName The name of the label to check/create.
     */
    function ensureLabelExists(labelName) {
      // Look for labels with the exact name.
      var labelSelector = AdsApp.labels().withCondition("Name = '" + labelName + "'");
      var labelIterator = labelSelector.get();
    
      // If the iterator doesn't find any labels (count is 0), then the label doesn't exist.
      if (!labelIterator.hasNext()) {
        Logger.log('Label "' + labelName + '" not found. Creating it now.');
        // Create the label. You could add a description or color if you wanted.
        AdsApp.createLabel(labelName, 'Campaign paused by automated spend script');
        Logger.log('Label "' + labelName + '" created.');
      } else {
        // If the label already exists, we don't need to do anything.
        Logger.log('Label "' + labelName + '" already exists.');
      }
    }
    
    /**
     * Formats a number as currency, using the account's currency code.
     * Example: 123.456 becomes "$123.46" or "€123.46"
     * @param {number} amount The number to format.
     * @return {string} The formatted currency string.
     */
    function formatCurrency(amount) {
        // Get the currency code for the Google Ads account (e.g., "USD", "EUR", "GBP").
        var currencyCode = AdsApp.currentAccount().getCurrencyCode();
        // Format the number to 2 decimal places and add the currency symbol/code.
        // Note: This basic formatting might not be perfect for all currencies, but it's usually good enough.
        return currencyCode + ' ' + amount.toFixed(2);
    }

Want to test it?

It might need some debugging (again, with the help of an LLM, this should not be a lot of work). Let me know if you get stuck.  

– Nils

PS: In case you missed it, I recently launched my first eBook “21 Must Have Scripts” with scripts of equal or higher value for your accounts . It’s not too late to grab your copy!

The Script Day — register for FREE! Also: what topics do you want me to talk about?

Save the date!

On April 29th, join me and other scripting fanatics at the FREE online event “Script Day.”

This day will be jam-packed with Google Ads Scripts knowledge from no less than the following speakers:

  • Charles Bannister
  • Frederick Vallaeys
  • Martin Röttgerding
  • Mike Rhodes
  • Mike Ryan
  • …and yours truly.

I’ll be presenting some of the Google Ads Scripts I use for e-commerce accounts and how I use them. Totally free.

Sign up today >>

And tell me…

What topics do you want me to talk about?

You can email me about them at nils@nilsrooijmans.com 🙂

– Nils

PS: In case you missed it, I recently launched my first eBook “21 Must Have Scripts” that includes some of the scripts I will be talking about. It’s not too late to grab your copy!