What I was trying to do
Yesterday (08-18) I submitted A/B experiments on five apps to measure whether captions on screenshots move install conversion. This is App Store Product Page Optimization.
The one thing I cared about in the design: hold the variable to captions only. If I re-shot the UI, the seed data and the build would differ too, and then I could not tell whether a conversion delta came from the caption or from a changed screen. So I downloaded the live screenshots that serve as control and composited captions onto them.
Today I checked the results. Four approved, one rejected (a word-chain game app). The reason:
Guideline 2.3.7 - Performance - Accurate Metadata
The screenshots for caption-v1 includes references to prices.A question before I go on. If one arm of your experiment got rejected, where would you look first? I looked at the captions I had just made. Obviously.
The real bottleneck, and the reversals
Reversal 1 — the culprit was the part I did not touch
The two images Apple attached were not my composited caption shots. They were paywall screenshots that had been live for months, unchanged.
The captions were innocent. All the experiment did was put live assets back on the review desk, and the violation had been sitting on the product page from the start.
"Suspect the diff" is basic debugging. Here it pointed precisely at the wrong answer. I re-checked Japanese line breaking, whether I had invented any copy, the screenshot ordering — all clean. A change that triggers re-review puts the unchanged parts back on the desk too. Review is not scoped to your diff.
Reversal 2 — "free" is a price
Apple's own wording:
references to free or discounted services are considered a price reference
So it is not only $34.99. "7-day free trial", "Save 41%", "BEST VALUE", "Billed monthly" are all price references.
And here is the strange part. Inside the app, 3.1.2(c) requires you to display the billed amount prominently. Capture that same screen and put it on the store, and that information becomes a violation. Two rules demand opposite things of the same pixels. Which means the more conscientious your paywall, the more likely your screenshots break the rule.
Reversal 3 — being live does not mean being safe
One app got caught, so I had to check the rest. I ran OCR over the whole fleet's live screenshots and found price references in 8 apps.
Two of those had caption experiments containing the identical violation approved on the same day (08-18). One reviewer caught it; another let it through.
Enforcement is probabilistic. Which also means "it has always passed, so it's fine" has no evidence behind it. Being live is not proof of compliance — it only tells you that nobody has flagged it yet.
The worse thing I found on the side
I opened the iPad set for that app. Prices were not the only problem. The home-screen shot had the paywall sheet overlapping it, cutting off the Subscribe button, and the vocabulary shot was a 93KB near-blank screen, live on the store.
I had reworked the iPhone shots several times. Nobody ever looked at the iPad set again. The checker only inspects the iPhone slots. I have already been through a capture pipeline shooting the wrong screen; this time the pipeline was fine and the problem was a slot nobody looks at.
How I found them — an OCR audit
44 apps, one locale each, 267 live screenshots pulled down and turned into text. I had no OCR tool on hand, so I wrote a 20-line CLI on the Vision framework.
let req = VNRecognizeTextRequest()
req.recognitionLevel = .accurate
req.usesLanguageCorrection = false
req.recognitionLanguages = ["en-US", "ko-KR", "ja-JP"]xcrun swiftc -O ocr.swift -o ocrtool
ls live/* | xargs -n 8 ./ocrtool > ocr_live.jsonlWhat came out of the offending shot:
Annual | BEST VALUE | $2.92 / mo • Save 41% | $34.99 | Monthly | Billed monthly |
$4.99 | 7-day free trial, then $34.99 per year | SubscribeA detection regex built on currency symbol plus digits will miss things. There were shots with "free trial" and no number at all. So I widened it:
free trial|per year|per month|/\s?mo\b|billed|save \d|\d+%\s?off|best value|무료 체험|無料|…The values found across the 8 apps: $49.99/$7.99/Save 49%, $29.99/SAVE 50%, $19.99/$2.99/SAVE 44%, $39.99/Save 33%, $89.99/yr·$14.99/mo, $3.99/$29.99, Start Pro - $4.99 / month. In three apps the caption band I wrote myself contained "7-day free trial".
One distinction matters. Money that is app content is not a violation. Spend amounts in a receipts app, nightly-rate comparisons, a "$62" inside a conversation — those stay. The test is whether it states the price of this app or its subscription.
The fix — a switch, not a deleted screen
struct PaywallView: View {
var hidesPricing = false // store shots only. production keeps the default false
...
if !hidesPricing {
if pricesLoaded { planPicker; billingDisclosure; subscribeButton }
else { unavailableState }
}
}
// RootView: case "paywall": PaywallView(service: paywall, hidesPricing: true) {}Removing the pricing block left 60% of the screen empty, so I gave the screenshot path its own layout (centered, line height 24, double bottom padding).
I did not write new marketing copy. I pulled the Pro benefit list into a constant so the paywall and the store shot share the same localization keys. Inventing copy here would have attached a 16-locale translation debt to it.
Two things the capture pipeline surfaced:
- The size guard's ceiling was 1.6MB and the home-screen shot was 1.9MB. A perfectly good shot was retried four times per locale, burning a minute per locale. The guard was checking "an incidental byte count", not "quality".
- I edited a zsh script while it was running. zsh reads the file incrementally, so execution can break. I ended up killing it and starting over. Don't.
Result: iPhone 6.9" 16 locales × 5 shots plus 5 iPad shots re-captured, all 85 clean of price text under OCR, all 16 locales at 5 shots · COMPLETE · correct order, 0 preflight blockers, 1.1.7 in review.
Three things to check on your own build
- Have you run your live screenshots through OCR? Don't just grep for currency symbols — include "free trial", "save", "billed", "best value". Violations without a single digit exist.
- When did you last actually look at your iPad (or least-used device) slots? And check which slots your own checker inspects.
- When you ship a change that triggers re-review, do you know the full list of assets it drags onto the review desk with it? A caption experiment put my paywall shots back in front of a reviewer.
The honest part
- You cannot read the rejection reason over the API.
reviewSubmissionsstops atUNRESOLVED_ISSUES, item state stops atREJECTED. A GET on a single item is 403 (CREATE/DELETE/UPDATE only), andresolutionCenterThreads/reviewRejectionsare not in the public API. The iris path wants a session cookie, so an API key gets 401. In the end a human had to open the web UI and paste the text before diagnosis could start. That is exactly where automation stops. - I was tempted to edit the caption copy and resubmit without knowing the reason. I didn't. A guessed fix burns another review cycle. The captions turned out innocent, so the call was right — but the price was hours blocked on human input.
- I fixed one app. The other seven are deferred to their next regular releases. Submitting six at once puts six reviews in flight, and one of them blocking for an unrelated reason delays all of them.
- There is a point with no cheap fix. Live screenshots cannot change without a new version: a new build plus a full review cycle. And apps under experiment must not be touched — if the control changes mid-experiment, the measurement dies. So 2 of the 8 violations stay in place, knowingly, until the experiments end on 09-30 and 11-16.
- The audit sample is thin. One locale per app (en-US preferred). Price text surviving in a single other locale is invisible to it, and OCR misses prices rendered into images.
- That arm of the experiment lost a day, and after resubmission and approval it needs another 54 days to reach a verdict.
- The 1.1.7 release notes are effectively "maintenance". I paid the full cost of shipping a version that changes nothing for users, for one screenshot swap.
Conclusion
This has the same shape as the 4.3(b) rejection where I searched the app and the answer was in the listing. Review does not look at what I changed this time. It looks at my entire product page, against today's standard.
And on the same day, the script watching these very experiments was lying to me too — that one is written up separately in the list endpoint said not started.
Would you check one app — just one — for a "free trial" sitting in a live screenshot right now? I had eight.