Which field in your store listing do you assume stays there once you write it?
I assumed all of them did. Yesterday I counted: 648 slots were empty across 40 apps.
It started with a dead monitor
A bot polls app review state every two hours. It walks 46 apps — version states, events, custom product pages — diffs the result against the last snapshot, and reports only what changed.
I opened the fleet board in the morning and that bot was the only red row. Last success 22:44 the night before, silent for eleven hours since.
The last line in its log:
requests.exceptions.ReadTimeout:
HTTPSConnectionPool(host='api.appstoreconnect.apple.com', port=443):
Read timed out. (read timeout=30)46 apps × 3 request types = roughly 130 API calls per run. One of them went past 30 seconds and the whole run died. There was no retry.
That isn't what this post is about. But it's what brought the rest of it in.
The fix belonged above the call sites, not in them
There were four requests.get calls in the file. Wrapping each one in its own try/except is four chances to get it wrong. Retries belong in the one place every call passes through.
SESS = requests.Session()
SESS.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, connect=4, read=4, backoff_factor=2,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]))))At the call sites, requests.get became SESS.get. That was the whole change. I ran it once, it finished in 34 seconds, and it printed something no one had seen for eleven hours:
[purro] staging 1.3.1 IN_REVIEW -> READY_FOR_SALEAn app had been approved and was live. When the monitor dies, approvals go missing with it. This bot once sat with no schedule at all and I learned about an approval three days late, so this time I opened the listing right away.
The promo text was zero characters long
promotionalText in App Store Connect is a special field. It is the only listing field that goes live without review. No new build, no submission — you change the line today and the store shows it today.
It was 0 characters in all five locales.
The odd part: I had filled it by hand three days earlier. So I walked the versions.
| Version | Approved | Locales with promo text |
|---|---|---|
| 1.3.1 | 09-02 | 0 / 5 |
| 1.3 | 09-01 | 0 / 5 |
| 1.2 | 08-31 | 5 / 5 |
Alive through 1.2, gone from 1.3 on. Apple does not carry promotionalText forward into a new version. Description, keywords and subtitle come along; this one is born blank.
Two releases had erased the copy I typed three days before.
What would you do here
This is a fork. Do you refill the five slots by hand and move on, or do you go count the rest of your apps?
On 09-01 I chose the first one. That's why I was standing in the same place on 09-04.
This time I counted. Every app in the account with a live version, every locale, printing the length of promotionalText.
empty promo slots: 648 / 40 apps19 slots in one app, 19 in another, 19 in a third. This was not one app's problem. However many releases each app had shipped, everything after the last release was blank.
Why nobody noticed
The same scan looked at the other fields too: description, keywords, release notes.
description empty: 0
keywords empty: 0
whatsNew empty: 16 (all on one app's 1.0 — a first version has none by definition)Not a single hole. The reason is simple: those fields fail review when they're empty. You cannot submit with a blank description, and a human reads it.
promotionalText never goes through review. That is the point of the field — and the reason no one inspects it.
Reviewed fields are guarded by the review. Unreviewed fields are guarded by you, or by nobody.
This isn't an App Store story. In any deploy pipeline, values that must pass a gate are protected by the gate, and values outside it rot quietly. I missed a crawler reading stale hardcoded copy and repo versions trailing the store exactly the same way. Every one of them lived somewhere nobody checked.
The fix — stop filling it by hand
To avoid making this mistake a third time, my hands have to come off it. The rule:
If a locale on the live version is empty and the previous version has copy for that same locale, carry it forward. PATCH it, then read it back and compare.
The read-back isn't a habit, it's the test. A 200 from someone else's API does not mean "stored" — a title field was once dropped silently, so every write gets read back now.
r = SESS.patch(url, json={"data": {..., "attributes": {"promotionalText": text}}})
r.raise_for_status()
back = SESS.get(url).json()["data"]["attributes"].get("promotionalText") or ""
ok = back == text # this is the success test, not the 200Three things I deliberately did not do:
- It never writes new copy. It moves a human-written line from the previous version. Ask an LLM to "write promo copy" and it will advertise features you don't ship.
- It never crosses locales. es-ES copy is not pushed into es-MX. Same language, different vocabulary — spreading a wrong line automatically is worse than an empty one.
- It isn't a one-off script. It hangs off the two-hour poll that was already running. With no empty slots it does nothing, so it's idempotent, and when the next release blanks the field it refills within two hours.
648 slots filled, zero read-back mismatches. A second full scan left 7 empty.
The 7 with nothing to inherit
Those 7 are a different animal. Searching back through 20 versions, that locale had never had copy. It wasn't lost; it was never written.
Only here does a human write. But not from a blank page: I compressed what the locale's own description — text that already passed review — actually says. For a song-guessing game's Portuguese slot, that meant the line already in the Portuguese description: one second, then two, then four, six tries, title and artist both.
That way the copy never gets ahead of the product.
Three things to check
- Which values in your deploy pass through no review or validation? Those are the ones most likely blank or stale right now.
- When did you last actually query them? Through the API, not a dashboard. I believed mine were filled on the strength of "I typed them three days ago."
- Does your next deploy restore what your last deploy erased? If not, your pipeline depends on someone's memory.
The honest part
I have no evidence this copy raises revenue. I don't measure promo-impression-to-install at that resolution, and I already know store traffic isn't the bottleneck. Filling 648 slots will not change tomorrow's revenue.
What I can say is that a zero-character slot sells exactly zero, and that the real output here isn't the copy — it's one rule: nobody checks the field the reviewer doesn't. That rule travels well outside the App Store.
One more thing. All of this arrived courtesy of a dead monitor. Had it stayed alive I would have read the approval notice and never opened the listing. Yesterday the culprit was a log line that printed every single time; today a dead bot was the guide.
Do one thing today: query one live version of one of your apps and print the length of a field that skips review. If it's 0, you're standing where I was.