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!

Last chance: 20% OFF my “21 Must-Have Google Ads Scripts” eBook (ends tonight)

Yesterday, 53 of your fellow PPC specialists secured their copy of my “21 Must-Have Google Ads Scripts” eBook. Smart move — they’re now automating critical tasks, spotting hidden issues, and protecting ad budgets.

Here’s why Ana Kostic considers it essential:

“Precise, concise, and fluff-free. It saves time and instantly boosts productivity, especially if you manage multiple accounts.”

The good news: The 20% discount is still available.

The bad news: The discount ends tonight at midnight.

This curated collection of scripts can save you hours of work each month and help you deliver significantly better results for your clients or team.

👉 Get Your Copy » 

LIMITED TIME OFFER (ends at midnight):
Get 20% off using this discount code -> 21SCRIPTS

Get Your Discount » 

Don’t leave performance (or profit) on the table.

Just script it,
Nils

P.S. Alex van de Pol highlights:

“If you’re spending anything on Google Ads, do yourself a favour and get on board with scripts — and trust that Nils is a great driver!”

NEW eBook: 21 Scripts every PPC Pro MUST Have (20% discount next 24 hours)

Ever felt overwhelmed by the sheer number of Google Ads scripts out there?

I’ve been asked over and over by fellow PPC pros:

What’s the 20% of scripts that’ll get me 80% of the results?”

Great question. I’ve done the digging, so you don’t have to.

Here I am, introducing my brand new eBook:

👉 21 Must-Have Scripts to Automate & Optimize Your Google Ads

These aren’t random picks; they’re battle-tested scripts I’ve personally used to:

✓ Spot costly anomalies before they tank results
✓ Catch broken links that silently burn ad budgets
✓ Quickly alert me when conversions unexpectedly drop
✓ And much more…

Whether you’re managing a single account or juggling multiple clients, these 21 scripts have your back, just like I’ve got yours.

No coding needed — simply copy-paste.

Here’s what Ana Kostic, a PPC professional, has to say:

21 Must-Have Scripts is an incredibly useful resource. Precise, concise, and fluff-free — it saves time and boosts productivity, especially for those managing multiple accounts. It’s a fantastic resource I keep close in my day-to-day work.”

And here’s Tina Arnoldi’s perspective, who’s been in PPC since 2011:

Nils is one of my go-to sources for creating and modifying scripts. He doesn’t just provide scripts, he provides a use case for why you want to use them.”

Ready to save hours, avoid headaches, and impress your clients (and your boss)?

Get Your Copy »

LIMITED TIME OFFER: In the next 24 hours, you can get 20% off using the discount code 21SCRIPTS.

Get Your Discount »

P.S. Implement just 3 scripts today. See the immediate impact. Then watch your performance skyrocket.

As PPC Pro Martin Snowdon says:

“Works like a dream!”

– Nils

ouch… (second time) – please, don’t be like me

Roughly three years ago, I made a mistake.

Here’s what I wrote in my daily email from January 26, 2022:

Due to my elite auditing skills, I figured out how to save 30 PERCENT of ad spend without reducing conversion value.

Here’s some optimization tips:

1. End the broad match campaign experiment you added a month ago and forgot about
…”

Today, I discovered I made the same mistake again.

I guess I should take my own writing more seriously. (*)

– Nils

(*) Or stop excluding campaign experiments from my monitoring scripts.

Get My New eBook “21 Must-Have Google Ads Scripts” – FREE (Beta Offer) for 20 Readers 

I’m wrapping up my new eBook “21 Must-Have Google Ads Scripts,” packed with free, ready-to-use, time-saving scripts for any PPC pro.

Before it goes live, I’m offering a free beta version to the first 20 responders who are willing to:

1. Read the eBook with the first 10 scripts (10 mins)
2. Install and test at least 3 scripts mentioned in the book (15 mins)
3. Provide feedback on the eBook (3 mins)
4. Give me a short testimonial (2 mins)

As a thank-you for the 30 minutes of your time and for your feedback, you’ll also get 80% off the final version.

Interested? Email me at nils@nilsrooijmans.com and say “Let’s test!” 🙂

