← Back to blog
AdMob · TypeScript · Indie

Pulling AdMob revenue from the Reporting API

Fetch AdMob ad revenue directly via the Reporting API — OAuth (admob.readonly), the networkReport POST, NDJSON and micros parsing, currency gotchas. Compared with App Store Connect.


horog needs ad revenue in the $/hour math too, so I added AdMob. It turned out pretty different from App Store Connect. App Store is JWT (ES256) signing + gzipped TSV; AdMob is Google OAuth 2.0 + NDJSON + micros. Here are the differences and the gotchas I hit.

What you need from Google

  1. Google Cloud Console → APIs & Services → enable the AdMob API
  2. Create an OAuth 2.0 Client (Web) → Client ID / Client Secret
  3. Scope admob.readonly — reports only. No ad-unit creation, no payout data
  4. Add your Google account as a test user on the consent screen
Even with the same Google account as Google Calendar, a different scope means a fresh consent. horog keeps a separate OAuth client for AdMob.

OAuth — not JWT

App Store signs a JWT per request from one key; AdMob is the standard Google OAuth authorization-code flow. The key bit is access_type=offline + prompt=consent — that’s what gets you a refresh_token. Without it you only get an access token that dies in an hour.

typescript
// AdMob isn't JWT like App Store — it's plain Google OAuth 2.0.
// admob.readonly scope + access_type=offline to get a refresh_token.
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", process.env.ADMOB_OAUTH_CLIENT_ID!);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set(
  "scope",
  "https://www.googleapis.com/auth/admob.readonly openid email profile",
);
url.searchParams.set("access_type", "offline"); // required to get refresh_token
url.searchParams.set("prompt", "consent");

// In the callback, exchange code -> token (POST oauth2.googleapis.com/token,
// grant_type=authorization_code). Refresh later with grant_type=refresh_token.

The report call — POST + JSON

First call GET /v1/accounts to find the publisher (usually one per user, pub-XXXXXXXXXXXX). Then call networkReport:generate — which is a POST with a JSON body, not a GET.

typescript
// Gotcha 1: the report call is POST + JSON body, not GET.
// publisherId comes from GET /v1/accounts (usually one per user).
const res = await fetch(
  `https://admob.googleapis.com/v1/accounts/${publisherId}/networkReport:generate`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reportSpec: {
        dateRange: {
          startDate: { year: 2026, month: 6, day: 1 },
          endDate: { year: 2026, month: 6, day: 7 },
        },
        dimensions: ["DATE", "APP"],
        metrics: ["ESTIMATED_EARNINGS", "IMPRESSIONS", "CLICKS"],
        // Gotcha 2: pin the currency, or it comes in the account currency
        localizationSettings: { currencyCode: "USD", languageCode: "en-US" },
      },
    }),
  },
);

Parsing — NDJSON + micros

The response isn’t a single JSON object — it’s NDJSON. One object per line: header first, data rows in the middle, footer last. Parse line by line and keep only the ones with a row key. And earnings are in micros, so divide by a million to get dollars.

typescript
// Gotcha 3: the response is NDJSON — one JSON object per line
// (header -> rows -> footer).
const text = await res.text();
for (const line of text.trim().split("\n")) {
  const obj = JSON.parse(line);
  if (!("row" in obj)) continue; // skip the header / footer lines

  const date = obj.row.dimensionValues.DATE.value;       // "20260607" (YYYYMMDD)
  const app = obj.row.dimensionValues.APP.displayLabel;  // app name

  // Gotcha 4: ESTIMATED_EARNINGS is micros (1 USD = 1,000,000)
  const usd =
    Number(obj.row.metricValues.ESTIMATED_EARNINGS.microsValue) / 1_000_000;
}

Dates arrive as 20260607-style YYYYMMDD strings. For per-app numbers, use the APP dimension’s displayLabel.

Gotchas I actually hit

  1. Not JWT — Google OAuth 2.0. No access_type=offline, no refresh_token
  2. Report is POST networkReport:generate with a reportSpec JSON body
  3. NDJSON — one JSON per line. Don’t mix the header/footer rows into your data
  4. microsESTIMATED_EARNINGS is 1 USD = 1,000,000. Sum it raw and you’re a million× off
  5. currency — comes in the account currency. Pin localizationSettings.currencyCode or convert yourself
  6. estimates — ESTIMATED_EARNINGS is an *estimate*. It can drift a few % from the finalized month-end payout. Fine for a rate trend, not for accounting
  7. 401 → refresh — refresh the access token once and retry on expiry

Versus App Store

  • Auth: App Store = JWT (ES256, 20 min) / AdMob = OAuth refresh_token
  • Report: App Store = GET → gzip TSV / AdMob = POST → NDJSON
  • Money: App Store = decimal string / AdMob = micros integer
  • Lag: App Store = D+1 / AdMob = same-day partial (estimated)
horog combines revenue and time from 9 integrations (App Store, Google Play, AdMob, Stripe, and more) into the real $/hour per side project. Start free.
Pulling AdMob revenue from the Reporting API — horog · horog