--- name: axiom-storekit-ref description: Reference — Complete StoreKit 2 API guide covering Product, Transaction, AppTransaction, RenewalInfo, SubscriptionStatus, StoreKit Views, purchase options, server APIs, and all iOS 18.4 enhancements with WWDC 2025 code examples license: MIT compatibility: iOS 15+ (iOS 18.4+ for latest features) metadata: version: "1.0.0" last-updated: "2025-12-08" --- # StoreKit 2 — Complete API Reference ## Overview StoreKit 2 is Apple's modern in-app purchase framework with async/await APIs, automatic receipt validation, and SwiftUI integration. This reference covers every API, iOS 18.4 enhancements, and comprehensive WWDC 2025 code examples. ### Product Types Supported **Consumable**: - Products that can be purchased multiple times - Examples: coins, hints, temporary boosts - Do NOT restore on new devices **Non-Consumable**: - Products purchased once, owned forever - Examples: premium features, level packs, remove ads - MUST restore on new devices **Auto-Renewable Subscription**: - Subscriptions that renew automatically - Organized into subscription groups - MUST restore on new devices - Support: free trials, intro offers, promotional offers, win-back offers **Non-Renewing Subscription**: - Fixed duration subscriptions (no auto-renewal) - Examples: seasonal passes - MUST restore on new devices ### Key Improvements Over StoreKit 1 - **Async/Await**: Modern concurrency instead of delegates/closures - **Automatic Verification**: JSON Web Signature (JWS) verification built-in - **Transaction Types**: Strong Swift types instead of SKPaymentTransaction - **Testing**: StoreKit configuration files for local testing - **SwiftUI Views**: Pre-built purchase UIs (ProductView, SubscriptionStoreView) - **Server APIs**: App Store Server API and Server Notifications --- ## When to Use This Reference Use this reference when: - Implementing in-app purchases with StoreKit 2 - Understanding new iOS 18.4 fields (appTransactionID, offerPeriod, etc.) - Looking up specific API signatures and parameters - Planning subscription architecture - Debugging transaction issues - Implementing StoreKit Views - Integrating with App Store Server APIs **Related Skills**: - `axiom-in-app-purchases` — Discipline skill with testing-first workflow, architecture patterns - (Future: `iap-auditor` agent for auditing existing IAP code) - (Future: `iap-implementation` agent for implementing IAP from scratch) --- ## Product ### Overview `Product` represents an in-app purchase item configured in App Store Connect or StoreKit configuration file. ### Loading Products **Basic Loading**: ```swift import StoreKit let productIDs = [ "com.app.coins_100", "com.app.premium", "com.app.pro_monthly" ] let products = try await Product.products(for: productIDs) ``` #### From WWDC 2021-10114 **Handling Missing Products**: ```swift let products = try await Product.products(for: productIDs) // Check what loaded let loadedIDs = Set(products.map { $0.id }) let missingIDs = Set(productIDs).subtracting(loadedIDs) if !missingIDs.isEmpty { print("Missing products: \(missingIDs)") // Products not configured in App Store Connect or .storekit file } ``` ### Product Properties **Basic Properties**: ```swift let product: Product 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 // .nonConsumable ``` **Product Type Enum**: ```swift switch product.type { case .consumable: // Coins, hints, boosts case .nonConsumable: // Premium features, level packs case .autoRenewable: // Monthly/annual subscriptions case .nonRenewing: // Seasonal passes @unknown default: break } ``` ### Subscription-Specific Properties **Check if Product is Subscription**: ```swift if let subscriptionInfo = product.subscription { // Product is auto-renewable subscription let groupID = subscriptionInfo.subscriptionGroupID let period = subscriptionInfo.subscriptionPeriod } ``` **Subscription Period**: ```swift let period = product.subscription?.subscriptionPeriod switch period?.unit { case .day: print("\(period?.value ?? 0) days") case .week: print("\(period?.value ?? 0) weeks") case .month: print("\(period?.value ?? 0) months") case .year: print("\(period?.value ?? 0) years") default: break } ``` **Introductory Offer**: ```swift if let introOffer = product.subscription?.introductoryOffer { print("Free trial: \(introOffer.period.value) \(introOffer.period.unit)") print("Price: \(introOffer.displayPrice)") switch introOffer.paymentMode { case .freeTrial: print("Free trial - no charge") case .payAsYouGo: print("Discounted price per period") case .payUpFront: print("One-time discounted price") @unknown default: break } } ``` **Promotional Offers**: ```swift let offers = product.subscription?.promotionalOffers ?? [] for offer in offers { print("Offer ID: \(offer.id)") print("Price: \(offer.displayPrice)") print("Period: \(offer.period.value) \(offer.period.unit)") } ``` ### Purchase Methods **Purchase with UI Context (iOS 18.2+)**: ```swift let product: Product let scene: UIWindowScene let result = try await product.purchase(confirmIn: scene) ``` #### From WWDC 2025-241:9:32 **Purchase with Options**: ```swift let accountToken = UUID() let result = try await product.purchase( confirmIn: scene, options: [ .appAccountToken(accountToken) ] ) ``` #### From WWDC 2025-241:11:01 **Purchase with Promotional Offer (JWS Format)**: ```swift let jwsSignature: String // From your server let result = try await product.purchase( confirmIn: scene, options: [ .promotionalOffer(offerID: "promo_winback", signature: jwsSignature) ] ) ``` #### From WWDC 2025-241:10:55 **Purchase with Custom Intro Eligibility**: ```swift let jwsSignature: String // From your server let result = try await product.purchase( confirmIn: scene, options: [ .introductoryOfferEligibility(signature: jwsSignature) ] ) ``` #### From WWDC 2025-241:10:42 **SwiftUI Purchase (Using Environment)**: ```swift struct ProductView: View { let product: Product @Environment(\.purchase) private var purchase var body: some View { Button("Buy \(product.displayPrice)") { Task { do { let result = try await purchase(product) // Handle result } catch { print("Purchase failed: \(error)") } } } } } ``` #### From WWDC 2025-241:9:50 ### PurchaseResult **Handling Purchase Results**: ```swift let result = try await product.purchase(confirmIn: scene) switch result { case .success(let verificationResult): // Purchase succeeded - verify transaction guard let transaction = try? verificationResult.payloadValue else { print("Transaction verification failed") return } // Grant entitlement await grantEntitlement(for: transaction) await transaction.finish() case .userCancelled: // User tapped "Cancel" in payment sheet print("User cancelled purchase") case .pending: // Purchase requires action (Ask to Buy, payment issue) // Transaction will arrive via Transaction.updates when approved print("Purchase pending approval") @unknown default: break } ``` #### From WWDC 2025-241 --- ## Transaction ### Overview `Transaction` represents a successful in-app purchase. Contains purchase metadata, product ID, purchase date, and for subscriptions, expiration date. ### New Fields (iOS 18.4) **appTransactionID**: ```swift let transaction: Transaction let appTransactionID = transaction.appTransactionID // Unique ID for app download (same across all purchases by same Apple Account) ``` #### From WWDC 2025-241:4:13 **offerPeriod**: ```swift if let offerPeriod = transaction.offer?.period { print("Offer duration: \(offerPeriod)") // ISO 8601 duration format (e.g., "P1M" for 1 month) } ``` #### From WWDC 2025-249:3:11 **advancedCommerceInfo**: ```swift if let advancedInfo = transaction.advancedCommerceInfo { // Only present for Advanced Commerce API purchases // nil for standard IAP } ``` #### From WWDC 2025-241:4:42 ### Essential Properties **Basic Fields**: ```swift let transaction: Transaction transaction.id // Unique transaction ID transaction.originalID // Original transaction ID (consistent across renewals) transaction.productID // "com.app.pro_monthly" transaction.productType // .autoRenewable transaction.purchaseDate // Date of purchase transaction.appAccountToken // UUID set at purchase time (if provided) ``` **Subscription Fields**: ```swift transaction.expirationDate // When subscription expires transaction.isUpgraded // true if user upgraded to higher tier transaction.revocationDate // Date of refund (nil if not refunded) transaction.revocationReason // .developerIssue or .other ``` **Offer Fields**: ```swift if let offer = transaction.offer { offer.type // .introductory or .promotional or .code offer.id // Offer identifier from App Store Connect offer.paymentMode // .freeTrial, .payAsYouGo, .payUpFront, .oneTime } ``` #### From WWDC 2025-241:8:00 ### Current Entitlements **Get All Current Entitlements**: ```swift var purchasedProductIDs: Set = [] for await result in Transaction.currentEntitlements { guard let transaction = try? result.payloadValue else { continue } // Only include non-refunded transactions if transaction.revocationDate == nil { purchasedProductIDs.insert(transaction.productID) } } ``` #### From WWDC 2025-241 **Get Entitlements for Specific Product (iOS 18.4+)**: ```swift let productID = "com.app.premium" for await result in Transaction.currentEntitlements(for: productID) { if let transaction = try? result.payloadValue, transaction.revocationDate == nil { // User owns this product return true } } ``` #### From WWDC 2025-241:3:31 **Deprecated API (iOS 18.4)**: ```swift // ❌ Deprecated in iOS 18.4 let entitlement = await Transaction.currentEntitlement(for: productID) // ✅ Use this instead (returns sequence, handles Family Sharing) for await result in Transaction.currentEntitlements(for: productID) { // ... } ``` #### From WWDC 2025-241:3:31 ### Transaction History **Get All Transactions**: ```swift for await result in Transaction.all { guard let transaction = try? result.payloadValue else { continue } print("Transaction: \(transaction.productID) on \(transaction.purchaseDate)") } ``` **Get Transactions for Product**: ```swift for await result in Transaction.all(matching: productID) { guard let transaction = try? result.payloadValue else { continue } // All transactions for this product } ``` ### Transaction Listener **Listen for Real-Time Updates (REQUIRED)**: ```swift func listenForTransactions() -> Task { Task.detached { for await verificationResult in Transaction.updates { await handleTransaction(verificationResult) } } } func handleTransaction(_ result: VerificationResult) async { guard let transaction = try? result.payloadValue else { return } // Grant or revoke entitlement if transaction.revocationDate != nil { await revokeEntitlement(for: transaction.productID) } else { await grantEntitlement(for: transaction) } // CRITICAL: Always finish transaction await transaction.finish() } ``` #### From WWDC 2021-10114 **Transaction Sources**: - In-app purchases - Purchases from App Store (promoted IAP) - Offer code redemptions - Subscription renewals - Family Sharing transactions - Pending purchases (Ask to Buy) that complete - Refund notifications ### Verification **VerificationResult**: ```swift let result: VerificationResult switch result { case .verified(let transaction): // ✅ Transaction signed by App Store await grantEntitlement(for: transaction) await transaction.finish() case .unverified(let transaction, let error): // ❌ Transaction signature invalid print("Unverified: \(error)") // DO NOT grant entitlement await transaction.finish() // Still finish to clear queue } ``` **What Verification Checks**: - Transaction signed by App Store (not fraudulent) - Transaction belongs to this app (bundle ID match) - Transaction belongs to this device ### Finishing Transactions **Always Call finish()**: ```swift await transaction.finish() ``` **When to finish**: - ✅ After granting entitlement to user - ✅ After storing transaction receipt/ID - ✅ Even for unverified transactions (to clear queue) - ✅ Even for refunded transactions **What happens if you don't finish**: - Transaction redelivered on next app launch - `Transaction.updates` re-emits transaction - Queue builds up over time --- ## AppTransaction ### Overview `AppTransaction` represents the original app download. Available via `AppTransaction.shared`. ### New Fields (iOS 18.4) **appTransactionID**: ```swift let appTransaction = try await AppTransaction.shared switch appTransaction { case .verified(let transaction): let appTransactionID = transaction.appTransactionID // Globally unique ID for this Apple Account + app // Same value appears in Transaction and RenewalInfo case .unverified(_, let error): print("AppTransaction verification failed: \(error)") } ``` #### From WWDC 2025-241:1:42 **originalPlatform**: ```swift if let appTransaction = try? await AppTransaction.shared.payloadValue { let platform = appTransaction.originalPlatform switch platform { case .iOS: print("Originally downloaded on iPhone/iPad") case .macOS: print("Originally downloaded on Mac") case .tvOS: print("Originally downloaded on Apple TV") case .visionOS: print("Originally downloaded on Vision Pro") @unknown default: break } } ``` #### From WWDC 2025-241:2:11 **Note**: Apps downloaded on watchOS show `originalPlatform = .iOS` ### Essential Properties ```swift let appTransaction: AppTransaction appTransaction.appVersion // "1.2.3" appTransaction.originalAppVersion // "1.0.0" appTransaction.originalPurchaseDate // First download date appTransaction.bundleID // "com.company.app" appTransaction.deviceVerification // UUID for device appTransaction.deviceVerificationNonce // Nonce for verification ``` ### Use Cases **Check App Version**: ```swift if let appTransaction = try? await AppTransaction.shared.payloadValue { if appTransaction.appVersion != currentVersion { // Prompt user to update } } ``` #### From WWDC 2025-241:0:51 **Business Model Migration**: ```swift // Moving from paid app to free app with IAP if appTransaction.originalPlatform == .iOS, appTransaction.originalPurchaseDate < migrationDate { // User paid for app before migration - grant premium await grantPremiumAccess() } ``` #### From WWDC 2025-241:2:32 --- ## Product.SubscriptionInfo.RenewalInfo ### Overview `RenewalInfo` provides information about auto-renewable subscription renewal state, including whether it will renew, expiration reason, and upcoming offers. ### New Fields (iOS 18.4) **appTransactionID**: ```swift let renewalInfo: RenewalInfo let appTransactionID = renewalInfo.appTransactionID ``` #### From WWDC 2025-241:6:40 **offerPeriod**: ```swift if let offerPeriod = renewalInfo.offerPeriod { print("Next renewal offer period: \(offerPeriod)") // ISO 8601 duration (applies at next renewal) } ``` #### From WWDC 2025-249:3:11 **appAccountToken**: ```swift if let token = renewalInfo.appAccountToken { // UUID associating subscription with your server account } ``` #### From WWDC 2025-241:6:56 **advancedCommerceInfo**: ```swift if let advancedInfo = renewalInfo.advancedCommerceInfo { // Only for Advanced Commerce API subscriptions } ``` #### From WWDC 2025-241:6:50 ### Essential Properties **Renewal State**: ```swift let renewalInfo: RenewalInfo renewalInfo.willAutoRenew // true if subscription will renew renewalInfo.autoRenewPreference // Product ID customer will renew to renewalInfo.expirationReason // Why subscription expired (if expired) ``` **Expiration Reasons**: ```swift switch renewalInfo.expirationReason { case .autoRenewDisabled: // User turned off auto-renewal case .billingError: // Payment method issue case .didNotConsentToPriceIncrease: // User didn't accept price increase - show win-back offer! case .productUnavailable: // Product no longer available case .unknown: // Unknown reason @unknown default: break } ``` #### From WWDC 2025-241:5:38 **Grace Period**: ```swift if let gracePeriodExpiration = renewalInfo.gracePeriodExpirationDate { // Subscription in grace period - billing issue // Show update payment method UI } ``` **Price Increase Consent**: ```swift if let consentStatus = renewalInfo.priceIncreaseStatus { switch consentStatus { case .agreed: // User accepted price increase case .notYetResponded: // User hasn't responded - show consent UI @unknown default: break } } ``` ### Accessing RenewalInfo **From SubscriptionStatus**: ```swift let statuses = try await Product.SubscriptionInfo.status(for: groupID) for status in statuses { switch status.renewalInfo { case .verified(let renewalInfo): print("Will renew: \(renewalInfo.willAutoRenew)") case .unverified(_, let error): print("Renewal info verification failed: \(error)") } } ``` --- ## Product.SubscriptionInfo.Status ### Overview `SubscriptionStatus` represents the current state of an auto-renewable subscription, including whether it's active, expired, in grace period, or in billing retry. ### Subscription States **State Enum**: ```swift let status: Product.SubscriptionInfo.Status switch status.state { case .subscribed: // User has active subscription - full access case .expired: // Subscription expired - show resubscribe/win-back offer case .inGracePeriod: // Billing issue but access maintained - show update payment UI case .inBillingRetryPeriod: // Apple retrying payment - maintain access case .revoked: // Family Sharing access removed - revoke access @unknown default: break } ``` #### From WWDC 2025-241 ### Getting Subscription Status **For Subscription Group**: ```swift let groupID = "pro_tier" let statuses = try await Product.SubscriptionInfo.status(for: groupID) // Find highest service level let activeStatus = statuses .filter { $0.state == .subscribed } .max { $0.transaction.productID < $1.transaction.productID } ``` #### From WWDC 2025-241:6:22 **For Specific Transaction (iOS 18.4+)**: ```swift let transactionID = transaction.id let status = try await Product.SubscriptionInfo.status(for: transactionID) ``` #### From WWDC 2025-241:6:40 **Listen for Status Updates**: ```swift for await statuses in Product.SubscriptionInfo.Status.updates(for: groupID) { // Process updated statuses for status in statuses { print("Status: \(status.state)") } } ``` ### Status Properties ```swift let status: Product.SubscriptionInfo.Status status.state // .subscribed, .expired, etc. status.transaction // VerificationResult status.renewalInfo // VerificationResult ``` --- ## StoreKit Views ### ProductView (iOS 17+) **Basic Usage**: ```swift import StoreKit struct ContentView: View { let productID = "com.app.premium" var body: some View { ProductView(id: productID) } } ``` #### From WWDC 2023-10013 **With Loaded Product**: ```swift struct ContentView: View { let product: Product var body: some View { ProductView(for: product) } } ``` **Custom Icon**: ```swift ProductView(id: productID) { Image(systemName: "star.fill") .foregroundStyle(.yellow) } ``` **Control Styles**: ```swift ProductView(id: productID) .productViewStyle(.regular) // Default ProductView(id: productID) .productViewStyle(.compact) // Smaller ProductView(id: productID) .productViewStyle(.large) // Prominent ``` ### StoreView (iOS 17+) **Basic Store**: ```swift struct ContentView: View { let productIDs = [ "com.app.coins_100", "com.app.coins_500", "com.app.coins_1000" ] var body: some View { StoreView(ids: productIDs) } } ``` #### From WWDC 2023-10013 **With Loaded Products**: ```swift struct ContentView: View { let products: [Product] var body: some View { StoreView(products: products) } } ``` ### SubscriptionStoreView (iOS 17+) **Basic Subscription Store**: ```swift struct SubscriptionView: View { let groupID = "pro_tier" var body: some View { SubscriptionStoreView(groupID: groupID) { // Marketing content above subscription options VStack { Image("app-icon") Text("Go Pro") .font(.largeTitle.bold()) Text("Unlock all features") } } } } ``` #### From WWDC 2023-10013 **Control Style**: ```swift SubscriptionStoreView(groupID: groupID) { // Marketing content } .subscriptionStoreControlStyle(.automatic) // Default .subscriptionStoreControlStyle(.picker) // Horizontal picker .subscriptionStoreControlStyle(.buttons) // Stacked buttons .subscriptionStoreControlStyle(.prominentPicker) // Large picker (iOS 18.4+) ``` #### From WWDC 2025-241 ### SubscriptionOfferView (iOS 18.4+) **Basic Offer View**: ```swift struct ContentView: View { let productID = "com.app.pro_monthly" var body: some View { SubscriptionOfferView(id: productID) } } ``` #### From WWDC 2025-241:14:27 **With Loaded Product**: ```swift let product: Product // Already loaded via Product.products(for:) SubscriptionOfferView(product: product) ``` **With Promotional Icon**: ```swift SubscriptionOfferView( id: productID, prefersPromotionalIcon: true ) // Also available as modifier SubscriptionOfferView(id: productID) .prefersPromotionalIcon(true) ``` **With Custom Icon**: ```swift SubscriptionOfferView(id: productID) { Image("custom-icon") .resizable() .frame(width: 60, height: 60) } placeholder: { Image(systemName: "photo") .foregroundStyle(.gray) } ``` #### From WWDC 2025-241:15:14 **With Detail Action**: ```swift @State private var showStore = false var body: some View { SubscriptionOfferView(id: productID) .subscriptionOfferViewDetailAction { showStore = true } .sheet(isPresented: $showStore) { SubscriptionStoreView(groupID: "pro_tier") } } ``` #### From WWDC 2025-241:15:38 **Visible Relationship**: ```swift // Only show if customer can upgrade SubscriptionOfferView( groupID: "pro_tier", visibleRelationship: .upgrade ) // Only show if customer can downgrade SubscriptionOfferView( groupID: "pro_tier", visibleRelationship: .downgrade ) // Show crossgrade options (same tier, different billing period) SubscriptionOfferView( groupID: "pro_tier", visibleRelationship: .crossgrade ) // Show current subscription (only if offer available) SubscriptionOfferView( groupID: "pro_tier", visibleRelationship: .current ) // Show any plan in group SubscriptionOfferView( groupID: "pro_tier", visibleRelationship: .all ) ``` #### From WWDC 2025-241:17:44 **With App Icon**: ```swift SubscriptionOfferView( groupID: groupID, visibleRelationship: .all, useAppIcon: true ) ``` #### From WWDC 2025-241:19:06 ### Offer Modifiers **Promotional Offer (JWS)**: ```swift SubscriptionStoreView(groupID: groupID) .subscriptionPromotionalOffer( for: { subscription in // Return offer for this subscription return subscription.promotionalOffers.first }, signature: { subscription, offer in // Get JWS signature from server let signature = try await server.signOffer( productID: subscription.id, offerID: offer.id ) return signature } ) ``` #### From WWDC 2025-241:12:17 ### subscriptionStatusTask Modifier (iOS 18.4+) Track subscription status at the app level with a SwiftUI modifier. Eliminates manual polling by reacting to status changes automatically. **Basic Usage**: ```swift @main struct MyApp: App { @State private var customerStatus: CustomerStatus = .unknown var body: some Scene { WindowGroup { ContentView() .environment(\.customerSubscriptionStatus, customerStatus) .subscriptionStatusTask(for: "your.group.id") { statuses in if statuses.contains(where: { $0.state == .subscribed }) { customerStatus = .subscribed } else if statuses.contains(where: { $0.state == .expired }) { customerStatus = .expired } else { customerStatus = .notSubscribed } } } } } ``` **Key behavior**: - Fires on app launch with current statuses - Fires again when subscription status changes (renewal, expiration, upgrade) - Translate StoreKit statuses to your app's model — keep your domain model simple - Attach at the top of your view hierarchy (App or root WindowGroup) --- ## Offer Codes (iOS 18.2+) ### Overview Offer codes now support all product types (previously subscription-only): - Consumables - Non-consumables - Non-renewing subscriptions - Auto-renewable subscriptions ### Redeem in App **UIKit**: ```swift func showOfferCodeSheet() { guard let scene = view.window?.windowScene else { return } StoreKit.AppStore.presentOfferCodeRedeemSheet(in: scene) } ``` #### From WWDC 2025-241:7:38 **SwiftUI**: ```swift .offerCodeRedemption(isPresented: $showRedeemSheet) ``` ### Payment Mode **New: .oneTime**: ```swift let transaction: Transaction if let offer = transaction.offer { switch offer.paymentMode { case .freeTrial: // No charge during offer period case .payAsYouGo: // Discounted price per billing period case .payUpFront: // One-time discounted price for entire duration case .oneTime: // ✨ New: One-time offer code redemption (iOS 17.2+) @unknown default: break } } ``` #### From WWDC 2025-241:8:17 **Legacy Access (iOS 15-17.1)**: ```swift if let offerMode = transaction.offerPaymentModeStringRepresentation { // String representation for older OS versions print(offerMode) // "oneTime" } ``` #### From WWDC 2025-241:8:49 --- ## App Store Server Library ### Overview Open-source library for signing IAP requests and decoding server API responses. Available in Swift, Java, Python, Node.js. ### Create Promotional Offer Signature **Swift Example**: ```swift import AppStoreServerLibrary // Configure signing let signingKey = "YOUR_PRIVATE_KEY" let keyID = "YOUR_KEY_ID" let issuerID = "YOUR_ISSUER_ID" let bundleID = "com.app.bundle" let creator = PromotionalOfferV2SignatureCreator( privateKey: signingKey, keyID: keyID, issuerID: issuerID, bundleID: bundleID ) // Create signature let productID = "com.app.pro_monthly" let offerID = "promo_winback" let transactionID = transaction.id // Optional but recommended let signature = try creator.createSignature( productIdentifier: productID, subscriptionOfferIdentifier: offerID, applicationUsername: nil, nonce: UUID(), timestamp: Date().timeIntervalSince1970, transactionIdentifier: transactionID ) // Send signature to app return signature // Compact JWS string ``` #### From WWDC 2025-241:12:44, 2025-249 **Server Endpoint Example**: ```swift app.get("promo-offer") { req async throws -> String in let productID = try req.query.get(String.self, at: "productID") let offerID = try req.query.get(String.self, at: "offerID") let signature = try creator.createSignature( productIdentifier: productID, subscriptionOfferIdentifier: offerID, transactionIdentifier: nil ) return signature } ``` #### From WWDC 2025-241:12:52 --- ## App Store Server API ### Set App Account Token **Endpoint**: ``` PATCH /inApps/v1/transactions/{originalTransactionId} ``` **Request Body**: ```json { "appAccountToken": "550e8400-e29b-41d4-a716-446655440000" } ``` **Usage**: - Set appAccountToken for purchases made outside your app (offer codes, App Store) - Update appAccountToken when account ownership changes - Associates transaction with customer account on your server #### From WWDC 2025-249:5:19 ### Get App Transaction Info **Endpoint**: ``` GET /inApps/v2/appTransaction/{transactionId} ``` **Response**: ```json { "signedAppTransactionInfo": "eyJhbGc..." } ``` **Usage**: - Get app download information on server - Check app version, platform, environment - Available later in 2025 #### From WWDC 2025-249:10:48 ### Send Consumption Information V2 **Endpoint**: ``` PUT /inApps/v2/transactions/consumption/{transactionId} ``` **Request Body**: ```json { "customerConsented": true, "sampleContentProvided": false, "deliveryStatus": "DELIVERED", "refundPreference": "GRANT_PRORATED", "consumptionPercentage": 25000 } ``` **Fields**: - `customerConsented` (required): User consented to send consumption data - `sampleContentProvided` (optional): Sample provided before purchase - `deliveryStatus` (required): "DELIVERED" or various UNDELIVERED statuses - `refundPreference` (optional): "NO_REFUND", "GRANT_REFUND", "GRANT_PRORATED" - `consumptionPercentage` (optional): 0-100000 (millipercent, e.g., 25000 = 25%) **Prorated Refund**: - New in 2025 - Supports partial consumption (consumables, non-consumables, non-renewing) - For auto-renewable subscriptions, App Store calculates based on time remaining #### From WWDC 2025-249:16:09 ### Refund Notifications **REFUND Notification**: ```json { "notificationType": "REFUND", "data": { "signedTransactionInfo": "...", "refundPercentage": 75, "revocationType": "REFUND_PRORATED" } } ``` **revocationType Values**: - `REFUND_FULL`: 100% refund - revoke all access - `REFUND_PRORATED`: Partial refund - revoke proportional access - `FAMILY_REVOKE`: Family Sharing removed - revoke access #### From WWDC 2025-249:20:17 --- ## Edge Cases ### Family Sharing **Detect Family Shared Transactions**: ```swift // appAccountToken is NOT available for family shared transactions let transaction: Transaction if transaction.appAccountToken == nil { // Might be family shared (or appAccountToken not set) // Check ownershipType (if available) } ``` **Subscription Status for Family Sharing**: ```swift // Each family member has unique appTransactionID // Use appTransactionID to identify individual family members ``` #### From WWDC 2025-241:1:54 ### Refunds **Handle Refund**: ```swift func handleTransaction(_ transaction: Transaction) async { if let revocationDate = transaction.revocationDate { // Transaction was refunded print("Refunded on \(revocationDate)") switch transaction.revocationReason { case .developerIssue: // Refund due to app issue case .other: // Other refund reason @unknown default: break } // Revoke entitlement await revokeEntitlement(for: transaction.productID) } } ``` ### Advanced Commerce API The Advanced Commerce API enables support for: - In-app purchases for large content catalogs - Creator experiences (tipping, patronage) - Subscriptions with optional add-ons **Check if Transaction Uses Advanced Commerce**: ```swift if transaction.advancedCommerceInfo != nil { // Transaction from Advanced Commerce API // Large catalogs, creator experiences, subscriptions with add-ons } ``` Accessible through the `advancedCommerceInfo` field on both `Transaction` and `RenewalInfo`. Returns `nil` for standard IAP transactions. #### From WWDC 2025-241:4:51 ### Win-Back Offers **Show Win-Back for Expired Subscription**: ```swift let renewalInfo: RenewalInfo if renewalInfo.expirationReason == .didNotConsentToPriceIncrease { // Perfect time for win-back offer! SubscriptionOfferView( groupID: groupID, visibleRelationship: .current ) .preferredSubscriptionOffer(offer: winBackOffer) } ``` #### From WWDC 2025-241:5:38 --- ## Testing ### StoreKit Configuration File **Create**: 1. Xcode → File → New → StoreKit Configuration File 2. Add products (consumables, non-consumables, subscriptions) 3. Configure prices, images, descriptions **Enable in Scheme**: 1. Scheme → Edit Scheme → Run → Options 2. StoreKit Configuration: Select .storekit file **Test Scenarios**: - Successful purchases - Cancelled purchases - Subscription renewals (accelerated time) - Subscription expirations - Upgrades/downgrades - Offer code redemptions - Family Sharing (enable in config file) ### Transaction Manager Use the Transaction Manager window in Xcode to inspect and manipulate transactions during testing: - Create transactions manually (test specific purchase flows) - Modify transaction properties (expiration, renewal state) - Test subscription offer scenarios - Inspect transaction details and verification status **Open**: Debug → StoreKit → Manage Transactions (while running with StoreKit configuration) ### Sandbox Testing **Create Sandbox Account**: 1. App Store Connect → Users and Access → Sandbox Testers 2. Create test Apple ID 3. Sign in on device Settings → App Store → Sandbox Account **Clear Purchase History**: - Settings → App Store → Sandbox Account → Clear Purchase History --- ## Migration from StoreKit 1 ### Key Changes **Delegates → Async/Await**: ```swift // StoreKit 1 class StoreObserver: NSObject, SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { // Handle transactions } } // StoreKit 2 for await result in Transaction.updates { // Handle transactions } ``` **Receipt → Transaction**: ```swift // StoreKit 1 let receiptURL = Bundle.main.appStoreReceiptURL let receipt = try Data(contentsOf: receiptURL!) // StoreKit 2 let transaction: Transaction // Automatically verified! ``` **Products → Product.products(for:)**: ```swift // StoreKit 1 let request = SKProductsRequest(productIdentifiers: Set(productIDs)) request.delegate = self request.start() // StoreKit 2 let products = try await Product.products(for: productIDs) ``` --- ## Resources **WWDC**: 2025-241, 2025-249, 2024-10061, 2024-10062, 2024-10110, 2023-10013, 2023-10140, 2022-10007, 2022-110404, 2021-10114 **Docs**: /storekit **Skills**: axiom-in-app-purchases --- ## Quick Reference ### Product Types - `.consumable` - Can purchase multiple times (coins, boosts) - `.nonConsumable` - Purchase once, own forever (premium, level packs) - `.autoRenewable` - Auto-renewing subscriptions - `.nonRenewing` - Fixed duration subscriptions ### Transaction States - `success` - Purchase completed - `userCancelled` - User tapped cancel - `pending` - Requires action (Ask to Buy) ### Subscription States - `.subscribed` - Active subscription - `.expired` - Subscription ended - `.inGracePeriod` - Billing issue, access maintained - `.inBillingRetryPeriod` - Apple retrying payment - `.revoked` - Family Sharing removed ### Essential Calls ```swift // Load products try await Product.products(for: productIDs) // Purchase try await product.purchase(confirmIn: scene) // Current entitlements Transaction.currentEntitlements(for: productID) // Transaction listener Transaction.updates // Subscription status Product.SubscriptionInfo.status(for: groupID) // Restore purchases try await AppStore.sync() // Finish transaction (REQUIRED) await transaction.finish() ```