storekit2-iap-defaults
Default StoreKit 2 architecture for a single non-consumable IAP (Remove Ads, Pro Unlock): `StoreKitBridge` isolates `import StoreKit` to one Live file; launch-time `Transaction.updates`; `Transaction.
Files2
View on GitHubSKILL.mdapple-dev-skills/skills/storekit2-iap-defaults/SKILL.md
GitHubStoreKit 2 IAP Defaults
Default shape for the smallest IAP most solo/small apps ship: one
non-consumable unlock (Remove Ads, Pro Unlock). Product, Transaction, and
AppStore have no public initializers — you cannot construct a fixture — so
the seam below exists to make StoreKit 2 testable at all, not for abstraction's
sake.
When to invoke
- Adding a first non-consumable IAP to a new or existing app.
- Wiring
Product.products(for:),Transaction.updates,Transaction.currentEntitlements, orAppStore.sync(). - Deciding where entitlement state lives, when to call
finish(), or how to implement Restore Purchases. - Setting up a
.storekitconfiguration file or a StoreKit unit-test seam. - Asked "how do I test a purchase without a sandbox account" or "why isn't my unlock surviving reinstall".
Scope
Owns: bridge/seam shape, entitlement-derivation rules, test strategy for non-consumable IAP. Does NOT own:
- Subscriptions/consumables — different renewal semantics; this skill's
currentEntitlements()shape is deliberately "own it or don't." - Ad SDK isolation — same bridge-protocol pattern, different domain →
monetization-sdk-integration. - What App Review requires of Restore Purchases / IAP pricing clarity (3.1.1)
→
app-store-review-rejections. - Creating the IAP product in App Store Connect — the ASC API 2.0 has
POST /v2/inAppPurchasesplusinAppPurchaseLocalizations,inAppPurchasePriceSchedules, andinAppPurchaseSubmissionsfor automating this end-to-end →asc-api-automation; the web UI is the manual alternative. - Getting the binary containing this code to TestFlight →
local-archive-export-upload.
The bridge seam
Product/Transaction/AppStore are untestable globals. Put a protocol
between the client and StoreKit; tests inject a fake instead:
// StoreKitBridge.swift — no `import StoreKit`; fully fake-able.
protocol StoreKitBridge: Sendable {
func products(for ids: Set<String>) async throws -> [BridgeProduct]
func currentEntitlements() async -> Set<String>
func purchase(productId: String) async throws -> BridgePurchaseOutcome
func sync() async throws
func transactionUpdates() -> AsyncStream<BridgeTransactionEvent>
}
struct BridgeProduct: Sendable, Equatable { let id, displayName, displayPrice: String }
enum BridgePurchaseOutcome: Sendable, Equatable {
case success(productId: String), userCancelled, pending, failed(reason: String)
}
// LiveStoreKitBridge.swift — the ONLY file that imports StoreKit.
import StoreKit
struct LiveStoreKitBridge: StoreKitBridge {
func currentEntitlements() async -> Set<String> {
var ids: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case .verified(let t) = result, t.revocationDate == nil else { continue }
ids.insert(t.productID)
}
return ids
}
// products(for:) / purchase(productId:) / sync() / transactionUpdates()
// follow the same shape: Product.products(for:), Product.purchase(options:)
// (visionOS: purchase(confirmIn:options:) instead — purchase(options:)
// isn't available there), AppStore.sync(), Transaction.updates.
}
Everything above LiveStoreKitBridge talks only to any StoreKitBridge — zero
import StoreKit. Verify: rg '^(internal |public )*import StoreKit' Sources/
→ expect exactly 1 hit.
Entitlement state, finish(), restore
- Unlock state is derived, not stored. Don't persist "isPurchased"
independently — derive it from
currentEntitlements()each time (a non-consumable withrevocationDate == nilis entitled); an independently stored boolean drifts from Apple's record on refund/family-share/restore. - Call
finish()after the entitlement is applied, not before (risks losing the unlock on a mid-purchase crash) and not never (an unfinished transaction is redelivered viaTransaction.updateson every launch). - The
Transaction.updateslistener starts at app launch, not lazily on first paywall visit — refunds/family-share revocations/Ask-to-Buy approvals can arrive while the user is anywhere in the app. restorePurchases()always callsAppStore.sync()first, even when a local cache looks empty — Apple'ssync()docs position it as the forced sync behind a user-initiated Restore Purchases control (Guideline 3.1.1 itself only asks for a restore mechanism), so it's not an optimization to skip:
func restorePurchases() async throws -> [BridgeProduct] {
try await bridge.sync()
let entitled = await bridge.currentEntitlements()
guard !entitled.isEmpty else { return [] }
return try await bridge.products(for: entitled)
}
Testing strategy
| Layer | Tool | Covers |
|---|---|---|
| Unit tests | FakeStoreKitBridge (scripted outcomes + call counters) |
Client logic — zero StoreKit dependency, runs in CI |
| Interactive local run | .storekit file wired into the Xcode scheme (Run → Options → StoreKit Configuration) |
Manual purchase-flow smoke test, no sandbox Apple ID |
| Automated purchase-flow tests | StoreKitTest's SKTestSession (loads the same .storekit file) |
XCUITest/integration-level flows against the real StoreKit stack |
A .storekit file is a testing fixture with no effect on a shipped build;
it's what enables the last two rows, not a gap in unit-test coverage if absent.
War stories (evidence tier in italics)
Product.products(for:)returns[], not a thrown error, for an unknown ID (typo/sandbox drift) — decide what "no products" means forpurchase()before you hit it (one real app:.failed(reason: "product not found: <id>")rather than a silent no-op). Practice observed.- A verified
Transaction.updates/purchase-path switch onProduct.PurchaseResultneeds@unknown default, not a plaindefault— without it Swift 6 mode fails to compile ("switch covers known cases, but 'Product.PurchaseResult' may have additional unknown values"; a warning in Swift 5 mode), and a plaindefaultwould silence the warning a case Apple adds later should raise (Switching Over Future Enumeration Cases); recheck on every OS-support bump. Compiled-verified (Swift 6.3.2). - Post-purchase catalog refetch can come back empty even though the purchase
succeeded (rare ASC catalog instability). Synthesizing a minimal entitled
product (id + a locale-neutral placeholder price) beats
.failedfor a purchase Apple already charged for; pair it with a telemetry hook so the desync is observable. Practice observed. - The transaction-observer
Task's priority is a real UX decision: a refund/family-share event should flip entitlement state promptly while the user may be in-session..backgrounddeprioritizes it behind arbitrary work; one real app shipped.backgroundfirst and upgraded to.utilityafter review. Practice observed.
Provenance for the bridge/skeleton's verification claims: references/official-docs.md.
Rationale
The bridge exists because Product/Transaction have no public
initializers — "test the untestable" is the first wall a from-scratch
StoreKit 2 implementation hits, not a hypothetical. Isolating import StoreKit to one file also keeps the client testable on CI runners without a
signed-in sandbox tester.
Deviation considerations
- A small catalog of non-consumables — extend
BridgeProduct's fields, but keepcurrentEntitlements()a flatSet<String>. - Subscriptions — the "own it or don't" model is too coarse; you need
Transaction.subscriptionStatusand renewal-state handling this skill does not cover.
Common Mistakes
- Persisting
isPurchasedinstead of deriving it fromcurrentEntitlements(). - Never calling
finish(), or calling it before the entitlement is applied. - Starting the
Transaction.updateslistener lazily instead of at launch. - Treating
products(for:)returning[]as a thrown-error case. - Skipping
AppStore.sync()inrestorePurchases()"because the cache is empty." - No
@unknown defaulton theProduct.PurchaseResultswitch (Swift 6 mode won't compile it), or a plaindefaultthat hides cases Apple adds later. - Treating the
.storekitfile as unit-test infrastructure — it configures the interactive runtime andStoreKitTest, not the fake bridge.
Review Checklist
-
import StoreKitappears in exactly one file. - Unlock state is derived from
currentEntitlements(), not stored as an independent boolean. -
Transaction.updateslistener starts at app launch. -
finish()is called after the entitlement is applied, on every path. -
restorePurchases()always callssync()before reading entitlements. -
Product.PurchaseResult's switch has an@unknown defaultarm (not a plaindefault). - A fake bridge covers purchase success/cancel/pending/failed and restore empty/non-empty in unit tests.
- A visible Restore Purchases control exists — this catalog's default
places it in Settings (3.1.1 —
app-store-review-rejections).
Related skills
monetization-sdk-integration— same bridge-isolation pattern for ad SDKs.app-store-review-rejections— Restore Purchases / pricing-clarity review gate (3.1.1).asc-api-automation— TestFlight/App Store ops after the build exists.local-archive-export-upload/xcode-cloud-single-track-ci— shipping the binary.swift-dependency-injection— the general protocol-injection pattern this bridge instantiates.swift-testing-baseline— where this bridge's fake fits this catalog's test stack.apple-skills:storekit(aggregated external) — StoreKit 2 API reference incl. subscriptions,SubscriptionStoreView, renewal state — the part this skill does not cover.- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.