Antigravity × App Store Connect API: Complete Automation Guide for Revenue, ASO & Reviews
A hands-on guide to automating App Store Connect with Antigravity. From JWT authentication to sales report fetching, review sentiment analysis, ASO tracking, and a daily Slack dashboard — everything indie developers need to reclaim development time.
Every indie developer knows the routine: open App Store Connect in the morning, check yesterday's sales, skim new reviews, wonder why that keyword ranking dropped. Multiply that by the number of apps you run, and suddenly a significant chunk of your working day disappears into a dashboard that never changes itself.
App Store Connect API has existed since 2018, yet many indie developers never get past the initial JWT authentication hurdle. The official documentation is comprehensive but scattered, and the authentication mechanism — signing tokens with an EC private key — feels unfamiliar compared to standard OAuth flows. This guide uses Antigravity as your AI coding partner to cut through that friction, walking you through authentication, sales automation, review monitoring, and a morning dashboard that runs without you.
What the App Store Connect API Can Actually Do
Before diving into code, it helps to understand the API's scope. The three areas most valuable to indie developers are:
Sales and Trends Reports: Daily, weekly, and monthly revenue broken down by country, product type, and app. Unlike the App Store Connect dashboard (limited to 365 days), the API gives you your entire sales history in a structured format suitable for long-term trend analysis.
Customer Reviews: Fetch all reviews, filter by rating or territory, and post responses — all programmatically. The key operational use case is detecting low-rated reviews within hours rather than stumbling on them the next day.
App Analytics: Impression counts, page view rates, conversion funnels, and source attribution (search vs. browse vs. referral). This data underlies any serious ASO effort.
With these three pillars automated, you essentially have a personal business intelligence layer running in the background at zero marginal cost.
JWT Authentication: Getting Past the First Wall
App Store Connect uses JWT signed with an EC private key (ES256 algorithm). Unlike bearer tokens you'd get from an OAuth exchange, you generate these locally every time — or more practically, every 20 minutes, since that's Apple's maximum token validity.
Generating Your API Key
Log into App Store Connect, navigate to Users and Access → Integrations → App Store Connect API, and create a new API key. For a read-heavy setup covering sales and reviews, select the "Sales and Trends" and "Customer Support" roles. Write down the following before leaving the page:
Issuer ID: A UUID like XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
Key ID: A 10-character alphanumeric string
.p8 private key file: Download it immediately — it cannot be re-downloaded
Store the .p8 file in a password manager or encrypted vault. If it's compromised, revoke it from App Store Connect immediately and generate a new one.
Implementing the Auth Class
Use this Antigravity prompt to scaffold the authentication layer:
Implement a TypeScript class that generates App Store Connect API JWT tokens.
Requirements:
- Accept issuer_id, key_id, and path to a .p8 private key file
- Sign using ES256 algorithm
- Set token expiry to 20 minutes (Apple's maximum)
- Cache and auto-refresh the token (refresh 2 minutes before expiry)
- Include proper error handling with descriptive messages
The resulting implementation should look like this:
import * as jwt from 'jsonwebtoken';import * as fs from 'fs';class AppStoreConnectAuth { private readonly issuerId: string; private readonly keyId: string; private readonly privateKey: string; private cachedToken: string | null = null; private tokenExpiry: number = 0; constructor(issuerId: string, keyId: string, privateKeyPath: string) { this.issuerId = issuerId; this.keyId = keyId; // Normalize line endings — critical if the key was ever opened on Windows this.privateKey = fs.readFileSync(privateKeyPath, 'utf8').replace(/\r\n/g, '\n'); } generateToken(): string { const now = Math.floor(Date.now() / 1000); // Return cached token if still valid (with 2-minute buffer) if (this.cachedToken && this.tokenExpiry > now + 120) { return this.cachedToken; } const payload = { iss: this.issuerId, iat: now, exp: now + 1200, // 20 minutes aud: 'appstoreconnect-v1', }; try { this.cachedToken = jwt.sign(payload, this.privateKey, { algorithm: 'ES256', header: { alg: 'ES256', kid: this.keyId, typ: 'JWT' }, }); this.tokenExpiry = now + 1200; return this.cachedToken; } catch (error) { // Most common cause: malformed private key (missing PEM headers or bad line endings) throw new Error( `JWT generation failed: ${error instanceof Error ? error.message : String(error)}. ` + `Verify the .p8 file includes BEGIN/END PRIVATE KEY headers.` ); } } getAuthHeader(): { Authorization: string } { return { Authorization: `Bearer ${this.generateToken()}` }; }}// Environment variables (store in .env or CI secrets — never commit)// ASC_ISSUER_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX// ASC_KEY_ID=XXXXXXXXXX// ASC_VENDOR_NUMBER=XXXXXXXX (found in Payments and Financial Reports)// APP_ID=XXXXXXXXXX (the Apple ID number of your app, not the bundle ID)
Common Mistake 1: Line Ending Corruption
If you open the .p8 file on Windows, copy-paste its contents into a text field, or let your IDE auto-convert line endings, the jsonwebtoken library will throw a PEM_read_bio error. Always read the file directly with fs.readFileSync and apply .replace(/\r\n/g, '\n') as a safeguard.
Common Mistake 2: Wrong aud Claim
The aud field must be the string "appstoreconnect-v1" — not an array, not capitalized differently. Any deviation results in a 401 with an unhelpful "FORBIDDEN" message.
✦
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
✦Implement App Store Connect API JWT authentication with ES256 and get a reusable token class that handles auto-renewal
✦Fetch sales, reviews, and ASO metrics in parallel and ship a morning revenue summary to Slack within the GitHub Actions free tier
✦Go from instant negative-review detection to semi-automated replies — the automation patterns indie developers actually need in production
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.
With authentication working, sales reports are the first thing worth automating. The Sales and Trends API returns gzip-compressed TSV files, which need to be decompressed and parsed.
Recommendation: Rather than auto-posting AI-generated responses, build a semi-automated flow where Slack shows the suggested response with an Approve button. This lets you catch tone issues before they go live.
Common Mistake 4: Treating Responses as Editable
Once posted, a review response cannot be edited via the API — only deleted and reposted. Build in a preview step before posting to avoid embarrassing corrections.
Building the Morning Dashboard on GitHub Actions
Combining sales reporting and review alerts into a scheduled job requires no server. GitHub Actions' free tier (2,000 minutes/month) comfortably covers daily runs.
// src/report.ts — the entry point for the scheduled jobasync function runMorningReport(): Promise<void> { const auth = new AppStoreConnectAuth( process.env.ASC_ISSUER_ID\!, process.env.ASC_KEY_ID\!, process.env.ASC_PRIVATE_KEY_PATH\! ); const yesterday = new Date(Date.now() - 86_400_000).toISOString().split('T')[0]; const [salesData, reviews] = await Promise.all([ fetchWithRetry(() => fetchDailySalesReport(auth, process.env.ASC_VENDOR_NUMBER\!, yesterday) ), fetchRecentReviews(auth, process.env.APP_ID\!, new Date(Date.now() - 7 * 86_400_000)), ]); const totalRevenue = salesData.reduce((sum, r) => sum + r.developerProceeds, 0); const downloads = salesData.filter(r => r.productType === '7').reduce((sum, r) => sum + r.units, 0); const avgRating = reviews.length ? (reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length).toFixed(1) : 'N/A'; await slack.chat.postMessage({ channel: process.env.SLACK_CHANNEL_ID\!, text: [ `📊 *Sales Report: ${yesterday}*`, `💰 Developer proceeds: $${totalRevenue.toFixed(2)}`, `📥 Downloads: ${downloads.toLocaleString()}`, `⭐ Avg rating (7d): ${avgRating}`, `💬 New reviews (7d): ${reviews.filter(r => new Date(r.createdDate) > new Date(Date.now() - 86_400_000)).length}`, ].join('\n'), }); await alertNegativeReviews(auth, process.env.APP_ID\!, 'MyApp');}runMorningReport().catch(console.error);
Common Mistake 5: How to Store the .p8 File in GitHub Secrets
Paste the entire file content — including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines — directly into the secret value field. GitHub Secrets handles multiline values correctly. The echo "${{ secrets.ASC_PRIVATE_KEY }}" > ./AuthKey.p8 step recreates the file at runtime.
Managing Multiple Apps in Parallel
Running four apps and want reports for all of them? Use Promise.allSettled to parallelize without letting one failure block the others:
const APPS = [ { id: '111111111', name: 'App A' }, { id: '222222222', name: 'App B' }, { id: '333333333', name: 'App C' },];async function runAllReports(): Promise<void> { // Single auth instance shared across all requests (token caching handles concurrency) const auth = new AppStoreConnectAuth( process.env.ASC_ISSUER_ID\!, process.env.ASC_KEY_ID\!, process.env.ASC_PRIVATE_KEY_PATH\! ); const results = await Promise.allSettled( APPS.map(app => alertNegativeReviews(auth, app.id, app.name)) ); results.forEach((result, i) => { if (result.status === 'rejected') { console.error(`Failed for ${APPS[i].name}:`, result.reason); } });}
Production Considerations
Rate limits: Apple rate-limits the API. For high-volume requests, add 100–200ms delays between calls and implement exponential backoff on 429 responses. For typical indie developer usage (daily sales reports + review fetches for 3–5 apps), you're unlikely to hit limits.
Data persistence: App Store Connect stores a finite history. Export and persist sales data to a database or spreadsheet from day one — recovering historical data after the fact is not possible through the API.
Key rotation: Build a reminder to review and rotate API keys annually. Revoke keys immediately if you suspect compromise. The .p8 file should never appear in your git history; add *.p8 to .gitignore before your first commit.
Once the JWT authentication class exists and your first sales report lands in Slack, the rest of this system assembles quickly. The goal isn't to build a perfect dashboard on day one — it's to remove the daily manual check-in from your routine. Start with yesterday's sales report and one negative review alert. From there, add layers as you discover which data points you actually act on.
Extending the System: Weekly Trend Analysis
Daily data tells you what happened. Weekly aggregations tell you whether what you're doing is working. Add this function to your reporting pipeline to get a seven-day rolling view of key metrics.
interface WeeklyTrend { period: string; // 'YYYY-Www' format totalRevenue: number; totalDownloads: number; avgRating: number; reviewCount: number; topCountry: string; topCountryRevenue: number;}async function computeWeeklyTrend( auth: AppStoreConnectAuth, vendorNumber: string, appId: string, weeksBack = 4): Promise<WeeklyTrend[]> { const trends: WeeklyTrend[] = []; for (let week = 0; week < weeksBack; week++) { const weekStart = new Date(Date.now() - (week + 1) * 7 * 86_400_000); const weekDays: string[] = []; for (let d = 0; d < 7; d++) { const day = new Date(weekStart.getTime() + d * 86_400_000); weekDays.push(day.toISOString().split('T')[0]); } // Fetch each day in parallel (respecting rate limits with a small delay) const weekSalesRaw = await Promise.allSettled( weekDays.map((date, i) => new Promise<SalesRecord[]>(resolve => setTimeout( () => fetchDailySalesReport(auth, vendorNumber, date).then(resolve), i * 150 // 150ms stagger to avoid rate limiting ) ) ) ); const weekSales = weekSalesRaw .filter((r): r is PromiseFulfilledResult<SalesRecord[]> => r.status === 'fulfilled') .flatMap(r => r.value); // Country breakdown const byCountry = weekSales.reduce<Record<string, number>>((acc, r) => { acc[r.storefront] = (acc[r.storefront] || 0) + r.developerProceeds; return acc; }, {}); const topCountryEntry = Object.entries(byCountry).sort(([, a], [, b]) => b - a)[0]; // Reviews for this week const weekReviews = await fetchRecentReviews(auth, appId, weekStart); trends.push({ period: `Week -${week + 1}`, totalRevenue: weekSales.reduce((s, r) => s + r.developerProceeds, 0), totalDownloads: weekSales.filter(r => r.productType === '7').reduce((s, r) => s + r.units, 0), avgRating: weekReviews.length ? weekReviews.reduce((s, r) => s + r.rating, 0) / weekReviews.length : 0, reviewCount: weekReviews.length, topCountry: topCountryEntry?.[0] ?? 'N/A', topCountryRevenue: topCountryEntry?.[1] ?? 0, }); } return trends;}// Expected output (4 weeks of data, most recent first):// [// { period: 'Week -1', totalRevenue: 127.50, totalDownloads: 843, avgRating: 4.3, reviewCount: 12, topCountry: 'JP', topCountryRevenue: 84.00 },// { period: 'Week -2', totalRevenue: 108.20, totalDownloads: 761, avgRating: 4.1, reviewCount: 8, topCountry: 'JP', topCountryRevenue: 71.50 },// ...// ]
You can prompt Antigravity to format these four weeks as a simple ASCII table for the Slack weekly summary post, or push the structured data to a Google Sheet for visualization. Either way, you now have a consistent baseline to measure against when you make pricing changes, add new features, or run App Store Search Ads campaigns.
Automating App Metadata Updates for Localization
Once your app is generating consistent revenue in Japan, the natural next step is expanding to other markets. App metadata — title, subtitle, description, keywords — must be localized for each territory. The App Store Connect API makes batch updates possible, though the workflow has a few nuances.
interface AppLocalization { locale: string; // e.g., 'en-US', 'ja-JP', 'de-DE' name: string; // App name (max 30 chars on iOS) subtitle: string; // Subtitle (max 30 chars) description: string; // Full description keywords: string; // Comma-separated, max 100 chars total promotionalText: string; // Updated without a new submission (max 170 chars)}async function updateAppLocalization( auth: AppStoreConnectAuth, appId: string, localization: AppLocalization): Promise<void> { // First, fetch the existing localization resource ID const listResponse = await axios.get( `${BASE_URL}/apps/${appId}/appStoreVersions?filter[platform]=IOS&filter[appStoreState]=PREPARE_FOR_SUBMISSION&limit=1`, { headers: auth.getAuthHeader() } ); const versionId = listResponse.data.data?.[0]?.id; if (\!versionId) { throw new Error('No version in PREPARE_FOR_SUBMISSION state. Create a new version first.'); } // Get the localization ID for this locale const locResponse = await axios.get( `${BASE_URL}/appStoreVersions/${versionId}/appStoreVersionLocalizations?filter[locale]=${localization.locale}`, { headers: auth.getAuthHeader() } ); const locId = locResponse.data.data?.[0]?.id; if (locId) { // Update existing localization await axios.patch( `${BASE_URL}/appStoreVersionLocalizations/${locId}`, { data: { type: 'appStoreVersionLocalizations', id: locId, attributes: { description: localization.description, keywords: localization.keywords, promotionalText: localization.promotionalText, }, }, }, { headers: { ...auth.getAuthHeader(), 'Content-Type': 'application/json' } } ); console.log(`✅ Updated ${localization.locale} localization`); } else { // Create new localization for this locale await axios.post( `${BASE_URL}/appStoreVersionLocalizations`, { data: { type: 'appStoreVersionLocalizations', attributes: { locale: localization.locale, name: localization.name, subtitle: localization.subtitle, description: localization.description, keywords: localization.keywords, }, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } }, }, }, }, { headers: { ...auth.getAuthHeader(), 'Content-Type': 'application/json' } } ); console.log(`✅ Created ${localization.locale} localization`); }}
The workflow for expanding to a new market using Antigravity looks like this: provide your existing Japanese or English description, ask Antigravity to produce a localized version with culturally appropriate phrasing (not just a word-for-word translation), then call updateAppLocalization to push it directly — no copy-pasting into the App Store Connect web interface required.
Common Mistake 6: Updating a Live Version's Keywords
Keywords can only be updated in versions that are in the PREPARE_FOR_SUBMISSION state. If your current version is live (READY_FOR_SALE), you need to create a new version before updating metadata. Promotional text is the exception — it can be changed at any time without a new submission, making it useful for time-sensitive messaging.
Correlating Review Sentiment with Release History
One of the most actionable analyses you can run is mapping your average review rating against your release dates. A rating drop following a specific release tells you exactly which update broke something users cared about.
interface ReleaseRatingCorrelation { versionString: string; releaseDate: string; avgRatingAfterRelease: number; // average rating for reviews posted in the 14 days after release reviewCount: number; sentiment: 'positive' | 'neutral' | 'negative';}async function analyzeReleaseSentiment( auth: AppStoreConnectAuth, appId: string): Promise<ReleaseRatingCorrelation[]> { // Fetch version history const versionsResponse = await axios.get( `${BASE_URL}/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10&sort=-createdDate`, { headers: auth.getAuthHeader() } ); const versions = versionsResponse.data.data.map((v: any) => ({ versionString: v.attributes.versionString, releaseDate: v.attributes.earliestReleaseDate || v.attributes.createdDate, })); // Fetch all reviews once, then filter by date for each version const allReviews = await fetchRecentReviews( auth, appId, new Date(Date.now() - 90 * 86_400_000) // Last 90 days ); return versions.map(version => { const releaseDate = new Date(version.releaseDate); const windowEnd = new Date(releaseDate.getTime() + 14 * 86_400_000); const windowReviews = allReviews.filter(r => { const d = new Date(r.createdDate); return d >= releaseDate && d <= windowEnd; }); const avgRating = windowReviews.length ? windowReviews.reduce((sum, r) => sum + r.rating, 0) / windowReviews.length : 0; return { versionString: version.versionString, releaseDate: version.releaseDate, avgRatingAfterRelease: Math.round(avgRating * 10) / 10, reviewCount: windowReviews.length, sentiment: avgRating >= 4.0 ? 'positive' : avgRating >= 3.0 ? 'neutral' : 'negative', }; });}// Expected output:// [// { versionString: '2.4.1', releaseDate: '2026-04-10', avgRatingAfterRelease: 4.6, reviewCount: 18, sentiment: 'positive' },// { versionString: '2.4.0', releaseDate: '2026-03-28', avgRatingAfterRelease: 2.8, reviewCount: 31, sentiment: 'negative' },// { versionString: '2.3.5', releaseDate: '2026-03-10', avgRatingAfterRelease: 4.2, reviewCount: 9, sentiment: 'positive' },// ]
When version 2.4.0 shows a 2.8 average and 31 reviews — while the versions before and after show 4.2 and 4.6 respectively — you have a clear signal about what patch 2.4.1 fixed and why it was worth rushing out.
Structuring the Full Project
For a maintainable codebase that Antigravity can extend over time, organize the project as follows:
When you bring this structure to Antigravity and say "add a function to export 30 days of sales data to a Google Sheet," it has the full context of where new code should live and how the auth layer works — which is what separates a maintainable system from a collection of scripts.
Why I Came to Rely on This Setup
I run several apps as an indie developer, and for years my morning began the same way: coffee in hand, App Store Connect open, scanning yesterday's numbers and reading new reviews. Good days felt good; bad days sent me hunting through the dashboard for an explanation. Either way, that ritual quietly consumed the sharpest focus of my day.
The turning point was deciding to stop checking at all. I set up a morning summary delivered to Slack and let only the one- and two-star reviews surface on their own. Suddenly there was nothing to "go look at." On calm days nothing interrupted me, and I could stay inside the work.
With less time spent chasing daily figures, my relationship with the numbers actually grew steadier. Instead of reacting to every small fluctuation, I began reading week-over-week and month-over-month trends. The real value of this automation, I've come to believe, is less about the minutes saved and more about the quiet margin it gives back.
If you also feel the morning check stealing your best hours, start with just two pieces — JWT authentication and the daily sales report. The morning that first Slack summary arrives, you may notice your shoulders drop a little. That small relief is worth more than it sounds.
What to Build Next
Once the daily report is running reliably, these are the highest-value additions:
App Store Search Ads API integration: Apple's Search Ads API (separate from App Store Connect) lets you pull campaign performance data — impressions, taps, downloads, CPT — and correlate it directly with your App Store Connect revenue numbers. Seeing CPT alongside revenue per download per keyword is what makes budget allocation decisions data-driven rather than intuitive.
Subscription cohort analysis: The Subscription Reports endpoint provides renewal rates, churn rates, and trial conversion rates by country and subscription period. Building a cohort table (users who started a trial in March, how many are still paying in May) surfaces retention issues that aggregate metrics hide.
Automated screenshot testing before ASO experiments: Before changing your App Store screenshots, capture the current conversion rate from the Analytics API. Run the experiment for two weeks, then compare. Without a baseline, you never know if the new screenshots helped.
The App Store Connect API is the foundation for treating your app portfolio as a business rather than a collection of projects. Each endpoint you automate is one less reason to open the App Store Connect dashboard manually — and one more hour redirected toward work that actually moves the needle.
For a comprehensive reference on RevenueCat's subscription analytics as a complement to the raw App Store Connect data, see the Antigravity × RevenueCat subscription implementation guide. The two data sources together give you both the raw numbers Apple provides and the higher-level cohort and retention analytics RevenueCat specializes in.
Share
Thank You for Reading
Antigravity 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.