Notes

My paywall did nothing for six months, and StoreKit never once returned an error

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])
A batch lookup. Ask for five IDs, get back however many the store recognises.

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()
This compiles, runs, and reports success. The user just watches nothing happen.

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
}
"Can't fetch the product" and "the purchase failed" are different diagnoses. Don't collapse them into one message.

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:

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