Opening the App Store Connect reviews tab on a Sunday evening and trying to read every comment below three stars used to be one of those quiet weekly habits that slowly ate into my work. I have been an indie developer since 2014, and the wallpaper apps I run have crossed fifty million cumulative downloads. That means somewhere between fifty and a hundred and twenty reviews land per day across Japanese, English, Spanish, German and a few more locales. By the end of a week that easily passes seven hundred, and I would catch myself starting the week with last week's reviews still unread.
Two weeks ago I changed the routine. I now dump the reviews as a CSV, upload the file to the Gemini File API, and ask it to sort them into "needs an apology", "feature request", "ad complaint" and "OS-version bug report". I also tested Batch Mode in parallel, because indie budgets matter, but I keep coming back to the File API for the day-to-day work. This post is the log of what I learned in those two weeks.
Why I started uploading the whole CSV
My first attempt was the naive one: one review per request to generateContent. The granularity is nice when something fails, since you only retry that single row. But when I tried to re-classify the past six months in one go, around twelve thousand reviews, the rate limits became the bottleneck. Even with backoff I was looking at a full day of background work for one pass.
The File API lets you upload up to 2GB once and then reference that file from parts for the next 48 hours. One upload, then as many prompts as you want against the same payload. That fits perfectly with the way I actually use this: I want to slice the same review batch by three or four different lenses on the same day, and I do not want to pay the upload cost every time.
The other reason was token accounting. When I inlined the CSV into the prompt, my input token usage swung wildly depending on how chatty that week's reviews were. With File API references the input cost is much more predictable. For my workflow of re-reading a 500-row CSV five to eight times a day, that predictability turned out to matter more than I expected.
The three tasks I let it run
I ended up running three concrete tasks:
- Priority label — tag every review as
critical(crashes, billing trouble),support(questions, requests, confusing UI) orneutral(vague impressions, low star with no specifics). - Cluster summary — group similar complaints, so that "ad frequency is too high" or "downloading this specific wallpaper fails" show up as counts instead of as a wall of text.
- Reply drafts — for critical rows only, generate a short apology plus first response in both Japanese and English.
I kept (3) off for the first week and only added it once the labels in (1) and (2) felt right. Layering the tasks one by one made it much easier to see which step was burning tokens and which one was actually pulling its weight.
A minimal File API upload
The actual setup is a small Python script using the google-genai SDK. The pattern is: upload the CSV, then put the returned file handle at the start of contents.
import pathlib
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
csv_path = pathlib.Path("reviews_2026-05-week2.csv")
uploaded = client.files.upload(
file=csv_path,
config=types.UploadFileConfig(
display_name="reviews_2026-05-week2",
mime_type="text/csv",
),
)
prompt = """
The CSV is a week of App Store Connect reviews.
One row per review. Columns: row, rating, locale, title, body, app_version, os_version, created_at.
Return exactly one label per row as a JSON array:
- critical: crash, billing issue, data loss, purchase not honored
- support: how-to questions, feature requests, confusing UI
- neutral: vague comments, low-star reviews with no specifics, spam
Output JSON only.
Format: [{"row": 1, "label": "critical"}, ...]
"""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[uploaded, prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json",
temperature=0.1,
),
)
print(response.text)Setting temperature=0.1 matters here. The same CSV gives nearly identical results across runs, and for a labeling task that consistency is the whole point — it makes diffs between weeks much easier to read.
Uploaded files expire after 48 hours, so I just re-upload at the start of each batch. For the full twelve-thousand-row history I split by month into separate files, which made it much easier to retry a single month when something failed.
Three rough edges I had to round off
Two weeks of running this gave me three concrete things to share, in case you walk the same path.
The first was that multi-line review bodies make Gemini lose track of row numbers. If a reviewer puts an emoji-laden newline inside their body, the model would sometimes re-count rows internally. The fix was to encode the CSV with csv.writer(quoting=csv.QUOTE_ALL) so every field is wrapped in double quotes, plus carrying an explicit row column.
import csv
with open("reviews_2026-05-week2.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f, quoting=csv.QUOTE_ALL)
writer.writerow(["row", "rating", "locale", "title", "body", "app_version", "os_version", "created_at"])
for i, r in enumerate(reviews, start=1):
writer.writerow([i, r.rating, r.locale, r.title, r.body, r.app_version, r.os_version, r.created_at])Asking Gemini to return the row value from the CSV itself, instead of asking "which row is this", made downstream joining trivially correct.
The second thing was label skew. For the first few days I was getting fewer critical labels than my gut expected. Looking into it, short English reviews like "Crashed once" or "Couldn't restore purchase" were being treated as neutral. One added sentence in the prompt — "short reports of a crash or purchase failure count as critical even without details" — brought the ratio back to where my hand sample said it should be.
The third was the reply drafts ending up too formulaic. Left to its own devices, Gemini produced the same shape: apology, ask for repro, thanks for understanding. I do not personally enjoy long apologies, so instead of writing more rules into the prompt I replaced two example replies with two of my own past responses (with personal info stripped). Two or three real examples in the prompt shifted the tone faster than any amount of rule writing did.
Where Batch Mode fits, and where it does not
Batch Mode runs at half the cost and is great for "kick it off overnight, look at the answers in the morning". But the latency contract is "within 24 hours", and for "I want the critical reviews triaged before I sit down for coffee" that contract is too loose.
After two weeks I have settled into a split:
- File API with synchronous
generateContent— same-day reviews, classify and summarize before the day ends. Mostlygemini-2.5-flash. - Batch Mode — historical passes (six months, one year) and full re-labels when I change a label definition.
gemini-2.5-profor those, to lean into accuracy.
The combined flow is: a nightly batch refreshes labels across the whole history, and during the day I run the File API path to draft replies for that day's critical rows. The number of reviews I personally read fell from around seven hundred a week to about ninety. I open the critical candidates, tweak roughly twenty percent of Gemini's draft, and send.
What I want to add in the next two months
Three things are on the immediate list. First, hooking into the App Store Connect API to fully automate the CSV export — right now I still do that step by hand on the weekend, and removing it would let "today's critical drafts" show up in Slack without me touching anything.
Second, putting Google Play reviews through the same pipeline. I run the Android side of the wallpaper apps too, and the Reply to Reviews API on that side is friendlier than what Apple offers.
Third, measuring whether replies actually move the star rating. If I can track whether a replied-to reviewer later raises their rating, I will have a real signal for how to tune the drafts. I still remember being a sixteen-year-old in 1997 teaching myself to code in order to talk to strangers across the world, and replying to a review feels like answering one of those messages — only this time with a few thousand of them waiting. That is exactly the reason I want a way to look back and ask "did this answer actually land".
If you are also an indie developer drowning in reviews, I hope some of this is useful. Thanks for reading.