StoreKit 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, or AppStore.sync().
- Deciding where entitlement state lives, when to call
finish(), or how to
implement Restore Purchases.
- Setting up a
.storekit configuration 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/inAppPurchases plus inAppPurchaseLocalizations,
inAppPurchasePriceSchedules, and inAppPurchaseSubmissions for
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(),
// 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 with revocationDate == nil is 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 via Transaction.updates on every launch).
- The
Transaction.updates listener 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 calls AppStore.sync() first, even when a
local cache looks empty — that's the 3.1.1 contract, 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 for purchase()
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 on
Product.PurchaseResult still needs @unknown default — the compiler won't
warn when Apple adds a case; recheck on every OS-support bump. Practice
observed.
- 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
.failed for 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. .background deprioritizes it behind arbitrary
work; one real app shipped .background first and upgraded to .utility
after review. Practice observed.
- The bridge/skeleton above typechecks clean under
swiftc -swift-version 6 -typecheck (Swift 6.3.2 / Xcode 26.5), 0
errors/warnings. Compiled-verified.
Transaction.updates, .currentEntitlements, .finish(),
.revocationDate, AppStore.sync(), Product.products(for:),
.purchase(options:), .PurchaseResult, VerificationResult — each
symbol's existence/signature confirmed against
developer.apple.com/tutorials/data/documentation/storekit/...json.
Apple-doc-verified.
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 keep currentEntitlements() a flat Set<String>.
- Subscriptions — the "own it or don't" model is too coarse; you need
Transaction.subscriptionStatus and renewal-state handling this skill does
not cover.
Common Mistakes
- Persisting
isPurchased instead of deriving it from currentEntitlements().
- Never calling
finish(), or calling it before the entitlement is applied.
- Starting the
Transaction.updates listener lazily instead of at launch.
- Treating
products(for:) returning [] as a thrown-error case.
- Skipping
AppStore.sync() in restorePurchases() "because the cache is empty."
- No
@unknown default on the Product.PurchaseResult switch.
- Treating the
.storekit file as unit-test infrastructure — it configures
the interactive runtime and StoreKitTest, not the fake bridge.
Review Checklist
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.
1---2name: storekit2-iap-defaults3description: 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.currentEntitlements` for unlock state; `finish()` timing; `AppStore.sync()` restore; `.storekit` + Fake-bridge test seam. Invoke when adding IAP, wiring StoreKit 2, or asked "how do I unlock a purchase / restore purchases / test IAP". Does NOT cover subscriptions or ad SDKs.4---56# StoreKit 2 IAP Defaults78Default shape for the smallest IAP most solo/small apps ship: one9non-consumable unlock (Remove Ads, Pro Unlock). `Product`, `Transaction`, and10`AppStore` have no public initializers — you cannot construct a fixture — so11the seam below exists to make StoreKit 2 testable at all, not for abstraction's12sake.1314## When to invoke1516- Adding a first non-consumable IAP to a new or existing app.17- Wiring `Product.products(for:)`, `Transaction.updates`,18 `Transaction.currentEntitlements`, or `AppStore.sync()`.19- Deciding where entitlement state lives, when to call `finish()`, or how to20 implement Restore Purchases.21- Setting up a `.storekit` configuration file or a StoreKit unit-test seam.22- Asked "how do I test a purchase without a sandbox account" or "why isn't my23 unlock surviving reinstall".2425## Scope2627Owns: bridge/seam shape, entitlement-derivation rules, test strategy for28**non-consumable IAP**. Does NOT own:2930- Subscriptions/consumables — different renewal semantics; this skill's31 `currentEntitlements()` shape is deliberately "own it or don't."32- Ad SDK isolation — same bridge-protocol *pattern*, different domain →33 `monetization-sdk-integration`.34- What App Review requires of Restore Purchases / IAP pricing clarity (3.1.1)35 → `app-store-review-rejections`.36- Creating the IAP product in App Store Connect — the ASC API 2.0 has37 `POST /v2/inAppPurchases` plus `inAppPurchaseLocalizations`,38 `inAppPurchasePriceSchedules`, and `inAppPurchaseSubmissions` for39 automating this end-to-end → `asc-api-automation`; the web UI is the40 manual alternative.41- Getting the binary containing this code to TestFlight →42 `local-archive-export-upload`.4344## The bridge seam4546`Product`/`Transaction`/`AppStore` are untestable globals. Put a protocol47between the client and StoreKit; tests inject a fake instead:4849```swift50// StoreKitBridge.swift — no `import StoreKit`; fully fake-able.51protocol StoreKitBridge: Sendable {52 func products(for ids: Set<String>) async throws -> [BridgeProduct]53 func currentEntitlements() async -> Set<String>54 func purchase(productId: String) async throws -> BridgePurchaseOutcome55 func sync() async throws56 func transactionUpdates() -> AsyncStream<BridgeTransactionEvent>57}58struct BridgeProduct: Sendable, Equatable { let id, displayName, displayPrice: String }59enum BridgePurchaseOutcome: Sendable, Equatable {60 case success(productId: String), userCancelled, pending, failed(reason: String)61}6263// LiveStoreKitBridge.swift — the ONLY file that imports StoreKit.64import StoreKit65struct LiveStoreKitBridge: StoreKitBridge {66 func currentEntitlements() async -> Set<String> {67 var ids: Set<String> = []68 for await result in Transaction.currentEntitlements {69 guard case .verified(let t) = result, t.revocationDate == nil else { continue }70 ids.insert(t.productID)71 }72 return ids73 }74 // products(for:) / purchase(productId:) / sync() / transactionUpdates()75 // follow the same shape: Product.products(for:), Product.purchase(),76 // AppStore.sync(), Transaction.updates.77}78```7980Everything above `LiveStoreKitBridge` talks only to `any StoreKitBridge` — zero81`import StoreKit`. Verify: `rg '^(internal |public )*import StoreKit' Sources/`82→ expect exactly 1 hit.8384## Entitlement state, `finish()`, restore8586- **Unlock state is derived, not stored.** Don't persist "isPurchased"87 independently — derive it from `currentEntitlements()` each time (a88 non-consumable with `revocationDate == nil` is entitled); an independently89 stored boolean drifts from Apple's record on refund/family-share/restore.90- **Call `finish()` after the entitlement is applied**, not before (risks91 losing the unlock on a mid-purchase crash) and not never (an unfinished92 transaction is redelivered via `Transaction.updates` on every launch).93- **The `Transaction.updates` listener starts at app launch**, not lazily on94 first paywall visit — refunds/family-share revocations/Ask-to-Buy approvals95 can arrive while the user is anywhere in the app.96- **`restorePurchases()` always calls `AppStore.sync()` first**, even when a97 local cache looks empty — that's the 3.1.1 contract, not an optimization to98 skip:99100```swift101func restorePurchases() async throws -> [BridgeProduct] {102 try await bridge.sync()103 let entitled = await bridge.currentEntitlements()104 guard !entitled.isEmpty else { return [] }105 return try await bridge.products(for: entitled)106}107```108109## Testing strategy110111| Layer | Tool | Covers |112|---|---|---|113| Unit tests | `FakeStoreKitBridge` (scripted outcomes + call counters) | Client logic — zero StoreKit dependency, runs in CI |114| Interactive local run | `.storekit` file wired into the Xcode scheme (Run → Options → StoreKit Configuration) | Manual purchase-flow smoke test, no sandbox Apple ID |115| Automated purchase-flow tests | `StoreKitTest`'s `SKTestSession` (loads the same `.storekit` file) | XCUITest/integration-level flows against the real StoreKit stack |116117A `.storekit` file is a testing fixture with no effect on a shipped build;118it's what enables the last two rows, not a gap in unit-test coverage if absent.119120## War stories (evidence tier in italics)121122- `Product.products(for:)` returns `[]`, not a thrown error, for an unknown ID123 (typo/sandbox drift) — decide what "no products" means for `purchase()`124 before you hit it (one real app: `.failed(reason: "product not found: <id>")`125 rather than a silent no-op). *Practice observed.*126- A verified `Transaction.updates`/purchase-path switch on127 `Product.PurchaseResult` still needs `@unknown default` — the compiler won't128 warn when Apple adds a case; recheck on every OS-support bump. *Practice129 observed.*130- Post-purchase catalog refetch can come back empty even though the purchase131 succeeded (rare ASC catalog instability). Synthesizing a minimal entitled132 product (id + a locale-neutral placeholder price) beats `.failed` for a133 purchase Apple already charged for; pair it with a telemetry hook so the134 desync is observable. *Practice observed.*135- The transaction-observer `Task`'s priority is a real UX decision: a136 refund/family-share event should flip entitlement state promptly while the137 user may be in-session. `.background` deprioritizes it behind arbitrary138 work; one real app shipped `.background` first and upgraded to `.utility`139 after review. *Practice observed.*140- The bridge/skeleton above typechecks clean under141 `swiftc -swift-version 6 -typecheck` (Swift 6.3.2 / Xcode 26.5), 0142 errors/warnings. *Compiled-verified.*143- `Transaction.updates`, `.currentEntitlements`, `.finish()`,144 `.revocationDate`, `AppStore.sync()`, `Product.products(for:)`,145 `.purchase(options:)`, `.PurchaseResult`, `VerificationResult` — each146 symbol's existence/signature confirmed against147 `developer.apple.com/tutorials/data/documentation/storekit/...json`.148 *Apple-doc-verified.*149150## Rationale151152The bridge exists because `Product`/`Transaction` have no public153initializers — "test the untestable" is the first wall a from-scratch154StoreKit 2 implementation hits, not a hypothetical. Isolating `import155StoreKit` to one file also keeps the client testable on CI runners without a156signed-in sandbox tester.157158## Deviation considerations159160- **A small catalog of non-consumables** — extend `BridgeProduct`'s fields,161 but keep `currentEntitlements()` a flat `Set<String>`.162- **Subscriptions** — the "own it or don't" model is too coarse; you need163 `Transaction.subscriptionStatus` and renewal-state handling this skill does164 not cover.165166## Common Mistakes1671681. Persisting `isPurchased` instead of deriving it from `currentEntitlements()`.1692. Never calling `finish()`, or calling it before the entitlement is applied.1703. Starting the `Transaction.updates` listener lazily instead of at launch.1714. Treating `products(for:)` returning `[]` as a thrown-error case.1725. Skipping `AppStore.sync()` in `restorePurchases()` "because the cache is empty."1736. No `@unknown default` on the `Product.PurchaseResult` switch.1747. Treating the `.storekit` file as unit-test infrastructure — it configures175 the interactive runtime and `StoreKitTest`, not the fake bridge.176177## Review Checklist178179- [ ] `import StoreKit` appears in exactly one file.180- [ ] Unlock state is derived from `currentEntitlements()`, not stored as an181 independent boolean.182- [ ] `Transaction.updates` listener starts at app launch.183- [ ] `finish()` is called after the entitlement is applied, on every path.184- [ ] `restorePurchases()` always calls `sync()` before reading entitlements.185- [ ] `Product.PurchaseResult`'s switch has an `@unknown default` arm.186- [ ] A fake bridge covers purchase success/cancel/pending/failed and restore187 empty/non-empty in unit tests.188- [ ] Restore Purchases is reachable from Settings (3.1.1 —189 `app-store-review-rejections`).190191## Related skills192193- `monetization-sdk-integration` — same bridge-isolation pattern for ad SDKs.194- `app-store-review-rejections` — Restore Purchases / pricing-clarity review gate (3.1.1).195- `asc-api-automation` — TestFlight/App Store ops after the build exists.196- `local-archive-export-upload` / `xcode-cloud-single-track-ci` — shipping the binary.197- `swift-dependency-injection` — the general protocol-injection pattern this bridge instantiates.198- `swift-testing-baseline` — where this bridge's fake fits this catalog's test stack.