Your StoreKit 2 paywall can fail in production even when your product IDs are correct and your code hasn’t changed. The painful part is that the failure often looks like a client bug: Product.products(for:) returns an empty array, or one product silently disappears after a metadata change.
This is a triage playbook for the “reasons your code can’t see”: App Store Connect configuration and state (offers, agreements, review attachment, propagation/caching) that can make StoreKit responses incomplete or undecodable.
The mental model: StoreKit 2 is a decoder over a server response
StoreKit 2 is not “querying a local catalogue”. It’s fetching a response derived from App Store Connect state, then decoding it into Product models.
That distinction matters because some classes of server-side inconsistency present as:
- No thrown error (just an empty product list).
- A decode error logged by StoreKit (and then the product is dropped from results).
- Different behaviour between Sandbox and Production.
Apple’s own guidance acknowledges that IAP configuration and agreements are prerequisites, and that metadata changes can take time to show up in Sandbox (Apple explicitly calls out “up to 1 hour”). (developer.apple.com)
Symptom-first triage map (what you see → what it usually is)
You don’t have time to spelunk App Store Connect blindly. Start from the symptom and narrow it.
Symptom A: Product.products(for:) returns an empty array (no error)
This is the most common production-breaking symptom because most paywalls assume “empty” means “your IDs are wrong” or “network failed”, and then render placeholders.
Likely causes:
- Paid Apps Agreement / tax / banking not in a state that allows IAP vending. Apple documents that to offer IAP, the Account Holder must accept the Paid Apps Agreement and provide banking and tax information. (developer.apple.com)
- Agreement state flipped recently (membership renewal, banking change, tax form update). Developers report StoreKit stopping and then recovering hours later after resolving agreements; treat this as a propagation/caching problem after you fix the paperwork. (reddit.com)
- You’re in a “first app / never approved” or “review attachment” edge case. There are reports of apps whose first version is in review where products marked “Ready to Submit” still aren’t vended, blocking review. (developer.apple.com)
What to do first:
- Have the Account Holder open App Store Connect → Agreements, Tax, and Banking and confirm there are no banners, pending updates, or anything “Active” that is actually conditional.
- Confirm each product has pricing and localisations completed. Apple’s sandbox troubleshooting guidance calls out missing configuration as a reason products don’t return. (developer.apple.com)
- Assume propagation: re-test after an hour (and in practice, be prepared for longer) after any agreements or metadata change.
Symptom B: One product vanishes; StoreKit logs countMismatch / “Failed to decode Product”
This is the class of bug that makes teams lose a day because it smells like corrupted JSON or a StoreKit 2 bug in your code. Often it’s neither: it’s your server-side configuration creating an “impossible” combination that StoreKit’s decoder refuses.
A real-world pattern reported in September 2026: changing an introductory offer (e.g., free trial duration) by scheduling a new offer while the existing one is still active can result in StoreKit receiving two introductory offers for the same subscription, which triggers a decode countMismatch and causes StoreKit to drop the product. (reddit.com)
Practical guidance:
- Do not overlap introductory offers for the same product. If App Store Connect’s UI flow allows “end old offer on date X, start new offer on date X” (or creates any period where both exist), assume this may break StoreKit decoding for a subset of clients.
- Prefer a “clean cutover”: end the existing offer, wait for propagation, then create the new offer, even if it means a short window with no intro offer.
Symptom C: Works in Xcode .storekit config / local testing, fails in TestFlight or production
This is an anti-signal: local StoreKit testing is valuable for UI and purchase flows, but it does not exercise the same server response shape as production.
Common causes:
.storekitconfig hides agreement/account problems because you’re not actually hitting StoreKit’s live catalogue.- Your
.storekitfile can’t represent server-side edge cases (for example, overlapping offers) in the same way production can.
Action:
- Always keep a build path that exercises live StoreKit (scheme StoreKit Configuration set to “None”), and run that path during release prep, especially after changing offers/pricing. (Developers have explicitly called out this approach when debugging intro offer failures.) (reddit.com)
Symptom D: Sandbox shows changes later than App Store Connect; behaviour differs across environments
Apple explicitly notes that product metadata changes can take up to an hour to appear in Sandbox. (developer.apple.com)
Implication:
- If you’ve just edited pricing, localisations, or subscription metadata, treat test results inside that window as non-deterministic.
- Your triage process should log when the App Store Connect change was made, and when the device fetched products, so you can correlate.
The minimal logging that turns “maybe our code” into evidence
If you can’t prove what StoreKit returned, you’ll end up changing UI code, retry loops, caching, and SDK versions—none of which fixes an upstream catalogue issue.
You want logs that answer two questions:
- What exact identifiers did we ask for?
- What did StoreKit return (counts, IDs, and any decode errors)?
Client logging (high signal, low volume)
Log one structured event per fetch attempt:
- App version/build, iOS version, device model
- Environment: local StoreKit testing vs Sandbox vs Production
- Storefront identifier (where available)
- Requested product IDs (sorted)
- Returned product IDs (sorted) and returned count
- Missing IDs (requested minus returned)
- Duration of call, plus whether this was cold-start / after app resume
Also capture StoreKit’s own logs in your debug builds (or via unified logging collection on internal devices). The countMismatch class of issues often only surfaces there, while your async call still “succeeds” and returns fewer products.
Server-side logging (if you have a backend)
Even if you don’t run your own purchase system, having a backend endpoint that receives “paywall fetch diagnostics” from clients gives you correlation at scale.
Store:
- The client payload above
- A short “recent App Store Connect change window” note (you can add this manually during incidents)
- Whether this user later completed a purchase / restore
This is also where App Store Server Notifications fit: Apple recommends enabling server notifications for near real-time subscription events, and they’re part of a mature production posture for subscriptions. (developer.apple.com)
The App Store Connect checklist (what to verify before touching code)
When production is broken, the correct move is usually to freeze code changes and verify App Store Connect state with a strict checklist.
- Agreements, tax, banking - Paid Apps Agreement signed and effective (Account Holder) - No pending banking verification / tax form updates - No recent changes (renewal, banking edits) that could be mid-propagation (developer.apple.com)
- Product completeness - Product status, pricing set, at least one localisation - Availability configured for the storefronts you’re testing
- Offer hygiene (subscriptions) - Intro offers: confirm you haven’t unintentionally created overlapping intro offers during an edit window (trial duration edits are a known foot-gun) (reddit.com) - If you use offer codes / promotional offers, confirm you’re not shipping UI that assumes offer data will always decode (be defensive)
- Propagation and timing - If you changed metadata, wait and re-test rather than thrashing - Record timestamps of changes; Apple documents “up to 1 hour” for Sandbox metadata propagation (developer.apple.com)
- Submission / review attachment edge cases - For a first release, confirm whether your IAPs/subscriptions are correctly attached to the version under review, and that App Review can access them. - If StoreKit returns empty across all products despite apparently correct configuration, consider this a likely account/app state issue and escalate to Apple with your logs. Developers are reporting this exact “empty, no error” blocker for first approvals. (developer.apple.com)
Defensive paywall behaviour (how to avoid making it worse)
When StoreKit can’t vend a product, you want the UI to fail “loudly enough” for you, but “softly enough” for the user.
Design rules that reduce damage:
- Never render placeholder pricing as if it’s real. A missing product should be a distinct state.
- Time-box loading, then offer retry. StoreKit failures are often transient (propagation/caching); a retry affordance is pragmatic.
- Treat partial results as a first-class case. If you request 4 product IDs and get 3 back, don’t silently drop the missing tier; show a reduced set and report diagnostics.
- Do not gate retries on thrown errors only. Decode failures and empty arrays can arrive without an exception.
If you use a third-party SDK (RevenueCat, etc.), keep a “bypass switch” in your code path so you can call Product.products(for:) directly during incidents. That lets you prove whether the issue is upstream StoreKit or your vendor abstraction.
When to stop debugging and escalate
Escalate to Apple when:
Product.products(for:)returns empty for known-good product IDs across multiple devices/storefronts, and you’ve verified agreements/tax/banking and product completeness.- You have evidence of StoreKit decode failure (
countMismatchetc.) linked to an App Store Connect offer/pricing change.
Your escalation packet should include:
- The exact product IDs requested
- The exact date/time (and timezone) you made App Store Connect changes
- Logs showing requested vs returned IDs, plus any StoreKit decode errors
- Device/iOS versions affected
This avoids the dead-end loop of “please confirm your product IDs match”.
A small note on how Codeversols helps
Codeversols builds and maintains mobile subscription systems end-to-end (client, backend, observability). Where teams usually get stuck is not implementing StoreKit 2, but operationalising it: logging that survives production, incident playbooks, and safe rollout processes for pricing/offers so configuration changes don’t take your paywall down.
Close
StoreKit 2 failures that look like “our code is broken” are often catalogue integrity and account state problems: agreements, offer overlap edge cases, review attachment, and plain propagation.
The practical difference between a one-hour incident and a multi-day one is whether you can produce evidence: requested IDs, returned IDs, decode errors, timestamps, and environment. Build that diagnostic path once, and the next time StoreKit returns an empty list with no error, you’ll know exactly where to look—and when not to touch your paywall code at all.



