| name | storekit2 |
| description | Expert guidance for StoreKit 2 in-app purchases and subscriptions on Apple platforms. Use when developers mention: (1) in-app purchases, IAP, or subscriptions, (2) Product, Transaction, or currentEntitlements, (3) receipt validation or transaction verification, (4) SubscriptionStoreView / StoreView / ProductView, (5) restore purchases, offer codes, promotional offers, refunds, (6) StoreKit Testing in Xcode or sandbox testing. Not for RevenueCat or third-party billing SDKs. |
| license | MIT |
| metadata | {"author":"ulk","version":"1.0","source":"ulk (interne) — extrait des sections natives d'isaac (27), agents/mobile/27-isaac.md","source-version":"ios-26","maintainer":"izo"} |
StoreKit 2
Scope: StoreKit 2 Swift API (iOS 15+): products, purchase flow, transaction verification, entitlements, subscription status, StoreKit views (iOS 17+), offer codes, Xcode StoreKit Testing, App Store Server Notifications V2 (client-side implications).
Out of scope: original StoreKit (SKProduct/SKPaymentQueue — legacy, do not generate), server-side JWS validation implementation details, third-party billing SDKs, App Store Connect product setup (→ asc-* skills).
Core Rules (non-negotiable)
- Listen for transactions from app launch, before any UI. Start a
Transaction.updates observer task in the app's init and keep it alive for the app's lifetime. Missing this loses Ask-to-Buy approvals, offer-code redemptions, purchases made on another device, and renewals.
- Never trust an unverified transaction. Every
VerificationResult must go through a check — payloadValue (throws on failure) or explicit case .verified/.unverified. Never try! result.payloadValue, never treat .unverified as purchased.
- Always
finish() — after granting. Grant the entitlement (persist it), then await transaction.finish(). Finishing first and granting after crashes = paid, not delivered. Unfinished transactions redeliver on every launch — that's the safety net, don't defeat it.
- Entitlement source of truth =
Transaction.currentEntitlements (plus your server if you have one). Never persist "isPro = true" as the only record; recompute from entitlements at launch and on every Transaction.updates event.
- "Restore Purchases" button is still mandatory (App Review). It's
try await AppStore.sync() — but explain in code that normal entitlement refresh doesn't need it; it's a user-facing escape hatch that prompts for App Store credentials.
- Handle every
Product.PurchaseResult case: .success(let verification), .userCancelled (silent — no error alert), .pending (Ask to Buy / SCA — inform the user, deliver later via Transaction.updates).
- Subscriptions: read status, don't infer it. Use
Product.SubscriptionInfo.status / Transaction.currentEntitlements — expiration, grace period, billing retry, and revocation (revocationDate) are all states your code must respect. Grace period = keep access.
- Services are actors. Purchase/entitlement logic lives in an
actor PurchaseService (or @MainActor @Observable store) — no free-floating global mutable state (Swift 6 strict concurrency).
- Test with a
.storekit configuration file in Xcode (products, renewals accelerated, refunds, offer codes, failure injection) before touching sandbox. CI: SKTestSession in unit tests.
- Price display: use the API's formatting (
product.displayPrice), never hand-formatted decimals/currency.
Canonical Patterns
Purchase service (Swift 6)
actor PurchaseService {
private var updatesTask: Task<Void, Never>?
func start() {
updatesTask = Task.detached { [weak self] in
for await result in Transaction.updates {
await self?.handle(result)
}
}
}
func purchase(_ product: Product) async throws -> Transaction? {
switch try await product.purchase() {
case .success(let verification):
let transaction = try verification.payloadValue
await grant(transaction)
await transaction.finish()
return transaction
case .userCancelled: return nil
case .pending: return nil
@unknown default: return nil
}
}
private func handle(_ result: VerificationResult<Transaction>) async {
guard case .verified(let transaction) = result else { return }
if transaction.revocationDate != nil { await revoke(transaction) }
else { await grant(transaction) }
await transaction.finish()
}
func refreshEntitlements() async -> Set<String> {
var productIDs = Set<String>()
for await result in Transaction.currentEntitlements {
if case .verified(let t) = result, t.revocationDate == nil {
productIDs.insert(t.productID)
}
}
return productIDs
}
}
Subscription status
if let status = try await product.subscription?.status.first {
switch status.state {
case .subscribed, .inGracePeriod: grantAccess()
case .inBillingRetryPeriod: grantAccess(); showBillingIssueBanner()
case .expired, .revoked: revokeAccess()
default: revokeAccess()
}
}
Paywall UI (iOS 17+)
Prefer the system views before hand-rolling: StoreView(ids:) (product list), ProductView(id:) (single product), SubscriptionStoreView(groupID:) (full subscription paywall with plan picker, marketing content, policies). Customize via .subscriptionStoreControlStyle, .storeButton. Hand-rolled paywalls: fetch with Product.products(for:) and keep the purchase path identical to above.
Server coupling (when the app has a backend)
- Send the JWS representation (
transaction.jwsRepresentation) to the server; the server verifies signature + claims itself. Never send a bare product ID as proof.
- Server subscribes to App Store Server Notifications V2; the client still refreshes
currentEntitlements on foreground as a fallback.
- Account linking: set
appAccountToken (a UUID you generate per user) at purchase time so server-side transactions map to accounts.
Common AI Mistakes
| ❌ Generated pattern | Why it fails | ✅ Correct |
|---|
SKPaymentQueue / SKProductsRequest | Legacy StoreKit 1 | Product.products(for:) + product.purchase() |
finish() before granting | Crash window = paid but not delivered | Grant → persist → finish() |
No Transaction.updates observer | Ask-to-Buy/renewals/other-device purchases lost | Lifetime observer from launch |
Treating .userCancelled as an error alert | Punishes an intentional user action | Return silently |
try! verification.payloadValue | Crashes on jailbreak/tamper instead of denying | case .verified / thrown error path |
| "Restore" reimplemented by looping products | Wrong tool | AppStore.sync() |
| Entitlement cached once in UserDefaults | Never updates on refund/expiry | Recompute from currentEntitlements |
Receipt file validation (appStoreReceiptURL) | StoreKit 1 pattern, deprecated path | JWS per-transaction verification |
Checklist before shipping