taupe Building, using, and running it.

Article

Daily AdSense and GA4 reports by email, with Google Apps Script

Daily AdSense and GA4 reports by email, with Google Apps Script

Every morning I used to open AdSense to see yesterday’s earnings and today’s running total, then open GA4 and do the same for traffic. Each step is trivial. Doing it daily is the annoying part.

So I moved it into Google Apps Script: pull AdSense and GA4 numbers, email them at 7am and 6pm. Same content both times — the morning one is yesterday’s near-final numbers plus today’s progress, the evening one is how today actually went.

Originally published in Japanese on January 3, 2026. This English version was written in September 2026 and re-checked against current Google documentation.

Full setup and code below, written to be copy-pasteable. One warning up front that the original Japanese version got wrong, and that will silently break your script after a week: if you link Apps Script to a standard Cloud project and leave the OAuth consent screen on “Testing” with External user type, Google issues refresh tokens that expire in seven days. More on that where it belongs.

This is a snapshot of a working configuration, not a guarantee. API permissions, metrics, and quotas change. Verify against the AdSense Management API v2 reports.generate reference, the Google Analytics Data API v1beta runReport reference, and the Apps Script quota page before you rely on it.

What you get

A plain-text email on a schedule, containing:

  • AdSense estimated earnings — today so far, yesterday (compared with the same weekday last week), and month to date (compared with the same period last year)
  • GA4 users and views — all properties combined, and per property

Roughly this shape:

AdSense / GA4 daily report
2026-01-03 18:52

────────────────────────
 Google AdSense
────────────────────────

Today (so far)
  10.00

Yesterday
  12.00  (vs same weekday last week: +1.00)

This month (1st to today)
  36.00  (vs same period last year: +5.00)

────────────────────────
 Google Analytics (GA4)
────────────────────────

All properties
  [Today]      Users: 10,000   Views: 15,000
  [Yesterday]  Users: 10,400 (YoY +1,000)   Views: 17,000 (YoY +2,000)

example.com
  [Today]      Users: 3,000   Views: 5,000
  [Yesterday]  Users: 3,200 (YoY +300)   Views: 6,000 (YoY +800)

────────────────────────
Today's figures are provisional.
Sent automatically.
────────────────────────

Overall shape

  • Create a Google Cloud project
  • Configure the OAuth consent screen
  • Enable the AdSense Management API and the Google Analytics Data API
  • Link Apps Script to the Cloud project
  • Add scopes in appsscript.json
  • Set script properties (recipient, GA property list)
  • Paste the code, run it, authorize
  • Add triggers

1. Create a Cloud project

In the Cloud Console, create a new project. Note the project number — Apps Script needs the number, not the ID.

2. OAuth consent screen — read this part carefully

Set the user type to External, give the app a name, and set support and developer contact emails. Add your own Google account as a test user.

Navigation note: this used to live under APIs & Services > OAuth consent screen. The Cloud console has since reorganized it under Google Auth Platform, whose sections are Overview, Branding, Audience, Clients, Data Access, and Verification Center. Google’s own docs still use the phrase “OAuth consent screen” for the same configuration, so both names refer to the thing you are looking for.

The Japanese original said to leave it in Testing and that this is fine for personal use. That advice is wrong for a script on a daily trigger.

Google documents that a Cloud project with an OAuth consent screen configured for External user type and a publishing status of Testing is issued refresh tokens that expire in seven days, unless the only scopes requested are a subset of name, email address, and user profile. This script requests AdSense and Analytics scopes, so it is not in that exempt subset. The support documentation says the same from the other direction: authorizations by a test user expire seven days from the time of consent, and any refresh token issued expires with them.

The symptom is distinctive: everything works for a week, then the trigger starts failing with an authorization error, and re-running manually fixes it for another week. If you are seeing that, this is why.

Two ways out. Publish the consent screen to In production, which removes the seven-day expiry. Or skip step 4 entirely and stay on the Apps Script default Cloud project, whose consent screen is generated automatically and is not subject to the test-user expiry — you lose the ability to customize the consent screen and to view logs in the Cloud console, neither of which this script needs.

One more prerequisite: the account that administers AdSense and the account that runs the script must be the same, or AdSense data will not come back.

3. Enable the APIs

APIs & Services > Library, then enable both:

  • AdSense Management API
  • Google Analytics Data API

4. Link Apps Script to the Cloud project

In the Apps Script editor, open Project Settings and set the Google Cloud Platform project to the project number from step 1.

Note that moving from the default project to a standard one is a one-way change, and it forces users to re-authorize. Given the seven-day issue above, only do this if you are also publishing the consent screen.

