◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-04-04Advanced

Automating App Store Reviews with Gemini API and App Store Connect API: Implementation Notes from Running 50M-Download Apps

Implementation notes for combining Gemini API and App Store Connect API to handle review sentiment analysis, reply drafting, competitor monitoring, and weekly ASO reports in Python. Includes lessons learned from running indie apps with over 50 million cumulative downloads.

Gemini API249App Store Connect APIASOReview AutomationPython51Competitor AnalysisApp Development

✦ Premium Article

When Review Replies Were Eating My Mornings

For a long stretch of my indie developer life, opening App Store Connect, reading unanswered reviews one by one, running unfamiliar languages through browser translation, gauging the tone, and drafting a reply consumed one to two hours every day. Since releasing my first app to the App Store in 2013 and growing the catalog to over 50 million cumulative downloads, the inbox routinely hit several dozen reviews a day — and over a thousand in some months.

Even so, I never wanted to drop the practice. One-star reviews always got a reply. Five-star reviews with a concrete request got one too. That discipline quietly held up the long-term rating across my apps. But the hours were real, and I kept telling myself I would automate this someday.

When Gemini API matured to a point where I trusted it in production, I rewired the whole flow on top of App Store Connect API. The result: review handling dropped from one to two hours a day to under fifteen minutes, and reply quality stayed steady because I switched to a "Gemini drafts, I approve" pattern rather than full hands-off automation.

This article is the implementation note for that pipeline, with the rough edges I hit along the way. The system covers:

  • Multilingual sentiment analysis and classification (Japanese, English, Spanish, and Chinese reviews coexist in my inbox)
  • Context-aware reply drafting (responses that match the specific review, not boilerplate)
  • Competitor rating and review-trend monitoring
  • Weekly ASO intelligence reports auto-delivered to Slack

The target audience is indie developers running iOS or macOS apps, comfortable with App Store Connect, and intermediate-to-advanced Python users.

Prerequisites: Working knowledge of App Store Connect, Python 3.10+, and an existing Gemini API key.


App Store Connect API Authentication

Generating Your API Key

App Store Connect API uses JWT (JSON Web Token) authentication. Log in to App Store Connect, navigate to Users and Access → Keys, and generate an App Store Connect API key.

Required permissions:

  • Customer Support: Required to respond to reviews
  • Reports: Required to download sales and reports
  • App Store: Required to read app information

After generating the key, download the .p8 file and store it securely — it cannot be re-downloaded.

Environment Setup

# .env file — NEVER commit this to Git
APP_STORE_KEY_ID=YOUR_KEY_ID_HERE
APP_STORE_ISSUER_ID=YOUR_ISSUER_ID_HERE
APP_STORE_PRIVATE_KEY_PATH=/path/to/AuthKey_XXXXXXXX.p8
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL

JWT Generation and API Client Implementation

import jwt
import time
import httpx
from pathlib import Path
from dotenv import load_dotenv
import os
 
load_dotenv()
 
class AppStoreConnectClient:
    """App Store Connect API client with automatic JWT refresh"""
 
    BASE_URL = "https://api.appstoreconnect.apple.com/v1"
    TOKEN_LIFETIME = 1200  # 20 minutes (maximum allowed)
 
    def __init__(self):
        self.key_id = os.getenv("APP_STORE_KEY_ID")
        self.issuer_id = os.getenv("APP_STORE_ISSUER_ID")
        self.private_key = Path(os.getenv("APP_STORE_PRIVATE_KEY_PATH")).read_text()
        self._token: str | None = None
        self._token_expiry: float = 0
 
    def _generate_token(self) -> str:
        """Generate a fresh JWT token"""
        now = int(time.time())
        payload = {
            "iss": self.issuer_id,
            "iat": now,
            "exp": now + self.TOKEN_LIFETIME,
            "aud": "appstoreconnect-v1",
        }
        return jwt.encode(payload, self.private_key, algorithm="ES256", headers={"kid": self.key_id})
 
    @property
    def token(self) -> str:
        """Cached token with automatic refresh before expiry"""
        if not self._token or time.time() > self._token_expiry - 60:
            self._token = self._generate_token()
            self._token_expiry = time.time() + self.TOKEN_LIFETIME
        return self._token
 
    def get(self, path: str, params: dict | None = None) -> dict:
        """GET request with pagination support"""
        headers = {"Authorization": f"Bearer {self.token}"}
        response = httpx.get(f"{self.BASE_URL}{path}", headers=headers, params=params, timeout=30)
        response.raise_for_status()
        return response.json()
 
    def patch(self, path: str, body: dict) -> dict:
        """PATCH request for updating resources like review replies"""
        headers = {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json",
        }
        response = httpx.patch(f"{self.BASE_URL}{path}", headers=headers, json=body, timeout=30)
        response.raise_for_status()
        return response.json()

✦

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
✦Working code for sentiment analysis and reply generation with Gemini 2.5 Flash, including the temperature and prompt parameters used in production
✦Lessons learned from running apps at 50M+ download scale: App Store Connect API gotchas around JWT caching, rate limits, and territory-based review filtering
✦A two-stage approval gate for reply drafts and a weekly-report design that keeps the system from drifting into boilerplate replies
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.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini 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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ API / SDK2026-10-14
When Your config Raises "Extra inputs are not permitted", Check the Spelling of Every Key
If a google-genai call fails with a pydantic ValidationError (extra_forbidden) the moment you pass a config, it is almost always a one-letter typo. Here is the output I reproduced, a small function that checks keys before the call, and how to tell why response.text came back as None.
◈ API / SDK2026-10-08
When a Deprecation Has No Shutdown Date: Re-Picking an Image Model on Three Conditions
gemini-nano-banana-2.1 went GA on October 6, and gemini-3.1-flash-image was deprecated with no shutdown date. Here is a table and a short script for choosing a replacement by resolution, reference images, and search grounding.
◈ API / SDK2026-09-22
When Function Calls Turn Into Plain Text, Suspect the Property Names in Your Tool Declarations
A record of chasing MALFORMED_FUNCTION_CALL after output limits and schema conflicts were ruled out, bisecting nine tool declarations down to one property name, and adding a linter that stops bad names before they ship.