Shipping Deep Cut Atlas: what broke during my first App Store submission
I finally submitted Deep Cut Atlas for App Store review this week. The app itself has been done for a while, the last real feature landed weeks ago, but “submit it” turned out to hide a surprising amount of work. Not hard work, exactly. Just a long chain of small, sharp platform gotchas that only show up once, at the very end, in production.
Here’s what actually broke, in the order I hit it.
The in-app purchase I created wrong
Deep Cut Atlas has one paid feature: a one-time “Pro” unlock that syncs your dismissed-albums list across devices. Everything else is free. Setting this up in App Store Connect meant picking a “Type” for the in-app purchase, and I picked the wrong one. Consumable instead of Non-Consumable. They’re right next to each other in the dropdown and I didn’t think twice.
This is not a cosmetic mistake. StoreKit 2’s whole API for “does this user own this” is Transaction.currentEntitlements, and it never returns consumable purchases. By design. Consumables are supposed to be used up and rebought, like extra lives in a game, so Apple doesn’t track their ownership history the way it does for a permanent unlock.
The scary part is how invisible this would have been. My purchase code flips local state immediately from the transaction StoreKit hands back, so the button would correctly say “Pro” right after buying it. Then the user closes the app. Next launch, the code re-derives Pro status from currentEntitlements, finds nothing, and the purchase is just gone. Forever. “Restore Purchases” can’t bring it back either, because there’s nothing in the entitlements stream to restore. A paying customer would have hit this and had no way to know why.
Nothing in my test suite caught this, because nothing in a test suite can. It’s a property of Apple’s live infrastructure, not my code.
Then I found out you can’t fix a product ID
Once I noticed the Consumable mistake, my plan was: delete it, recreate it correctly, done in thirty seconds. App Store Connect had other plans.
The Product ID you entered is already being used by another in-app purchase associated with this team.
Product IDs are permanently reserved the moment you type them in, even if the IAP is deleted five seconds later, even if it was never submitted or purchased by anyone. There’s no reuse, no waiting period, no appeal. I had to pick a brand new ID, which meant a real code change (the ID is a hardcoded Swift constant, checked against a local StoreKit testing config by a unit test) and a new build, not just an App Store Connect edit.
Lesson for next time: the product ID is one of maybe three decisions in this whole process that are truly permanent. Slow down on those specifically.
The purchase button that “didn’t work,” except it did
After fixing the product ID, I wanted to test the purchase flow for real before shipping, so I set up TestFlight and installed the build on my own phone. Tapping “Unlock Pro” immediately failed: “Pro isn’t available right now.”
Turns out this is expected, not a bug. A brand-new app’s first-ever in-app purchase isn’t available for sandbox testing until it’s been submitted for review at least once, attached to a version. Product.products(for:) just returns nothing until Apple’s backend “activates” the product, and that activation is tied to a real submission, not to the IAP simply existing. Once an app has shipped even once, every future new IAP works immediately. Only the very first one has this restriction.
So the order of operations is inverted from what I expected: submit first, then test, not the other way around. Apple does let you pull a submission back out of the review queue before it’s approved if you find something broken, so this isn’t a one-way door, just a confusing one.
One thing that did work the way I hoped: TestFlight builds always run purchases through Apple’s sandbox automatically, no matter which Apple ID installs them. No separate sandbox tester account needed, no real charge, just a small “[Environment: Sandbox]” label in the purchase sheet. That part was pleasantly simple.
The archive that quietly lied to me
This one stung a little. After fixing the product ID, I told the agent I’d uploaded a new build. It had a new build number and everything. Except it hadn’t actually rebuilt anything.
Xcode’s Organizer keeps every archive you’ve ever made in a list, and “Distribute App” can be run on any of them, including an old one from hours earlier. I’d clicked into an old archive entry instead of running Product then Archive fresh, so the “new” build was really the old binary repackaged under a new number. None of my actual code fixes were in it.
The only way this got caught was by unzipping the exported .ipa and checking it directly: Info.plist’s UIDeviceFamily key still said the app supported iPad, which it shouldn’t have after a fix I’d just made. That single mismatched value was the tell. Once I did a genuine fresh archive, everything lined up: the right build number, the right device family, and grepping the compiled binary’s strings confirmed the new product ID was actually in there and the old one was completely gone.
I don’t think I’d have caught this without checking the actual shipped artifact instead of trusting the “build uploaded” confirmation. Worth remembering for any future build pipeline: verify the thing that left the building, not the thing you meant to send.
A rename with a two-week fuse
Weeks earlier, in an unrelated session, I’d renamed the app’s compiled product from the old working title to “Deep Cut Atlas” so the shipped binary would match the real App Store name. Simple settings change, build succeeded, moved on.
It turns out that single rename had quietly broken two things that don’t fail until you run the full test suite, not just a plain build. The test target’s TEST_HOST setting still pointed at the old bundle path by name, so tests couldn’t find their host app anymore. And the compiled Swift module name silently follows the product name by default, so every @testable import of the old name in my test files stopped resolving.
The fix was one line, not a mass find-and-replace: explicitly pin PRODUCT_MODULE_NAME back to the old internal name, decoupling the Swift-level identity from the user-facing product name. Same idea as keeping an Xcode target/scheme name stable through a rebrand, just one layer deeper than I’d thought to check.
The last few paper cuts
A handful of smaller things, each one a hard stop until resolved. App Store Connect’s Support URL field flatly rejects mailto: links - it wants http:// or https://, full stop, no exceptions for a solo developer without a dedicated support page. Screenshot dimensions are an exact allow-list, not “close enough” - the newest iPhone’s native simulator resolution isn’t on Apple’s accepted list for the large-iPhone slot, and resizing to the nearest accepted size with sips was safe since the aspect ratios are within half a percent, but I had to find that out from a validation error, not from reading ahead. And the project had shipped as “universal” (iPhone and iPad) since the day I created it in Xcode, purely because that’s the template default; I’d never actually adapted or tested the layout for iPad, and Apple’s screenshot requirement for 13-inch iPad displays is what finally forced the decision: iPhone-only for this version.
What I’d tell past me
None of this was hard, individually. Each fix was small. What made it slow was that almost every one of these gotchas is invisible until the exact moment it bites, and several only show up in the gap between “looks done” and “verified against the actual shipped artifact.” The build that silently didn’t rebuild is the one that’ll stick with me. Everything looked correct from the outside. The confirmation email even came through. The only thing that gave it away was refusing to trust the confirmation and checking the binary itself.
Deep Cut Atlas is in review now. First submission for the App Store, and honestly, most of the actual app-building was the easy part.
Related reading
An App Review resubmission, and two App Store Connect quirks nobody documents
A screenshot already accepted elsewhere in the same listing, rejected three times - and an attach flow that only works from the opposite direction Apple's docs describe.
The App Store screenshots nobody updated for two weeks
Two folders that look interchangeable and aren't, a JSON cache that survives reinstall, and three rounds of 'looks done' that weren't.
Building a rating prompt I was sure wouldn't render in the simulator
A three-wins gate, one requestReview() call, and a piece of received wisdom about simulator behavior that turned out to be wrong when actually tried.