TL;DR — I shipped In-App Purchases for BookTS (my EPUB/PDF-to-audiobook app) and spent roughly 20 of the setup hours fighting App Store Connect, not Swift. None of it was a StoreKit bug. It was: (1) a Paid Apps Agreement that looked signed in the UI but was actually stuck in “New” status, (2) an IAP config page that stages your edits locally and discards them on navigation unless you hit the one real Save button, (3) sandbox propagation that is real, staggered, and can take from 10 minutes to a few hours per product, (4) a subscription paywall you literally cannot see once you already own the lifetime unlock, and (5) a 1024px icon rejected over an alpha channel nobody told me to check. Five fixes, one checklist, and a habit that would have saved me most of the 20 hours: verify state via the ASC API instead of trusting the page.
The job-to-be-done
When I’m setting up In-App Purchases for the first time on a real App Store Connect account, I want to get a lifetime-unlock product and an auto-renewable subscription live in sandbox so I can test the real purchase flow with StoreKit 2, so that I ship a paywall that actually works instead of discovering in review — or worse, in production — that Product.products() returns an empty array for reasons nobody surfaced.
That last clause is the whole post. Every one of these five gotchas produces the same symptom: your app calls Product.products(for: productIDs), and it comes back empty, or your purchase() call throws something unhelpful. StoreKit doesn’t tell you why the product isn’t resolving — incomplete metadata, an inactive agreement, and normal propagation lag all look identical from the client. You’re debugging a black box with one output: empty or not-empty. So the discipline that saves you isn’t clever Swift, it’s knowing which of five completely different root causes you’re looking at.
Gotcha 1 — the Paid Apps Agreement that lies to your face
Before any paid product can appear anywhere, sandbox included, your Paid Apps Agreement in App Store Connect has to be fully Active. Apple’s own canonical checklist for this is technote TN3186 — read it before you touch the IAP page, not after.
The trap: ASC’s Agreements screen can look signed. You clicked through the flow, you got a confirmation, the page feels done. But the real signal is one column in the Agreements table — Status — and it has to read “Active” with a populated Effective Date. If the underlying legal-entity step (banking, tax) isn’t fully processed, the status sits at “New” with the Effective Date blank, and the agreement has never actually activated. Nothing downstream tells you this explicitly. Your products just never show up in sandbox, forever, and everything else about your setup can be perfect.
Fix: before debugging anything else, go to Agreements, Tax, and Banking and confirm Status = Active + Effective Date populated. If it isn’t, nothing past this point matters.
Gotcha 2 — the IAP page is a staged-save trap
Once the agreement is active, you build the product on the In-App Purchases page: pricing, Availability (which countries), and a Localization (display name + description). This is where I lost the most hours, because the page’s save model is not what it looks like.
Each section has its own Done or Save button — the Availability radio group, the Localization dialog. Clicking those only stages the change locally in the page’s client-side state. Nothing is committed until you hit the top-level Save button for the whole product. Navigate away, or hit one of ASC’s occasional generic errors (“Sorry, something went wrong”), and the staged edits are silently discarded — while the UI still displays them as if they’d saved, because you’re looking at local state, not a re-fetch from the server.
The resulting product is incomplete (missing localization, or availability never actually set), and an incomplete product is permanently invisible to Product.products() — no error, just an empty result. That failure mode is indistinguishable from normal propagation lag unless you know to check. I burned most of a day assuming I just needed to wait longer.
The rule I now follow on every single edit: hit top-level Save, then reload the page from scratch, then re-verify every section — the Availability radio actually shows “All countries or regions selected,” the localization grid row shows real values rather than an empty “Add localization” prompt, price shows a currency, not a placeholder. The fastest diagnostic tool on the page, once you know it exists: the “Add for Review” action. It refuses to proceed and lists exactly what’s missing — use it as a completeness linter, not just a submission step.
Gotcha 3 — propagation is real, and it’s staggered per product
Assuming the agreement is active and the product is genuinely complete, there’s still a real propagation delay before sandbox resolves it — and it is not a fixed number. Across three products in the same app, I measured:
- A freshly created, clean product (never edited after initial save): resolved in sandbox in about 10 minutes.
- A product I created, then edited and re-saved (see Gotcha 2 above, after fixing a staged-save loss): about 45 minutes.
- The auto-renewable subscription: slowest of the three, taking a few hours.
The lesson isn’t “wait longer” — it’s that propagation time is not comparable across product types, so a 10-minute wait teaches you nothing about whether the subscription is stuck. Treat each product’s propagation as its own clock.
Gotcha 4 — testing a subscription you already own
Here’s a scenario that’s easy to walk into: BookTS sells both a lifetime unlock and an auto-renewable subscription. I’d already purchased the lifetime unlock under my sandbox tester while debugging Gotcha 1–3. When I went to test the subscription flow, the paywall simply never appeared — because StoreKit correctly saw the account already had an active entitlement and skipped the purchase UI, exactly as it should in production.
The obvious fix — clear purchase history for that sandbox tester and retry — doesn’t exist as a button if that tester isn’t listed under Users and Access → Sandbox Testers in ASC (mine had been created directly on-device and never appeared there). There’s no clear-history action for a tester ASC doesn’t know about. The actual fix: create a new sandbox tester in ASC, then on the test device go to Settings → App Store → Sandbox Account (or Settings → Developer → Sandbox Account on some iOS versions) and sign in as that fresh tester. Clean entitlement state, paywall reappears.
Once you’re testing subscriptions specifically: sandbox renewal intervals are dramatically compressed by default — roughly every 5 minutes instead of the real billing period — and a sandbox subscription auto-renews up to 12 times before it stops, which is exactly enough cycles to watch a full renewal-then-expiry lifecycle in about an hour instead of a year.
Gotcha 5 — the icon and screenshot rejections that have nothing to do with your app
Two purely mechanical rejections, both silent about the actual cause until you dig into the error text:
- The 1024×1024 marketing icon must be fully opaque — no alpha channel at all. An icon exported with any transparency, even fully-opaque pixels that merely carry an alpha channel, gets rejected on upload. Check with
sips -g hasAlpha <path.png>before you upload, not after a rejection email. If you’re rendering the icon programmatically, generate it withCGImageAlphaInfo.noneSkipLast, notpremultipliedLast— the latter still writes an alpha channel even when every pixel is fully opaque. - Screenshots must match an accepted display-size slot exactly — for example the 6.5” slot wants 1284×2778. A capture straight off a 6.3”-class device does not match any accepted slot size and gets rejected on upload, regardless of how good the screenshot looks. You either capture on a device/simulator whose native resolution matches an accepted slot, or you render the screenshot programmatically at the target size.
Neither of these is a StoreKit or IAP problem specifically, but both showed up during the same submission pass and cost real time because the failure messages don’t explain what property is wrong, only that the asset is not accepted.
The one habit that would have saved the most hours: verify state via the API, not the page
Every gotcha above shares a root cause: the ASC web UI is not a reliable source of truth about server-side state. It shows staged local edits as if committed, it doesn’t surface “New” vs “Active” clearly, and it gives no signal about propagation progress at all. This is the same lesson I wrote about in Harness Engineering: you can’t trust a system’s self-report, you need a deterministic check outside it. On BookTS, that check is a small script that does a JWT-signed read against the App Store Connect API — the same key-based auth used for headless notarization and TestFlight uploads — to pull the actual server-side product and agreement state directly, instead of trusting whatever the last page load rendered. It’s the same principle behind the Eval-Framework post: don’t guess from how something looks, measure the underlying state directly. A five-line API check would have told me, in Gotcha 2, that the localization was missing server-side — instead of the UI convincing me it wasn’t.
On the client side, the fix belongs in StoreKit 2 itself: every purchase should route through Transaction.updates, unwrap the VerificationResult, call transaction.finish() only after your entitlement store is updated, and re-derive entitlement state from Transaction.currentEntitlements rather than trusting a cached “purchased” flag. That closes the loop symmetrically — server-side state gets verified via the API, client-side state gets verified via StoreKit’s own transaction ledger, and neither depends on a UI that might be showing you something that was never actually saved.
What this still can’t fix
Even with the API check and the reload-and-reverify habit, propagation delay is genuinely opaque. Apple gives you no queue position, no ETA, and no webhook — you poll, and a product that’s been stuck for 40 minutes and a product that’s about to resolve in the next 30 seconds look exactly the same from your side. The only mitigation is time-boxing: if a complete product (verified via the API, not the page) hasn’t resolved in sandbox after roughly an hour, something is actually wrong and it’s worth re-checking Gotchas 1 and 2 rather than continuing to wait. There’s no way to make that judgment call any earlier than that, and no amount of tooling changes it — some parts of shipping on this platform are just waiting on a system you can’t see inside of.
If you’re setting up IAP for the first time: read TN3186 first, verify Agreement status before touching the IAP page, save at the top level and reload to confirm, expect propagation time to vary wildly by product type, create a dedicated sandbox tester per test scenario, and check your icon’s alpha channel before you ever get a rejection email. None of it is hard. All of it is invisible until it costs you a day.