5. Enable the AdSense advanced service

In the Apps Script editor sidebar, under Services, add AdSense (v2).

GA4 is called directly over UrlFetchApp in this script, so the Analytics Data advanced service is not required. Be aware that this is not the route Google documents. The documented way to reach the Data API from Apps Script is the Analytics Data advanced service (AnalyticsData.Properties.runReport(request, 'properties/' + propertyId)). I use UrlFetchApp because it keeps the request body identical to the REST reference, which makes it easier to debug against the docs — but if you would rather stay on a supported path, enable the advanced service and rewrite gaReport_ against it. Either way you still need the analytics.readonly scope.

6. appsscript.json

Turn on “Show appsscript.json manifest file” in Project Settings, then add the scopes.

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      { "userSymbol": "AdSense", "version": "v2", "serviceId": "adsense" }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/adsense.readonly",
    "https://www.googleapis.com/auth/analytics.readonly",
    "https://www.googleapis.com/auth/script.send_mail",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/userinfo.email"
  ]
}
  • adsense.readonly — read AdSense reports
  • analytics.readonly — read GA4 reports
  • script.send_mail — send via MailApp
  • script.external_request — call the GA4 API via UrlFetchApp
  • userinfo.email — read the address of the account running the script, which getReportTo_ falls back to when REPORT_TO is unset

Set timeZone to yours; it determines what “today” means in every date calculation below.

7. Script properties

Keep configuration out of the code. Project Settings > Script Properties.

REPORT_TO (optional) — the destination address. If unset, it goes to the account running the script.

GA_PROPERTIES (required) — your GA4 properties as JSON. This is what makes multi-site reporting work.

[
  {"label":"example.com","propertyId":"123456789"},
  {"label":"foo.com","propertyId":"123456790"},
  {"label":"demo.site","propertyId":"123456791"}
]

Property IDs come from GA4 under Admin > Property settings.

8. The script

Two changes from the version I originally published, both to reduce API calls: the AdSense account is resolved once instead of on every report, and per-site GA4 figures are summed locally rather than re-fetched for the combined total. That takes the GA4 request count from six per property to three.

function sendDailyReport() {
  const tz = Session.getScriptTimeZone() || 'Etc/UTC';
  const now = new Date();

  const dToday = formatDate_(now, tz);
  const dYesterday = formatDate_(addDays_(now, -1), tz);
  const dLastWeekSameDay = formatDate_(addDays_(now, -8), tz); // yesterday's weekday, a week earlier
  const monthStart = formatDate_(new Date(now.getFullYear(), now.getMonth(), 1), tz);

  const lastYearTodayStr = formatDate_(shiftYear_(now, -1), tz);
  const lastYearMonthStart =
    formatDate_(new Date(now.getFullYear() - 1, now.getMonth(), 1), tz);

  const lastYearYesterdayStr = formatDate_(shiftYear_(addDays_(now, -1), -1), tz);

  // --- AdSense: resolve the account once, then five report calls ---
  const account = getAdsenseAccountName_();
  const ads = {
    today: adsenseEarnings_(account, dToday, dToday),
    yesterday: adsenseEarnings_(account, dYesterday, dYesterday),
    lastWeekSame: adsenseEarnings_(account, dLastWeekSameDay, dLastWeekSameDay),
    thisMonth: adsenseEarnings_(account, monthStart, dToday),
    thisMonthLastYear: adsenseEarnings_(account, lastYearMonthStart, lastYearTodayStr),
  };

  // --- GA4: three calls per property, combined total summed locally ---
  const bySite = getGaProperties_().map(p => ({
    label: p.label,
    today: gaReport_(p.propertyId, dToday, dToday),
    yesterday: gaReport_(p.propertyId, dYesterday, dYesterday),
    yesterdayLY: gaReport_(p.propertyId, lastYearYesterdayStr, lastYearYesterdayStr),
  }));

  const all = {
    today: sumMetrics_(bySite.map(s => s.today)),
    yesterday: sumMetrics_(bySite.map(s => s.yesterday)),
    yesterdayLY: sumMetrics_(bySite.map(s => s.yesterdayLY)),
  };

  MailApp.sendEmail(
    getReportTo_(),
    `AdSense / GA4 daily report (${dToday})`,
    buildMailBody_({ now, tz, ads, ga: { all, bySite } })
  );
}

function getReportTo_() {
  const to = (PropertiesService.getScriptProperties().getProperty('REPORT_TO') || '').trim();
  return to || Session.getEffectiveUser().getEmail();
}

