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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
Consumable:
Non-Consumable:
Auto-Renewable Subscription:
Non-Renewing Subscription:
Use this reference when:
Related Skills:
in-app-purchases — Discipline skill with testing-first workflow, architecture patternsiap-auditor agent for auditing existing IAP code)iap-implementation agent for implementing IAP from scratch)Product represents an in-app purchase item configured in App Store Connect or StoreKit configuration file.
Basic Loading:
import StoreKit
let productIDs = [
"com.app.coins_100",
"com.app.premium",
"com.app.pro_monthly"
]
let products = try await Product.products(for: productIDs)
Handling Missing Products:
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
}
Basic Properties:
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:
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
}
Check if Product is Subscription:
if let subscriptionInfo = product.subscription {
// Product is auto-renewable subscription
let groupID = subscriptionInfo.subscriptionGroupID
let period = subscriptionInfo.subscriptionPeriod
}
Subscription Period:
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:
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:
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 with UI Context (iOS 18.2+):
let product: Product
let scene: UIWindowScene
let result = try await product.purchase(confirmIn: scene)
Purchase with Options:
let accountToken = UUID()
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
Purchase with Promotional Offer (JWS Format):
let jwsSignature: String // From your server
let result = try await product.purchase(
confirmIn: scene,
options: [
.promotionalOffer(offerID: "promo_winback", signature: jwsSignature)
]
)
Purchase with Custom Intro Eligibility:
let jwsSignature: String // From your server
let result = try await product.purchase(
confirmIn: scene,
options: [
.introductoryOfferEligibility(signature: jwsSignature)
]
)
SwiftUI Purchase (Using Environment):
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)")
}
}
}
}
}
Handling Purchase Results:
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
}
Transaction represents a successful in-app purchase. Contains purchase metadata, product ID, purchase date, and for subscriptions, expiration date.
appTransactionID:
let transaction: Transaction
let appTransactionID = transaction.appTransactionID
// Unique ID for app download (same across all purchases by same Apple Account)
offerPeriod:
if let offerPeriod = transaction.offer?.period {
print("Offer duration: \(offerPeriod)")
// ISO 8601 duration format (e.g., "P1M" for 1 month)
}
advancedCommerceInfo:
if let advancedInfo = transaction.advancedCommerceInfo {
// Only present for Advanced Commerce API purchases
// nil for standard IAP
}
Basic Fields:
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:
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:
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
}
Get All Current Entitlements:
var purchasedProductIDs: Set<String> = []
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)
}
}
Get Entitlements for Specific Product (iOS 18.4+):
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
}
}
Deprecated API (iOS 18.4):
// ❌ 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) {
// ...
}
Get All Transactions:
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:
for await result in Transaction.all(matching: productID) {
guard let transaction = try? result.payloadValue else {
continue
}
// All transactions for this product
}
Listen for Real-Time Updates (REQUIRED):
func listenForTransactions() -> Task<Void, Never> {
Task.detached {
for await verificationResult in Transaction.updates {
await handleTransaction(verificationResult)
}
}
}
func handleTransaction(_ result: VerificationResult<Transaction>) 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()
}
Transaction Sources:
VerificationResult:
let result: VerificationResult<Transaction>
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:
Always Call finish():
await transaction.finish()
When to finish:
What happens if you don't finish:
Transaction.updates re-emits transactionAppTransaction represents the original app download. Available via AppTransaction.shared.
appTransactionID:
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)")
}
originalPlatform:
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
}
}
Note: Apps downloaded on watchOS show originalPlatform = .iOS
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
Check App Version:
if let appTransaction = try? await AppTransaction.shared.payloadValue {
if appTransaction.appVersion != currentVersion {
// Prompt user to update
}
}
Business Model Migration:
// 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()
}
RenewalInfo provides information about auto-renewable subscription renewal state, including whether it will renew, expiration reason, and upcoming offers.
appTransactionID:
let renewalInfo: RenewalInfo
let appTransactionID = renewalInfo.appTransactionID
offerPeriod:
if let offerPeriod = renewalInfo.offerPeriod {
print("Next renewal offer period: \(offerPeriod)")
// ISO 8601 duration (applies at next renewal)
}
appAccountToken:
if let token = renewalInfo.appAccountToken {
// UUID associating subscription with your server account
}
advancedCommerceInfo:
if let advancedInfo = renewalInfo.advancedCommerceInfo {
// Only for Advanced Commerce API subscriptions
}
Renewal State:
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:
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
}
Grace Period:
if let gracePeriodExpiration = renewalInfo.gracePeriodExpirationDate {
// Subscription in grace period - billing issue
// Show update payment method UI
}
Price Increase Consent:
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
}
}
From SubscriptionStatus:
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)")
}
}
SubscriptionStatus represents the current state of an auto-renewable subscription, including whether it's active, expired, in grace period, or in billing retry.
State Enum:
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
}
For Subscription Group:
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 }
For Specific Transaction (iOS 18.4+):
let transactionID = transaction.id
let status = try await Product.SubscriptionInfo.status(for: transactionID)
Listen for Status Updates:
for await statuses in Product.SubscriptionInfo.Status.updates(for: groupID) {
// Process updated statuses
for status in statuses {
print("Status: \(status.state)")
}
}
let status: Product.SubscriptionInfo.Status
status.state // .subscribed, .expired, etc.
status.transaction // VerificationResult<Transaction>
status.renewalInfo // VerificationResult<RenewalInfo>
Basic Usage:
import StoreKit
struct ContentView: View {
let productID = "com.app.premium"
var body: some View {
ProductView(id: productID)
}
}
With Loaded Product:
struct ContentView: View {
let product: Product
var body: some View {
ProductView(for: product)
}
}
Custom Icon:
ProductView(id: productID) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
Control Styles:
ProductView(id: productID)
.productViewStyle(.regular) // Default
ProductView(id: productID)
.productViewStyle(.compact) // Smaller
ProductView(id: productID)
.productViewStyle(.large) // Prominent
Basic Store:
struct ContentView: View {
let productIDs = [
"com.app.coins_100",
"com.app.coins_500",
"com.app.coins_1000"
]
var body: some View {
StoreView(ids: productIDs)
}
}
With Loaded Products:
struct ContentView: View {
let products: [Product]
var body: some View {
StoreView(products: products)
}
}
Basic Subscription Store:
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")
}
}
}
}
Control Style:
SubscriptionStoreView(groupID: groupID) {
// Marketing content
}
.subscriptionStoreControlStyle(.automatic) // Default
.subscriptionStoreControlStyle(.picker) // Horizontal picker
.subscriptionStoreControlStyle(.buttons) // Stacked buttons
.subscriptionStoreControlStyle(.prominentPicker) // Large picker (iOS 18.4+)
Basic Offer View:
struct ContentView: View {
let productID = "com.app.pro_monthly"
var body: some View {
SubscriptionOfferView(id: productID)
}
}
With Promotional Icon:
SubscriptionOfferView(
id: productID,
prefersPromotionalIcon: true
)
With Custom Icon:
SubscriptionOfferView(id: productID) {
Image("custom-icon")
.resizable()
.frame(width: 60, height: 60)
} placeholder: {
Image(systemName: "photo")
.foregroundStyle(.gray)
}
With Detail Action:
@State private var showStore = false
var body: some View {
SubscriptionOfferView(id: productID)
.subscriptionOfferViewDetailAction {
showStore = true
}
.sheet(isPresented: $showStore) {
SubscriptionStoreView(groupID: "pro_tier")
}
}
Visible Relationship:
// 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
)
With App Icon:
SubscriptionOfferView(
groupID: groupID,
visibleRelationship: .all,
useAppIcon: true
)
Promotional Offer (JWS):
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
}
)
Offer codes now support all product types (previously subscription-only):
UIKit:
func showOfferCodeSheet() {
guard let scene = view.window?.windowScene else { return }
StoreKit.AppStore.presentOfferCodeRedeemSheet(in: scene)
}
SwiftUI:
.offerCodeRedemption(isPresented: $showRedeemSheet)
New: .oneTime:
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
}
}
Legacy Access (iOS 15-17.1):
if let offerMode = transaction.offerPaymentModeStringRepresentation {
// String representation for older OS versions
print(offerMode) // "oneTime"
}
Open-source library for signing IAP requests and decoding server API responses. Available in Swift, Java, Python, Node.js.
Swift Example:
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
Server Endpoint Example:
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
}
Endpoint:
PATCH /inApps/v1/transactions/{originalTransactionId}
Request Body:
{
"appAccountToken": "550e8400-e29b-41d4-a716-446655440000"
}
Usage:
Endpoint:
GET /inApps/v2/appTransaction/{transactionId}
Response:
{
"signedAppTransactionInfo": "eyJhbGc..."
}
Usage:
Endpoint:
PUT /inApps/v2/transactions/consumption/{transactionId}
Request Body:
{
"customerConsented": true,
"sampleContentProvided": false,
"deliveryStatus": "DELIVERED",
"refundPreference": "GRANT_PRORATED",
"consumptionPercentage": 25000
}
Fields:
customerConsented (required): User consented to send consumption datasampleContentProvided (optional): Sample provided before purchasedeliveryStatus (required): "DELIVERED" or various UNDELIVERED statusesrefundPreference (optional): "NO_REFUND", "GRANT_REFUND", "GRANT_PRORATED"consumptionPercentage (optional): 0-100000 (millipercent, e.g., 25000 = 25%)Prorated Refund:
REFUND Notification:
{
"notificationType": "REFUND",
"data": {
"signedTransactionInfo": "...",
"refundPercentage": 75,
"revocationType": "REFUND_PRORATED"
}
}
revocationType Values:
REFUND_FULL: 100% refund - revoke all accessREFUND_PRORATED: Partial refund - revoke proportional accessFAMILY_REVOKE: Family Sharing removed - revoke accessDetect Family Shared Transactions:
// 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:
// Each family member has unique appTransactionID
// Use appTransactionID to identify individual family members
Handle Refund:
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)
}
}
Check if Transaction Uses Advanced Commerce:
if transaction.advancedCommerceInfo != nil {
// Transaction from Advanced Commerce API
// Large catalogs, creator experiences, subscriptions with add-ons
}
More Info: Visit Advanced Commerce API documentation
Show Win-Back for Expired Subscription:
let renewalInfo: RenewalInfo
if renewalInfo.expirationReason == .didNotConsentToPriceIncrease {
// Perfect time for win-back offer!
SubscriptionOfferView(
groupID: groupID,
visibleRelationship: .current
)
.preferredSubscriptionOffer(offer: winBackOffer)
}
Create:
Enable in Scheme:
Test Scenarios:
Create Sandbox Account:
Clear Purchase History:
Delegates → Async/Await:
// 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:
// StoreKit 1
let receiptURL = Bundle.main.appStoreReceiptURL
let receipt = try Data(contentsOf: receiptURL!)
// StoreKit 2
let transaction: Transaction // Automatically verified!
Products → Product.products(for:):
// StoreKit 1
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
request.delegate = self
request.start()
// StoreKit 2
let products = try await Product.products(for: productIDs)
.consumable - Can purchase multiple times (coins, boosts).nonConsumable - Purchase once, own forever (premium, level packs).autoRenewable - Auto-renewing subscriptions.nonRenewing - Fixed duration subscriptionssuccess - Purchase completeduserCancelled - User tapped cancelpending - Requires action (Ask to Buy).subscribed - Active subscription.expired - Subscription ended.inGracePeriod - Billing issue, access maintained.inBillingRetryPeriod - Apple retrying payment.revoked - Family Sharing removed// 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()