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
- Google Cloud Console → APIs & Services → enable the AdMob API
- Create an OAuth 2.0 Client (Web) →
Client ID/Client Secret - Scope
admob.readonly— reports only. No ad-unit creation, no payout data - Add your Google account as a test user on the consent screen
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.
// 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.
// 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.
// 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
- Not JWT — Google OAuth 2.0. No
access_type=offline, no refresh_token - Report is POST —
networkReport:generatewith areportSpecJSON body - NDJSON — one JSON per line. Don’t mix the header/footer rows into your data
- micros —
ESTIMATED_EARNINGSis 1 USD = 1,000,000. Sum it raw and you’re a million× off - currency — comes in the account currency. Pin
localizationSettings.currencyCodeor convert yourself - 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
- 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)