For about six months my app had roughly 185 downloads and zero purchases. I assumed the obvious thing: people didn't want to pay. I rewrote the paywall copy. I moved the upgrade entry point. I second-guessed the price.
The actual reason was that the purchase button did nothing at all. Tap it, and the app did not show a sheet, did not show an error, did not log anything. It just sat there. And StoreKit was behaving exactly as documented the entire time.
Empty is not an error
In StoreKit 2 you fetch products like this:
let products = try await Product.products(for: [Self.proProductId])That call is a batch lookup, and it treats an unknown product ID the way a dictionary lookup treats a missing key: the ID simply isn't in the result. It does not throw. It does not warn. Ask for one product that isn't on sale and you get back an empty array, successfully.
Which means this shape — and it is a very natural shape to write — is a silent dead end:
// The bug
let products = try await Product.products(for: [proProductId])
guard let product = products.first else { return } // ← silently does nothing
let result = try await product.purchase()Every review of that code reads fine, because the failure it guards against feels impossible — of course my own product ID exists, I typed it myself. The try in front of the call makes it feel like errors are handled. They are, just not this one, because this one is not an error.
Why the store had never heard of my product
The product ID was correct. It existed in App Store Connect. Its status, which I had not looked at in months, was Developer Rejected.
Here is the rule that caused it, and it is the part I had never seen written down clearly: your first non-consumable in-app purchase has to be submitted for review attached to an app version. It does not get reviewed on its own, so it rides along with a version submission.
Which means the reverse is true as well. Months earlier I had withdrawn a version submission for an unrelated reason. Withdrawing it also cancelled the in-app purchase attached to it. The IAP dropped back to Developer Rejected — never on sale, never reviewed. App Store Connect did not warn me, and the app had no way to know.
So the store was right to return an empty array. There genuinely was no purchasable product. Every layer behaved correctly, and the result was six months of a dead button.
Two rules that came out of this
An empty product list must be a visible state, never an early return. The fix is not clever — it is just refusing to swallow it:
let products = try await Product.products(for: [Self.proProductId])
guard let product = products.first else {
IAPDiagnostics.recordProductMissing()
throw EntitlementError.productNotFound
}Product IDs are burned forever. Separately, I had created a product ID in App Store Connect, deleted it, then later tried to reuse the same string. Apple keeps deleted product IDs reserved permanently — you cannot re-register one. I had to move to a new ID with a 2 on the end and make sure every reference in code matched it exactly.
The diagnostics screen I should have had on day one
There is a second version of this problem, and it bit me later during review. A reviewer rejected the app because the purchase failed for them. Their screenshot showed the price rendering correctly — so products were fetching fine — but the app's only feedback was a generic "Purchase failed, please try again". Whatever product.purchase() actually threw had been swallowed. With nothing but that screenshot, there was nothing to debug.
So I built a diagnostics screen into settings. It does not attempt a purchase — in production that would charge real money. It just lays the black box open and makes every link in the chain readable and copyable:
- Whether the product can be fetched at all, and at what price and currency
- Whether the device can make payments (
AppStore.canMakePayments) - Which StoreKit environment and which storefront region the app is actually in
- Any entitlement the user already holds
- The last purchase error, raw — domain, code and description, recorded at the moment it was thrown
That last one matters most. A user who cannot pay you is not going to file a good bug report, and neither is a reviewer. Recording the raw error somewhere they can copy it turns "it doesn't work" into an actual diagnosis.
What I'd tell past me
- Zero purchases is a bug report until proven otherwise. Six months of rewriting copy was six months spent optimising a button that was not connected to anything.
- Check the IAP's status in App Store Connect after any version submission is withdrawn, rejected or replaced. The two are coupled and nothing tells you.
- Never let "the store returned nothing" and "the purchase failed" collapse into the same user-facing message.