
Storekit
- 143 installs
- 222 repo stars
- Updated January 18, 2026
- johnrogers/claude-swift-engineering
Add StoreKit 2 in-app purchases, auto-renewable subscriptions, transaction handling, and receipt validation to monetize Apple apps.
About
Provides expert guidance for implementing Apple StoreKit in Swift apps, including in-app purchases, auto-renewable subscriptions, transaction handling, entitlements, and App Store monetization workflows.
- In-app purchases
- Subscriptions
- StoreKit 2
- Receipt validation
- Transaction handling
Storekit by the numbers
- 143 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #532 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/johnrogers/claude-swift-engineering --skill storekitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 143 |
|---|---|
| repo stars | ★ 222 |
| Last updated | January 18, 2026 |
| Repository | johnrogers/claude-swift-engineering ↗ |
What it does
Add StoreKit 2 in-app purchases, auto-renewable subscriptions, transaction handling, and receipt validation to monetize Apple apps.
Files
StoreKit
StoreKit 2 patterns for implementing in-app purchases with async/await APIs, automatic verification, and SwiftUI integration.
Reference Loading Guide
ALWAYS load reference files if there is even a small chance the content may be required. It's better to have the context than to miss a pattern or make a mistake.
| Reference | Load When |
|---|---|
| [Getting Started](references/getting-started.md) | Setting up .storekit configuration file, testing-first workflow |
| [Products](references/products.md) | Loading products, product types, purchasing with Product.purchase() |
| [Subscriptions](references/subscriptions.md) | Auto-renewable subscriptions, subscription groups, offers, renewal tracking |
| [Transactions](references/transactions.md) | Transaction listener, verification, finishing transactions, restore purchases |
| [StoreKit Views](references/storekit-views.md) | ProductView, SubscriptionStoreView, SubscriptionOfferView in SwiftUI |
Core Workflow
1. Create .storekit configuration file first (before any code) 2. Test purchases locally in Xcode simulator 3. Implement centralized StoreManager with @MainActor 4. Set up Transaction.updates listener at app launch 5. Display products with ProductView or custom UI 6. Always call transaction.finish() after granting entitlements
Essential Architecture
@MainActor
final class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private var transactionListener: Task<Void, Never>?
init() {
transactionListener = listenForTransactions()
Task { await loadProducts() }
}
}Common Mistakes
1. Missing `.finish()` calls on transactions — Forgetting to call transaction.finish() after granting entitlements causes transactions to never complete. The user won't see their purchase reflected. Always call finish().
2. Unsafe StoreManager state — Shared StoreManager without @MainActor can have race conditions. Multiple async tasks can update @Published properties concurrently, corrupting state. Use @MainActor for thread safety.
3. No transaction listener at app launch — Not setting up Transaction.updates listener means app crashes or misses refunded/canceled purchases. Listen for transactions immediately in @main, not when user taps purchase button.
4. Hardcoded product IDs — Hardcoded IDs make testing and localization hard. Use configuration files or environment variables for product IDs. Same applies to prices (fetch from App Store, don't hardcode).
5. Ignoring verification failures — App Store verification fails silently sometimes. Not checking verification status means accepting unverified transactions (security risk). Always verify before granting entitlements.
Getting Started
Testing-first workflow for StoreKit 2 implementation.
Why .storekit-First
Create StoreKit configuration BEFORE writing any purchase code:
- Immediate validation: Product ID typos caught in Xcode, not at runtime
- Faster iteration: Test purchases in simulator without network requests
- Team benefits: Anyone can test purchase flows locally
- Documentation: Product catalog visible in project
Create Configuration File
1. Xcode > File > New > File > StoreKit Configuration File 2. Save as Products.storekit 3. Add to target for testing
Add Products
Click "+" and configure each product:
Consumable:
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99Non-Consumable:
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99Auto-Renewable Subscription:
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tierEnable in Scheme
1. Scheme > Edit Scheme > Run > Options 2. StoreKit Configuration: Select Products.storekit 3. Run app in simulator to test
Product Types
| Type | Description | Restores? |
|---|---|---|
| Consumable | Coins, hints, boosts | No |
| Non-Consumable | Premium features, level packs | Yes |
| Auto-Renewable | Monthly/annual subscriptions | Yes |
| Non-Renewing | Seasonal passes | Yes |
Testing Scenarios
Test these in StoreKit configuration before production code:
- [ ] Successful purchase for each product type
- [ ] Cancelled purchase (state remains consistent)
- [ ] Subscription renewal (accelerated time)
- [ ] Subscription expiration
- [ ] Upgrade/downgrade between tiers
- [ ] Restore purchases flow
- [ ] Family Sharing (enable in config)
Already Wrote Code First?
If you wrote purchase code before creating .storekit config:
Option A: Start Over (Recommended) Delete IAP code and follow testing-first workflow. Reinforces correct habits.
Option B: Create Config Now (Acceptable) Create .storekit with existing product IDs, test locally, document in PR.
Option C: Skip Config (Not Recommended) Misses local testing benefits, harder for teammates.
Sandbox Testing
After local testing passes:
1. App Store Connect > Users and Access > Sandbox Testers 2. Create test Apple ID 3. Sign in on device: Settings > App Store > Sandbox Account 4. Test purchases on physical device
Clear purchase history: Settings > App Store > Sandbox Account > Clear Purchase History
Checklist Before Production
Testing Foundation
- [ ] Created
.storekitconfiguration with all products - [ ] Verified each product renders in StoreKit preview
- [ ] Tested successful purchase for each product
- [ ] Tested purchase failure scenarios
- [ ] Tested restore purchases flow
- [ ] For subscriptions: tested renewal, expiration, upgrade/downgrade
Architecture
- [ ] Centralized StoreManager class exists
- [ ] StoreManager is
@MainActorandObservableObject - [ ] Transaction listener via
Transaction.updates - [ ] All transactions call
.finish()after entitlement granted
Products
Loading, displaying, and purchasing products with StoreKit 2.
Loading Products
import StoreKit
let productIDs = ["com.app.coins_100", "com.app.premium", "com.app.pro_monthly"]
let products = try await Product.products(for: productIDs)Handle Missing Products
let loadedIDs = Set(products.map { $0.id })
let missingIDs = Set(productIDs).subtracting(loadedIDs)
if !missingIDs.isEmpty {
print("Missing products: \(missingIDs)")
}Product Properties
product.id // "com.app.premium"
product.displayName // "Premium Upgrade"
product.description // "Unlock all features"
product.displayPrice // "$4.99"
product.price // Decimal(4.99)
product.type // .nonConsumableProduct Types
| Type | Description | Restores? |
|---|---|---|
.consumable | Coins, hints, boosts | No |
.nonConsumable | Premium features | Yes |
.autoRenewable | Subscriptions | Yes |
.nonRenewing | Seasonal passes | Yes |
Purchasing
Purchase with UI Context (iOS 18.2+)
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
guard let transaction = try? verificationResult.payloadValue else { return }
await grantEntitlement(for: transaction)
await transaction.finish() // CRITICAL
case .userCancelled:
print("User cancelled")
case .pending:
// Ask to Buy - arrives via Transaction.updates
print("Pending approval")
@unknown default: break
}SwiftUI Purchase
struct ProductRow: View {
let product: Product
@Environment(\.purchase) private var purchase
var body: some View {
Button("Buy \(product.displayPrice)") {
Task {
let result = try await purchase(product)
}
}
}
}Purchase Options
// With account token (for server association)
let result = try await product.purchase(
confirmIn: scene,
options: [.appAccountToken(UUID())]
)
// With promotional offer
let result = try await product.purchase(
confirmIn: scene,
options: [.promotionalOffer(offerID: "promo", signature: jwsSignature)]
)StoreManager Integration
@MainActor
final class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
func loadProducts() async {
products = try? await Product.products(for: productIDs) ?? []
}
func purchase(_ product: Product, in scene: UIWindowScene) async throws -> Bool {
let result = try await product.purchase(confirmIn: scene)
guard case .success(let verification) = result,
let transaction = try? verification.payloadValue else { return false }
await grantEntitlement(for: transaction)
await transaction.finish()
return true
}
}Anti-Patterns
| Pattern | Problem | Solution |
|---|---|---|
Scattered purchase() calls | Inconsistent handling | Centralize in StoreManager |
| No verification | Security risk | Check VerificationResult |
Missing finish() | Transaction redelivery | Always call finish() |
StoreKit Views
SwiftUI components for displaying products and subscriptions.
ProductView (iOS 17+)
import StoreKit
// By product ID
ProductView(id: "com.app.premium")
// With loaded product
ProductView(for: product)
// Custom icon
ProductView(id: productID) {
Image(systemName: "star.fill")
}
// Styles
ProductView(id: productID).productViewStyle(.regular) // Default
ProductView(id: productID).productViewStyle(.compact) // Smaller
ProductView(id: productID).productViewStyle(.large) // ProminentStoreView (iOS 17+)
Display multiple products:
StoreView(ids: ["com.app.coins_100", "com.app.coins_500"])
// With loaded products
StoreView(products: products)SubscriptionStoreView (iOS 17+)
SubscriptionStoreView(groupID: "pro_tier") {
VStack {
Image("app-icon")
Text("Go Pro").font(.largeTitle.bold())
}
}
// Control styles
.subscriptionStoreControlStyle(.automatic) // Default
.subscriptionStoreControlStyle(.picker) // Horizontal
.subscriptionStoreControlStyle(.buttons) // Stacked
.subscriptionStoreControlStyle(.prominentPicker) // Large (iOS 18.4+)SubscriptionOfferView (iOS 18.4+)
SubscriptionOfferView(id: "com.app.pro_monthly")
// With promotional icon
SubscriptionOfferView(id: productID, prefersPromotionalIcon: true)
// Custom icon
SubscriptionOfferView(id: productID) {
Image("custom-icon").resizable().frame(width: 60, height: 60)
}
// Detail action
SubscriptionOfferView(id: productID)
.subscriptionOfferViewDetailAction { showStore = true }Visible Relationship
SubscriptionOfferView(groupID: "pro_tier", visibleRelationship: .upgrade)
SubscriptionOfferView(groupID: "pro_tier", visibleRelationship: .downgrade)
SubscriptionOfferView(groupID: "pro_tier", visibleRelationship: .crossgrade)
SubscriptionOfferView(groupID: "pro_tier", visibleRelationship: .current)
SubscriptionOfferView(groupID: "pro_tier", visibleRelationship: .all)Promotional Offers
SubscriptionStoreView(groupID: groupID)
.subscriptionPromotionalOffer(
for: { $0.promotionalOffers.first },
signature: { subscription, offer in
try await server.signOffer(productID: subscription.id, offerID: offer.id)
}
)Offer Code Redemption
// SwiftUI
Button("Redeem") { showRedeemSheet = true }
.offerCodeRedemption(isPresented: $showRedeemSheet)
// UIKit
AppStore.presentOfferCodeRedeemSheet(in: scene)Manage Subscriptions
try? await AppStore.showManageSubscriptions(in: scene)Custom Purchase UI
struct CustomProductCard: View {
let product: Product
@Environment(\.purchase) private var purchase
@State private var isPurchasing = false
var body: some View {
VStack {
Text(product.displayName)
Button {
Task {
isPurchasing = true
defer { isPurchasing = false }
_ = try? await purchase(product)
}
} label: {
isPurchasing ? AnyView(ProgressView()) : AnyView(Text("Buy \(product.displayPrice)"))
}
}
}
}Best Practices
1. Use StoreKit Views when possible (pre-built, accessible) 2. Provide marketing content in content closures 3. Handle loading states with placeholders 4. Always provide restore functionality
Subscriptions
Auto-renewable subscription management with StoreKit 2.
Subscription Properties
if let info = product.subscription {
let groupID = info.subscriptionGroupID
let period = info.subscriptionPeriod // .day, .week, .month, .year
}Subscription Status
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
for status in statuses {
switch status.state {
case .subscribed: // Active - full access
case .expired: // Show resubscribe/win-back
case .inGracePeriod: // Billing issue, access maintained
case .inBillingRetryPeriod: // Apple retrying payment
case .revoked: // Family Sharing removed
@unknown default: break
}
}Listen for Status Updates
for await statuses in Product.SubscriptionInfo.Status.updates(for: groupID) {
for status in statuses { updateUI(for: status.state) }
}Renewal Info
switch status.renewalInfo {
case .verified(let renewalInfo):
renewalInfo.willAutoRenew // Will subscription renew?
renewalInfo.autoRenewPreference // Product ID for next renewal
renewalInfo.expirationReason // Why expired?
case .unverified: break
}Expiration Reasons
| Reason | Action |
|---|---|
.autoRenewDisabled | User turned off renewal |
.billingError | Payment issue |
.didNotConsentToPriceIncrease | Show win-back offer |
.productUnavailable | Product discontinued |
Grace Period
if let expiration = renewalInfo.gracePeriodExpirationDate {
// Show update payment method UI
}Offers
Introductory Offer
if let intro = product.subscription?.introductoryOffer {
intro.period // Duration
intro.displayPrice // Price
intro.paymentMode // .freeTrial, .payAsYouGo, .payUpFront
}Promotional Offers
for offer in product.subscription?.promotionalOffers ?? [] {
offer.id, offer.displayPrice, offer.period
}
// Apply with server-signed JWS
let result = try await product.purchase(
confirmIn: scene,
options: [.promotionalOffer(offerID: offer.id, signature: jwsSignature)]
)Subscription Groups
Users have one active subscription per group. Use for tier levels (Basic/Pro/Premium) or billing periods (Monthly/Annual).
let activeStatus = statuses.filter { $0.state == .subscribed }.firstFamily Sharing
Family Sharing transactions have appAccountToken == nil. Each family member has unique appTransactionID.
Enable: App Store Connect > Subscriptions > Enable Family Sharing
Tracking Status
extension StoreManager {
var isSubscribed: Bool {
get async {
let state = try? await Product.SubscriptionInfo.status(for: "pro_tier").first?.state
return state == .subscribed || state == .inGracePeriod || state == .inBillingRetryPeriod
}
}
}Win-Back Offers
if renewalInfo.expirationReason == .didNotConsentToPriceIncrease {
showWinBackOffer()
}Transactions
Transaction handling, verification, and restore purchases.
Transaction Listener (REQUIRED)
Set up at app launch to catch all transaction sources:
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}Transaction sources: In-app purchases, App Store purchases, offer codes, renewals, Family Sharing, Ask to Buy completions, refunds.
Transaction Verification
Always verify before granting entitlements:
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
print("Unverified: \(error)")
await transaction.finish() // Still finish to clear queue
}
}Transaction Properties
// Basic
transaction.id, transaction.originalID, transaction.productID
transaction.productType, transaction.purchaseDate, transaction.appAccountToken
// Subscription
transaction.expirationDate, transaction.isUpgraded
transaction.revocationDate, transaction.revocationReason
// Offer (iOS 18.4+)
transaction.offer?.type, transaction.offer?.id, transaction.offer?.paymentModeGrant Entitlements
func grantEntitlement(for transaction: Transaction) async {
guard transaction.revocationDate == nil else {
await revokeEntitlement(for: transaction.productID)
return
}
switch transaction.productType {
case .consumable: await addConsumable(productID: transaction.productID)
case .nonConsumable: await unlockFeature(productID: transaction.productID)
case .autoRenewable: await activateSubscription(productID: transaction.productID)
default: break
}
}Finishing Transactions (CRITICAL)
await transaction.finish()When to finish: After granting entitlement, after storing receipt, even for unverified/refunded transactions.
If you don't finish: Transaction redelivered on next app launch, queue builds up.
Current Entitlements
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue,
transaction.revocationDate == nil else { continue }
purchased.insert(transaction.productID)
}
// Check specific product (iOS 18.4+)
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil { return true }
}Note: currentEntitlement(for:) (singular) deprecated in iOS 18.4. Use currentEntitlements(for:).
Restore Purchases (REQUIRED)
func restorePurchases() async {
try? await AppStore.sync()
await updatePurchasedProducts()
}App Store requires restore functionality for non-consumables and subscriptions.
Handle Refunds
if let revocationDate = transaction.revocationDate {
switch transaction.revocationReason {
case .developerIssue: // App issue
case .other: // Other reason
@unknown default: break
}
await revokeEntitlement(for: transaction.productID)
}Anti-Patterns
| Pattern | Problem | Solution |
|---|---|---|
Only handle in purchase() | Misses pending, family sharing, restore | Use Transaction.updates |
| No restore button | App Store rejection | Provide restore in settings |
| Not finishing | Queue builds up | Always call finish() |