Cheers,
Nils

is it time to negotiate your raise in 2025, or start looking for a new job?

Last month, I sent out an email requesting you to participate in this year’s PPC Salary Survey.

If you participated in the survey, on behalf of Duane (the driving force behind the initiative) and myself: a big Thank You!

Special thanks to all you Dutch PPC folks on my list who participated. The Netherlands cracked the top 3 countries for responses this year for the first time! 

Salary transparency is so important to make things fair for everyone. Data like this gives us PPC peepz some bargaining power!

You can (re)negotiate salary for your current role (or a new one) at your employer, or use it in negotiations with a new employer.

This week, Duane shared the results. Here you go:

Final Report: PPC Salary Survey 2025
https://www.reddit.com/r/PPC/comments/1je1p92/ppc_salary_survey_2025_final_report_10th_year/

– Nils

Nolan’s Law

Nolan’s Law is an aphorism often attributed to Atari founder Nolan Bushnell, on the subject of video game design:

“All the best games are easy to learn and difficult to master. They should reward the first quarter and the hundredth.”

The same goes for Google Ads Scripts.

Getting started with scripts is easy to learn. And rewarding.

Writing advanced scripts from scratch (even with the help of AI) is hard to master. And rewarding.

Here’s the thing: the journey of scripts is rewarding every step of the way.

Start small. Install one or two “simple” scripts to get a feel of what is possible.

Increase complexity. Make some minor changes to the code and see what happens.

Challenge yourself. Create a very basic script yourself (with the help of ChatGPT).

Go play and have some fun.

Here’s a link to get you started on your journey:
https://nilsrooijmans.com/sitemap/

Happy scripting!

– Nils

[Google Ads Script] Disapproved Ads Alerts & Google’s Review Process

Google is clarifying its Google Ads review process to explain how it detects policy violations. See details here: https://support.google.com/adspolicy/answer/7187501 

TL;DR:
The review process combines Google’s AI and human evaluation (and is constantly changing). Google disapproves ads, assets, destinations, accounts, and other content that violate Google policies.

In determining whether an advertiser or destination is violating their policies, Google reviews information from a variety of sources, including:

  • ads
  • accounts
  • websites
  • content
  • user complaints
  • consumer reviews
  • regulatory warnings and rulings
  • other third-party sources

Google’s enforcement technologies use Google’s AI, modeled on human reviewers’ decisions. The policy-violating content is either blocked from serving by Google’s automated models or by manual review. Where a more nuanced determination is required, content is flagged for further review by trained operators and analysts who conduct content evaluations that might be difficult for algorithms to perform alone (for example, because an understanding of the context of the ad and account is required). The results of these manual reviews are then used to help build training data to improve the machine-learning models.

Here’s the thing:
These automated procedures often make mistakes (AI generates “false positives“). Google often disapproves your ads for faulty reasons. You need to apply for a review when that happens — otherwise, your ads won’t show!

Luckily, I have a script which can help you detect when Google disapproves your ads. (Note that not all disapprovals get reported via notifications in the Google Ads UI.)

[Google Ads Script] Disapproved Ads Alerts

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

What it does:
This script checks all enabled ads in your enabled ad groups and campaigns and sends an email alert if any of the ads are disapproved. It will add the alert to a Google Sheet so you’ll have a clear overview of the issues.

Why you’d care: 
Ads get disapproved for numerous reasons, and not all disapprovals get your attention. Google uses its AI to review your ads, and this AI often makes mistakes and often disapproves ads that have been running for months without problems. When this happens, you want to be in the know. This script assures that you are.

– Nils

5 steps for finding the time to learn how to automate your PPC tasks

Here’s how to FIND the time to learn how to CREATE more time:

  1. Decide the right time for automation of your PPC tasks is NOW.
  2. Open your calendar application.
  3. Create a recurring weekly appointment on Thursdays called SCRIPTING from 9am to noon.
  4. Install/change/create at least one Google Ads Script (use my list and starting guide for inspiration).
  5. There is no step 5.

Just try it.

Just Script It.

Once you get started, it’s easier than you think.

– Nils

