Screenshots & Assets8 min read

The Free Trial Was a Price — What Got Rejected Was the Part I Didn't Change

One of five screenshot-caption A/B experiments came back rejected under 2.3.7. The images Apple attached were not the captioned shots I had made — they were paywall screenshots that had been live for months. Apple counts 'free trial' and 'save 41%' as price references. Here is the OCR audit of 267 live screenshots that found the same violation in 8 apps.

#screenshots#app-store#app-store-connect#ios#gotchas#reality-check
Diagram: inside the app a rule requires the price to be shown, while the same screen becomes a violation as a store screenshot
The same paywall screen. Inside the app, 3.1.2(c) requires it. As a store shot, 2.3.7 forbids it.

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

What 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 | Subscribe

A 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

  1. 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.
  2. When did you last actually look at your iPad (or least-used device) slots? And check which slots your own checker inspects.
  3. 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. reviewSubmissions stops at UNRESOLVED_ISSUES, item state stops at REJECTED. A GET on a single item is 403 (CREATE/DELETE/UPDATE only), and resolutionCenterThreads / reviewRejections are 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.

Related