/* ========== AdSense Management API v2 ========== */

function getAdsenseAccountName_() {
  const accounts = AdSense.Accounts.list();
  if (!accounts?.accounts?.length) throw new Error('No AdSense account found.');
  return accounts.accounts[0].name;
}

function adsenseEarnings_(accountName, startDate, endDate) {
  const res = AdSense.Accounts.Reports.generate(accountName, {
    dateRange: 'CUSTOM',
    metrics: ['ESTIMATED_EARNINGS'],
    ...dateToJson_('startDate', parseYmd_(startDate)),
    ...dateToJson_('endDate', parseYmd_(endDate)),
  });
  const v = Number(res?.totals?.cells?.[0]?.value ?? 0);
  return isFinite(v) ? v : 0;
}

function parseYmd_(yyyyMmDd) {
  const [y, m, d] = yyyyMmDd.split('-').map(n => parseInt(n, 10));
  return new Date(y, m - 1, d);
}

function dateToJson_(prefix, dateObj) {
  return {
    [`${prefix}.year`]: dateObj.getFullYear(),
    [`${prefix}.month`]: dateObj.getMonth() + 1,
    [`${prefix}.day`]: dateObj.getDate(),
  };
}

/* ========== Google Analytics Data API v1beta ========== */

function gaReport_(propertyId, startDate, endDate) {
  const url =
    `https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`;
  const payload = {
    dateRanges: [{ startDate, endDate }],
    metrics: [{ name: 'activeUsers' }, { name: 'screenPageViews' }],
  };
  const resp = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
  const code = resp.getResponseCode();
  if (code < 200 || code >= 300) {
    throw new Error(`GA runReport error (${code}): ${resp.getContentText()}`);
  }
  const row = JSON.parse(resp.getContentText())?.rows?.[0]?.metricValues || [];
  return {
    users: row[0] ? Number(row[0].value) || 0 : 0,
    views: row[1] ? Number(row[1].value) || 0 : 0,
  };
}

function sumMetrics_(list) {
  return list.reduce(
    (acc, r) => ({ users: acc.users + r.users, views: acc.views + r.views }),
    { users: 0, views: 0 }
  );
}

function getGaProperties_() {
  const raw =
    (PropertiesService.getScriptProperties().getProperty('GA_PROPERTIES') || '').trim();
  if (!raw) throw new Error('Script property GA_PROPERTIES is not set.');
  const arr = JSON.parse(raw);
  if (!Array.isArray(arr) || arr.length === 0) {
    throw new Error('GA_PROPERTIES is malformed.');
  }
  return arr;
}

/* ========== Mail body ========== */

function buildMailBody_(ctx) {
  const rule = '────────────────────────';
  const L = [];

  L.push('AdSense / GA4 daily report');
  L.push(Utilities.formatDate(ctx.now, ctx.tz, 'yyyy-MM-dd HH:mm'));
  L.push('', rule, ' Google AdSense', rule, '');
  L.push('Today (so far)');
  L.push(`  ${fmtMoney_(ctx.ads.today)}`, '');
  L.push('Yesterday');
  L.push(
    `  ${fmtMoney_(ctx.ads.yesterday)}  (vs same weekday last week: ` +
    `${signedMoney_(ctx.ads.yesterday - ctx.ads.lastWeekSame)})`, ''
  );
  L.push('This month (1st to today)');
  L.push(
    `  ${fmtMoney_(ctx.ads.thisMonth)}  (vs same period last year: ` +
    `${signedMoney_(ctx.ads.thisMonth - ctx.ads.thisMonthLastYear)})`, ''
  );

  L.push('', rule, ' Google Analytics (GA4)', rule, '');
  L.push('All properties');
  L.push(...siteLines_(ctx.ga.all));

  ctx.ga.bySite.forEach(s => {
    L.push('', s.label);
    L.push(...siteLines_(s));
  });

  L.push('', '', rule);
  L.push("Today's figures are provisional.");
  L.push('Sent automatically.');
  L.push(rule);

  return L.join('\n');
}

function siteLines_(s) {
  return [
    `  [Today]      Users: ${fmtInt_(s.today.users)}   Views: ${fmtInt_(s.today.views)}`,
    `  [Yesterday]  Users: ${fmtInt_(s.yesterday.users)} ` +
      `(YoY ${signedInt_(s.yesterday.users - s.yesterdayLY.users)})   ` +
      `Views: ${fmtInt_(s.yesterday.views)} ` +
      `(YoY ${signedInt_(s.yesterday.views - s.yesterdayLY.views)})`,
  ];
}