[ad copy] heartbreaking

“For Sale: Baby Shoes, Never Worn”



Pause.



Read that headline above again.

There are two types of reactions to this headline: 

  • Type 1 – I am crying. The baby never made it to the point where the shoes would fit. My heart is broken.
  • Type 2 – Baby feet grow fast. They must have bought the wrong size. Let’s have ’em.

If your reaction was in camp 1, consider the power of the emotion it raised.

Can you write headlines that do the same?

– Nils

Want to get paid what you’re worth in 2025?

Today, I am sharing a request from our PPC friend and long-time member of the community Duane Brown:

Wow. 10 year mark. I don’t even know what to say as I just launched this as an off-the-wall idea 10 years ago. Thank you for making this possible. 

Companies make salaries a black box affair because they want to pay everyone as little as possible. I’ve been told more than a few times not to discuss what I made with others.

Learning what the industry pays your colleague or that new junior hire means that none of us are leaving money on the table. 

Even now as someone who runs an agency, I strongly believe this to be true.

Last year, we got 1,060 responses. Survey Closes Feb 28th, 2025 midnight PST.

Let’s give Duane a hand: fill out the PPC Salary Survey. 

Here’s the link:
https://docs.google.com/forms/d/e/1FAIpQLSex4uhXPCA18aaMDQ22b6XyFEhZWRv6uPPMMx5JBRByPgNtpw/viewform

Results will launch by March 22nd, 2025.

– Nils

Frustrated with ChatGPT not delivering your perfect Google Ads Script?

Are you struggling to get ChatGPT to whip up that perfect Google Ads Script for you?

I’m prepping my next online workshop and offering you my help in return for your questions/challenges.

If you have a great script idea and have had a hard time getting ChatGPT to create it, answer these questions below and I will try my best to help you (for free).

1. What is your script idea? (The more detail, the better.)
2. What prompt(s) did you use to get ChatGPT to create it for you?
3. Where and how did you get stuck? (Again, the more detail, the better.)

I cannot promise that I will completely solve your challenge, but I can promise a response that will get you at least one step closer to script victory!

– Nils

Tag Assistant No Longer Deprecated

I completely missed this one, and I think it is worth a mention:

I am a big fan of Google Tag Assistant, the Chrome extension to easily troubleshoot and resolve issues with Google Tag Manager and Google Tag implementations.

But then, Google did what Google does: the extension was marked as deprecated! It was replaced by the far less intuitive Tag Assistant Companion.

Oh no! The horror!

It haunted my nights. Sleep became a distant memory thanks to this.

Nah… not really.

There are lots of other ways to check my tags. 

But still, I loved the ease of using the Tag Assistant.

Luckily and surprisingly(?): Google has listened to user feedback and combined Tag Assistant Legacy and Tag Assistant Companion into a single, unified extension.

Yep, Tag Assistant FTW. 

You can quickly and easily test your tags with a single click on that lovely icon at the top of your browser.

All you have to do is UNINSTALL Tag Assistant Companion and re-install the Tag Assistant extension.

Here’s the link: https://chromewebstore.google.com/detail/tag-assistant/kejbdjndbnbjgmefkgdddjlbokphdefk

– Nils

‘mobileappcategory::69500’

Last week, I shared a little tactic to quickly and easily exclude all mobile apps from your PMax campaigns. You can find the details here: https://nilsrooijmans.com/excluding-all-mobile-app-placements-the-quicker-and-easier-way/

A long-time member of the list, Hana Kobzová (name shared with permission), sent a question that was asked often on LinkedIn and Reddit:

“Why do you recommend adding all those values from your list? Doesn’t ‘mobileappcategory::69500’ already cover everything?”

Great question! I also use ‘mobileappcategory::69500’ a lot for Display campaigns. It’s a quick way to easily add all mobile app categories to the placement exclusions in Display.

However, here’s the thing: Performance Max campaigns DO NOT have the option to exclude placement at the campaign level.

To exclude placements from PMax, you need to add the exclusion at the account level.

Also, the little trick of entering ‘mobileappcategory::69500’ in your placement exclusions does not work at the account level. Currently, the Google Ads UI throws this message when you try to save the exclusion: “An error occurred. Please try again or check your targeting options.”

