●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Build a Mobile App Revenue Dashboard with Claude Code — Integrating AdMob, App Store Connect, and Google Play Console APIs with Automated Daily Reports
A production-ready guide to unifying AdMob, App Store Connect, and Google Play Console revenue data into a single dashboard, with automated daily Slack reports. The definitive revenue visibility setup for indie mobile developers.
If you've ever shipped a couple of iOS and Android apps, you know this moment well: it's the end of the month, you want to know how much you made, and suddenly you're bouncing between three different admin panels — AdMob, App Store Connect, and Google Play Console — copying numbers into a spreadsheet by hand.
I lived that life for longer than I care to admit. Finding out "how much did I earn this month" took thirty minutes of clicking. This article is the distilled knowledge from the week I spent with Claude Code rebuilding all of that into one dashboard, running on free-tier infrastructure.
This is not "read the docs and you'll figure it out" content. Each of the three platforms has its own auth style, response format, and rate-limit quirks, and the docs alone won't save you from the sharp edges. I'll share the specific places I got stuck, the code that unblocked me, and the questions I asked Claude Code when I was spinning.
What the Dashboard Needs to Solve
Before we write any code, let's pin down what we're actually building. Vague goals always balloon into feature creep.
See daily revenue, downloads, and ARPU across all three platforms on one screen.
Plot a 30-day trend to spot anomalies.
Compare the launch velocity of a newly released app against older apps.
Get a yesterday summary posted to Slack every morning automatically.
Stay inside the Cloudflare Workers free tier (100k requests/day) so we're not paying for infrastructure to track indie revenue.
That last constraint is more important than it sounds. Paying a few thousand yen per month for an indie revenue dashboard defeats the purpose. We design around the free tier from day one.
Why Pairing with Claude Code Speeds This Up
Here's the honest answer: the three APIs are harder than the docs suggest, and Claude Code compresses the research time.
App Store Connect's Sales Reports API returns gzip-compressed TSV files, not JSON. Google Play's Reporting API uses service-account OAuth2, and data only finalizes the business day after month-end. AdMob's Network Report API is flexible but requires specific filter syntax to split by app.
Claude Code's strength is that once you teach it these quirks, they stick within the project. Drop "App Store Connect API returns gzip-compressed TSV" into CLAUDE.md, and every subsequent file it generates respects that assumption. You stop rewriting parsers. That's a bigger productivity win than it first seems.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Stop juggling three separate admin panels — fetch revenue data from all platforms into a unified schema
✦Master the three very different auth flows (JWT with P8 keys, OAuth2 service accounts) and stop getting stuck on 401 errors
✦Ship a Cloudflare Workers + KV pipeline that posts yesterday's revenue summary to Slack every morning automatically
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Cloudflare Workers — Cron Triggers for daily batch jobs. Zero cold-start cost and a generous free tier.
Cloudflare KV — Persistence for daily aggregates. prefix list supports date-range queries naturally.
Hono — Lightweight, type-safe framework that runs inside Workers.
Recharts — For dashboard charts. Lives in Next.js, not Workers.
Next.js + Cloudflare Pages — Dashboard UI that reads KV via an internal API route.
A key decision: the batch job and the UI are separate projects. Their runtime needs are different. The batch runs on a cron a few times a day; the UI has to respond fast to user clicks. Separating them means I can iterate on the UI without risk of breaking the ingestion pipeline.
Step 1: App Store Connect — Auth and Sales Reports
This is the first wall. App Store Connect's auth flow is nothing like the Google APIs, and if it's your first time, you will get stuck.
Signing Your Own JWT with a P8 Key
App Store Connect uses JWTs you sign yourself with an ES256 private key. Download the P8 file from your Apple Developer portal, then sign with jose or similar.
// src/auth/appstore.tsimport { SignJWT, importPKCS8 } from 'jose';interface AppStoreAuthConfig { issuerId: string; keyId: string; // the 10-char ID in the P8 filename privateKey: string; // contents of the P8 file (PEM)}/** * Generates a JWT for the App Store Connect API. * Apple caps validity at 20 minutes; we intentionally use 18. */export async function generateAppStoreJWT(config: AppStoreAuthConfig): Promise<string> { const privateKey = await importPKCS8(config.privateKey, 'ES256'); const jwt = await new SignJWT({ iss: config.issuerId, aud: 'appstoreconnect-v1', }) .setProtectedHeader({ alg: 'ES256', kid: config.keyId, typ: 'JWT', }) .setIssuedAt() .setExpirationTime('18m') .sign(privateKey); return jwt;}
Why 18 minutes instead of 20? Apple allows up to 20, but if the token expires mid-request you'll get a 401 that's hard to diagnose. Capping at 18 avoids the edge case where a long batch run sees a token flip mid-execution. This is the root cause of "mysterious occasional 401s" in production.
Parsing the gzip-Compressed TSV
Once the JWT works, you can call Sales Reports. Brace yourself for the response format.
Sharp edge: Without Accept: 'application/a-gzip', the API returns 404 with no hint that the header is the problem. It's in the docs but easy to miss if you're copying from a Stack Overflow snippet. I lost half an hour here.
Also, expect 404 for recent dates. Apple's daily data isn't available until a few hours after UTC midnight. Treat 404 as "data not ready yet" in your success path — if you treat it as a failure, your batch will alarm every morning.
Step 2: AdMob Network Report API
AdMob uses standard Google service-account OAuth2. Simpler than App Store Connect, but the query syntax has its own quirks.
Sharp edge: AdMob reports earnings as microsAmount — the real value multiplied by one million. Divide by 1,000,000 to get dollars or yen. Miss this and your revenue looks 100× larger than reality. I briefly thought I'd hit it big.
Also, AdMob's response mixes header, row, and footer elements in the same array. You have to filter for row before processing.
Step 3: Google Play Console Sales Data
Google Play's finalized revenue is exported as CSV to a Cloud Storage bucket. You have the Reporting API or the GCS approach. For finalized revenue, go with GCS — the Reporting API's numbers shift until the month closes.
// src/fetchers/playstore-sales.tsimport { GoogleAuth } from 'google-auth-library';import Papa from 'papaparse';export interface PlayStoreRow { date: string; productId: string; appName: string; amountMerchantCurrency: number; merchantCurrency: string;}export async function fetchPlayStoreSales( serviceAccountKeyJson: string, bucketName: string, // e.g. "pubsite_prod_rev_XXXXXXXXXX" yearMonth: string, // YYYYMM): Promise<PlayStoreRow[]> { const auth = new GoogleAuth({ credentials: JSON.parse(serviceAccountKeyJson), scopes: ['https://www.googleapis.com/auth/devstorage.read_only'], }); const client = await auth.getClient(); const token = (await client.getAccessToken()).token; const objectPath = `sales/salesreport_${yearMonth}.zip`; const url = `https://storage.googleapis.com/storage/v1/b/${bucketName}/o/${encodeURIComponent(objectPath)}?alt=media`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); if (\!res.ok) { throw new Error(`Play Store GCS error: ${res.status}`); } const zipBuffer = await res.arrayBuffer(); const csvText = await extractCsvFromZip(zipBuffer); const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true }); return (parsed.data as any[]).map((row) => ({ date: row['Transaction Date'], productId: row['Product id'], appName: row['Product Title'], amountMerchantCurrency: Number(row['Amount (Merchant Currency)'] || 0), merchantCurrency: row['Merchant Currency'], }));}async function extractCsvFromZip(buffer: ArrayBuffer): Promise<string> { const { unzipSync, strFromU8 } = await import('fflate'); const files = unzipSync(new Uint8Array(buffer)); const csvFile = Object.keys(files).find((name) => name.endsWith('.csv')); if (\!csvFile) throw new Error('No CSV found in Play Store ZIP'); return strFromU8(files[csvFile]);}
Why GCS over Reporting API: The Reporting API returns live numbers that keep shifting until month-end. For P&L-style reporting, you want the Financial Reports in GCS, which are final. Use the Reporting API for daily trend sniffing, and GCS for the monthly books.
Step 4: Wiring It Up in Cloudflare Workers
With three fetchers in place, we put them behind a cron handler.
Why Promise.allSettled instead of Promise.all: If one platform returns a 404 or 5xx, I still want to save the other two. Promise.all throws the moment one rejects, leaving you with a morning where Slack says "everything failed" when actually two-thirds of the data is fine.
Step 5: The Dashboard UI (Next.js + Recharts)
Once the Worker is writing to KV, the dashboard reads through an internal API route.
// app/api/revenue/route.tsimport { getCloudflareContext } from '@opennextjs/cloudflare';export async function GET(request: Request) { const { searchParams } = new URL(request.url); const days = Math.min(Number(searchParams.get('days') || '30'), 90); const { env } = getCloudflareContext(); const endDate = new Date(); const startDate = new Date(Date.now() - days * 86400_000); const results: any[] = []; for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const ymd = d.toISOString().slice(0, 10); const [appStore, adMob] = await Promise.all([ env.REVENUE_KV.get(`revenue:${ymd}:appstore`, 'json'), env.REVENUE_KV.get(`revenue:${ymd}:admob`, 'json'), ]); results.push({ date: ymd, appStoreTotal: sumOrZero(appStore, 'developerProceeds'), adMobTotal: sumOrZero(adMob, 'earnings'), }); } return Response.json(results);}function sumOrZero(data: any, key: string): number { if (\!Array.isArray(data)) return 0; return data.reduce((acc, r) => acc + (r[key] || 0), 0);}
Why an API route between the client and KV: Letting the client touch KV directly would leak auth material. Routing through a server endpoint keeps auth, rate limiting, and caching in one place. On Cloudflare Pages, the API route is itself a Worker, so there's no extra cost.
Pitfalls You'll Hit (and How to Avoid Them)
These are the mines I actually stepped on.
Pitfall 1: Wrong kid Causes an Infinite 401 Loop
The JWT header needs kid — the 10-character Key ID. It matches the XXXXXXXXXX portion of the P8 filename AuthKey_XXXXXXXXXX.p8, not your Issuer ID or Team ID. Apple's error message is just "401 Unauthorized" with zero hints. When I complained "the JWT looks right but I still get 401", Claude Code immediately asked me to double-check kid. That saved me from another hour of staring at the token.
Pitfall 2: node:zlib Inside Cloudflare Workers
gunzipSync is a Node built-in. On Workers it requires compatibility_flags = ["nodejs_compat"] in wrangler.toml. Miss it and your Worker won't even boot — you get zlib is not defined.
main = "src/worker.ts"compatibility_date = "2026-03-01"compatibility_flags = ["nodejs_compat"]
Pitfall 3: KV Same-Key Write Rate Limit
KV limits you to roughly one write per key per second. If you parallelize aggressively and two writes hit the same key simultaneously, one can silently drop (no error, just no write).
The fix is key design. Use revenue:YYYY-MM-DD:platform so parallel tasks are always writing different keys. Don't try to share a key across platforms.
Pitfall 4: Multi-Currency Chaos in AdMob
If your apps are configured in different currencies, AdMob's report mixes them. Summing USD and JPY rows gives a meaningless number.
Always include LOCALIZED_CURRENCY_CODE in your dimensions. Aggregate per-currency, then convert to a base currency with an exchange-rate API before summing.
Pitfall 5: "Yesterday's" App Store Data May Not Exist Yet
App Store Connect daily data closes at UTC 00:00 and finalizes a few hours later. If your cron runs at JST 00:00 (UTC 15:00), yesterday's data is almost certainly not there, and you'll get 404s.
Schedule the cron for JST 07:00 or later. JST 09:00 is the sweet spot for reliability.
Design Lessons from Running This for Six Months
A few notes from actually operating the system.
Idempotent re-aggregation: Apple occasionally revises past data (refunds, returns). Your batch should accept a days=N argument and re-fetch the last week on demand. It makes month-end reconciliation much easier.
Failure-notification thresholds: Don't alert Slack on every failure. Use a "two consecutive failures" rule, or you'll train yourself to ignore morning alerts. Real outages get buried among transient 5xx noise.
Cost visibility: The Workers free tier is generous, but KV reads grow quickly. If your dashboard gets any traction on social, you can brush up against the limit. Keep wrangler tail and Analytics within reach.
Bonus: Letting Claude Summarize Yesterday in Plain English
The final polish: ask Claude to describe yesterday's numbers in one or two sentences and post it to Slack. A line like "AdMob was 20% below the 7-day average yesterday — probably worth checking inventory or eCPM" turns a wall of numbers into an instant gut-check.
import Anthropic from '@anthropic-ai/sdk';async function generateInsight(rows: any[]): Promise<string> { const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const response = await client.messages.create({ model: 'claude-haiku-4-5-20251001', max_tokens: 300, messages: [{ role: 'user', content: `Here are the last 7 days of app revenue. If yesterday looks unusual, say so in 1-2 sentences with a concrete action. No number dumps — just actionable insight.\n\n${JSON.stringify(rows, null, 2)}`, }], }); return response.content[0].type === 'text' ? response.content[0].text : '';}
Why Haiku 4.5: The task is summarizing seven numbers in one or two sentences. Opus or Sonnet is overkill. Haiku 4.5 handles it naturally for roughly $0.0001 per call. Running it 30 times a day costs under a dollar a month.
Your Next Step
Don't try to implement all of this at once. Start with one thing: get App Store Connect's JWT auth to return a 200 on Sales Reports. Once that single request works, the remaining 70% of the project is variations on the same pattern.
If you get stuck on auth, drop into Claude Code's Plan Mode and tell it "I'm getting a 401 on App Store Connect's JWT auth — let's narrow down the cause together." That's exactly how I unblocked myself. The kid, the key format, the timing — one of those will be the culprit, and Plan Mode is good at isolating which.
Once all your indie revenue is on one screen, something else changes: you see which app to invest in next. The time you used to spend clicking between dashboards becomes the time you spend thinking about strategy. Six months in, I'd build this system again without hesitation.
Backfilling Historical Data
The cron only captures data going forward. When you first deploy, you'll want several months of history so the dashboard isn't empty. Here's the pattern I use.
// src/backfill.tsexport async function backfillAppStore( env: Env, startDate: string, endDate: string,): Promise<void> { const jwt = await generateAppStoreJWT({ issuerId: env.ASC_ISSUER_ID, keyId: env.ASC_KEY_ID, privateKey: env.ASC_PRIVATE_KEY, }); const dates = enumerateDates(startDate, endDate); for (const date of dates) { // Serialize to respect Apple's rate limits. try { const rows = await fetchAppStoreSales(jwt, env.ASC_VENDOR_NUMBER, date); await env.REVENUE_KV.put(`revenue:${date}:appstore`, JSON.stringify(rows)); await sleep(1000); // be polite } catch (err) { console.error(`Backfill failed for ${date}:`, err); } }}function enumerateDates(start: string, end: string): string[] { const dates: string[] = []; const s = new Date(start); const e = new Date(end); for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) { dates.push(d.toISOString().slice(0, 10)); } return dates;}function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms));}
Expose this behind an authenticated endpoint, not the cron. Backfills are long-running, and you want to run them deliberately, not on a schedule.
Observability Without a Paid Service
You don't need Datadog or Sentry for a project this size. Cloudflare gives you enough to stay sane.
wrangler tail for live logs during debugging.
Workers Analytics Engine for structured metrics — write a row per batch run with fields like platform, duration_ms, row_count, and query it from the dashboard.
Workers Logs with Logpush if you want to fan logs out to R2 or a third-party service once you outgrow the free tier.
A minimal pattern I like: write a "batch health" KV entry per run.
Then a tiny /health page reads the last 14 entries and flags any failures. Cheap, effective, and you can build it yourself in under an hour.
Currency Conversion for Cross-Platform Totals
Summing across platforms requires a shared currency. I recommend:
Store raw amounts per platform in their native currency.
Store a daily exchange-rate snapshot in KV: fx:YYYY-MM-DD keyed by currency pair.
Compute conversions at read time, not ingestion time.
Converting at read time means you can fix historical mistakes without re-running the batch. Fetch rates from a free source like the European Central Bank's daily feed or a paid service if you need weekend data.
async function loadFxRate(kv: KVNamespace, date: string, pair: string): Promise<number> { const data = await kv.get(`fx:${date}`, 'json') as Record<string, number> | null; // Fall back to the most recent rate if weekend/holiday if (data && data[pair]) return data[pair]; for (let i = 1; i <= 5; i++) { const fallbackDate = new Date(new Date(date).getTime() - i * 86400_000).toISOString().slice(0, 10); const fallback = await kv.get(`fx:${fallbackDate}`, 'json') as Record<string, number> | null; if (fallback && fallback[pair]) return fallback[pair]; } throw new Error(`No FX rate available near ${date} for ${pair}`);}
A Word on Secrets Management
Don't paste your P8 key into wrangler.toml. Use wrangler secret put for anything sensitive — ASC_PRIVATE_KEY, ADMOB_SERVICE_KEY, SLACK_WEBHOOK_URL. These are encrypted at rest and never show up in logs.
When pasting a multi-line P8 key into a secret, replace newlines with \n literal characters, or use the file-based form wrangler secret put ASC_PRIVATE_KEY < key.p8. Be aware that Cloudflare trims trailing whitespace from secrets in some cases — your importPKCS8 call may fail with "Invalid key format" if the final newline is lost. Append one explicitly in code if needed:
This is the kind of detail that eats a Saturday afternoon when you deploy on Monday and everything breaks.
What I'd Build Next
A few ideas that would extend this into something more useful if the base pipeline is boring (which is the goal).
Anomaly alerts: When today's revenue is more than 2 standard deviations below the 28-day mean, post a higher-priority Slack alert with recent AdMob eCPM trends attached.
Cohort retention overlay: Pull DAU from App Store Connect Analytics Reports and overlay install-day cohorts on the same chart so you can see retention drop-offs correlate with revenue dips.
Per-app P&L: Cross-reference with your infrastructure costs (Firebase, AWS, domain) so each app has a net-margin line, not just top-line revenue.
Forecasting: Feed the last 90 days to Claude and ask it to project month-end totals based on weekday-adjusted trend. Claude is surprisingly good at rough forecasts if you give it the right framing.
Keep the base pipeline simple. Add complexity only when the absence is actually costing you something. That's how indie dev stays sustainable — by not building features you don't need.
One more practical detail: version your KV schema. Write the shape version into each record like { schema: 2, data: [...] }. When you inevitably want to add a new field, readers can fall back gracefully on old entries instead of crashing on missing keys. This trivial habit has saved me twice already during schema migrations.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.