/* ========== utils ========== */

const LOCALE = 'en-US';

// setFullYear() alone turns Feb 29 into Mar 1 of the previous year, which
// silently shifts the year-on-year window. Clamp to the target month's last day.
function shiftYear_(date, years) {
  const d = new Date(date);
  const day = d.getDate();
  d.setDate(1);
  d.setFullYear(d.getFullYear() + years);
  d.setDate(Math.min(day, new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()));
  return d;
}

function formatDate_(date, tz) {
  return Utilities.formatDate(date, tz, 'yyyy-MM-dd');
}

function addDays_(date, n) {
  const d = new Date(date);
  d.setDate(d.getDate() + n);
  return d;
}

function fmtMoney_(n) {
  return (Math.round((Number(n) || 0) * 100) / 100).toLocaleString(LOCALE, {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

function fmtInt_(n) {
  return (Number(n) || 0).toLocaleString(LOCALE);
}

function signedMoney_(diff) {
  const v = Math.round((Number(diff) || 0) * 100) / 100;
  if (v > 0) return `+${fmtMoney_(v)}`;
  if (v < 0) return `-${fmtMoney_(Math.abs(v))}`;
  return '0.00';
}

function signedInt_(diff) {
  const v = Number(diff) || 0;
  if (v > 0) return `+${fmtInt_(v)}`;
  if (v < 0) return `-${fmtInt_(Math.abs(v))}`;
  return '0';
}

Run sendDailyReport once by hand, accept the authorization prompt, and confirm the email arrives.

Notes on the two APIs

AdSense. v2 is current — it is the only version with a discovery document in the reference index. accounts.reports.generate is a GET with query parameters — which is why the dates go in as startDate.year, startDate.month, startDate.day rather than as a nested object. ESTIMATED_EARNINGS is the documented metric, and it is estimated: recent days move before they settle. Two v2 constraints to know, both stated in the v2 release note: AdMob and YouTube properties are no longer supported, and the API only serves report data going back three years.

Amounts come back in your AdSense account’s currency, with no symbol. The formatter above prints a bare number for that reason — add your own symbol if you want one.

GA4. v1beta is still the recommended channel; there is no GA v1 to migrate to. Google’s own wording is narrower: “No breaking changes are expected in this channel.”

One naming caveat: screenPageViews is GA4’s views metric, corresponding to “Views” in the GA4 interface. It sums page_view and screen_view events, so it is the right choice for pageview-like reporting but is not identical to the old Universal Analytics pageviews metric. Treat year-over-year comparisons that cross your UA migration accordingly.

Data API quotas are token-based rather than request-based: 200,000 tokens per property per day and 40,000 per hour on standard properties, with a limit of 10 concurrent requests per property. A few daily reports will not come close. Add "returnPropertyQuota": true to the request body if you want to see consumption in the response.

9. Triggers

In the Apps Script editor, under Triggers, add two:

  • Function sendDailyReport, time-driven, day timer, 7am to 8am
  • Same function, day timer, 6pm to 7pm

Time-driven triggers fire at a randomized point within the hour you choose, which is fine for a daily digest.

Relevant Apps Script quotas: 100 email recipients per day on a consumer account (1,500 on Workspace), 20,000 URL Fetch calls per day (100,000 on Workspace), six minutes of runtime per execution, and 90 minutes of total trigger runtime per day on a consumer account (six hours on Workspace). Two executions a day with a handful of properties sits far inside all of those.

Two limitations worth knowing before you run this on your own numbers. It takes accounts[0] unconditionally, so if your Google account has more than one AdSense account you will silently report on whichever one comes back first — pin the account name if that is you. And the AdSense and GA4 calls are not isolated from each other: if either fails, the script throws before MailApp.sendEmail runs, so you get no mail at all rather than a partial report. For a daily report whose job is to tell you something is wrong, decide deliberately which of those you want.

Why this changed my behavior

The surprise was not the time saved. It was that numbers arriving unprompted get acted on, while numbers you have to go and fetch mostly get looked at. “This is climbing, let me extend that post” happens far more often now.

AdSense is mainly a blogger’s concern; if you do not run ads, delete that half and the GA4 reporting stands on its own. Either way, a few seconds a day compounds, and the friction of going to look is what the automation actually removes.

References

Next

These notes come from running this setup daily.

About the author

Hidekazu Ishikawa

Hidekazu Ishikawa builds and runs web products with AI agents from Japan. Available for consulting on AI workflow design and web development.

Next

Keep reading.