So, as far as I know, copy-pasting all the Mobile App Placement Exclusions from this list and adding them at the account level is the quickest way to exclude all mobile apps from your PMax campaigns.

– Nils

PS: Want to always be in the know when Performance Max campaigns are showing your ads on bad placements?  Check out my Google Ads Script for PMax Placement Exclusions.

how to add Google Ads ad schedules in bulk

Have you ever wanted to add the same ad schedule to tens of campaigns?

I did. Today.

And trust me, the Google Ads UI makes it very hard to do this without you wanting to send your mouse on a one-way trip to the wall! You literally have to click each and every individual campaign, and add the ad schedules for each individual campaign… over and over again.

Luckily, there’s a quicker way: using Google Ads Editor.

Here’s how you do it:

1) Create your desired ad schedule in one campaign
2) Open Google Ads Editor
3) Download your campaigns
4) Select “Campaigns” in the type list
5) Select the campaign with the ad schedule you want to copy
6) Select “Edit” > “Copy shell”
7) Select all the campaigns where you want to paste the schedule
8) Click the Paste icon in the edit panel next to “Ad schedule”

That’s it. This saved my mouse from its funeral today.

– Nils

Tired of clicking 140 categories to exclude Mobile App Placements? Here’s the quicker way.

Two weeks ago, we talked about excluding Mobile App Placements in your PMax campaigns. (Remember? You don’t want PMax to show your ads on apps that only attract accidental clicks from the under-aged).

As we learned, excluding Mobile Apps at the account level is currently the only way to exclude them from your PMax campaigns. And, excluding them at the account level requires you to check 140 (!) Mobile App categories in the Google Ads interface.

140+ clicks just to protect your PMax campaigns from these dreadful placements that only waste your budget. Talk about time sinks…

Not anymore!

Thanks to a suggestion by fellow list member Maciej Ilczyszyn (name shared with permission), I learned about a quicker solution.

I created a script to pull all Mobile App Categories from the Google API and report them in a Google Sheet so we can use them to exclude them more easily!

Here’s how to quickly and easily exclude ALL Mobile App Placements from your account (and PMax campaigns): simply go to this Google Sheet, copy all the values in column B ‘Mobile App Placement Exclusions’, and add them to your exclusions.

That’s it! No more checking 140 boxes.

More details here: https://nilsrooijmans.com/excluding-all-mobile-app-placements-the-quicker-and-easier-way/

Note: the script automatically updates the list daily with the latest Mobile App Categories. This way, we are always up to date. 🙂

– Nils

PS: Want to always be in the know when Performance Max campaigns are showing your ads on bad placements?  Check out the latest version of my Google Ads Script for PMax Placement Exclusions.

next time a Google Ads Rep reaches out, ask for this

A new year often means new targets. New targets for your clients, new targets for you, AND new targets for Google Ads Reps!

Already, the reps are reaching out like crazy to me and my clients.

If the same thing is happening to you, here’s the reply I would recommend:

Hey {name},

Thanks for reaching out, and all the best for the new year to you as well!

To kick off the new year, we want to focus on reducing wasted ad spend. For starters: can you enable the ‘Negative Keywords in PMax Campaigns’ feature in our accounts?

Next to that, if you have any other concrete suggestions to reduce wasted ad spend (based on our account’s data), please share them via email.

This will also enable us to see if scheduling your suggested call would benefit us both.

Thanks a ton already,

This reply will either get the rep off your back, or actually give you some meaningful result.

And, yes, you read that right! Google is (finally!) rolling out Negative Keywords for Performance Max campaigns (still in Beta). See details here: https://support.google.com/google-ads/answer/15726455

If you want to see what negative keywords you should add to your PMax campaigns to reduce wasted spend, run my script: https://nilsrooijmans.com/google-ads-script-pmax-non-converting-search-term-alerts/

If you want to learn how to add negative keywords to your PMax campaigns, read this:
https://nilsrooijmans.com/how-to-add-negative-keywords-to-your-performance-max-campaigns/

– Nils