StoreKit 2 Advanced Reference
Covers subscription control styles, offer management, testing, server-side validation, and advanced subscription handling patterns for StoreKit 2. Use this after the core purchase, transaction listener, and entitlement patterns from the top-level StoreKit skill are in place.
Contents
- SubscriptionStoreView Control Styles
- Subscription Group Management
- Introductory Offers
- Promotional Offers
- Win-Back Offers
- Offer Codes
- Server-Side Validation
- StoreKit Testing in Xcode
- Subscription Renewal States
- Grace Period and Billing Retry
- Refund Handling
- Family Sharing
- Ask to Buy Handling
- .currentEntitlementTask SwiftUI Modifier
- Subscription Status Listener
- Product Promotion Management
- Price Increase Handling
- Unfinished Transactions
- Common Advanced Mistakes
SubscriptionStoreView Control Styles
Apply control styles to change how subscription options render in
SubscriptionStoreView.
// Individual buttons for each subscription option
.subscriptionStoreControlStyle(.buttons)
// Inline picker for compact selection
.subscriptionStoreControlStyle(.picker)
// Picker with the selected option visually emphasized
.subscriptionStoreControlStyle(.prominentPicker)
// Full-page swipeable picker (one option per page)
.subscriptionStoreControlStyle(.pagedPicker)
// Paged picker with prominent selected option
.subscriptionStoreControlStyle(.pagedProminentPicker)
// Minimal inline picker for tight layouts
.subscriptionStoreControlStyle(.compactPicker)
// System decides based on context
.subscriptionStoreControlStyle(.automatic)Control Placement
Specify where controls appear within the view. Not every placement is supported by every control style, so let unsupported combinations fall back to the system default instead of assuming exact placement on every platform.
.subscriptionStoreControlStyle(.picker, placement: .bottom)
// Placement options: .bottom, .leading, .trailing, .scrollView,
// .bottomBar, .buttonsInBottomBarButton Labels
Control what information subscription buttons display:
.subscriptionStoreButtonLabel(.multiline) // Full details (name, price, period)
.subscriptionStoreButtonLabel(.price) // Price only
.subscriptionStoreButtonLabel(.displayName) // Product name only
.subscriptionStoreButtonLabel(.action) // Action text ("Subscribe")
.subscriptionStoreButtonLabel(.singleLine) // Condensed single line
.subscriptionStoreButtonLabel(.automatic) // System defaultContainer Backgrounds
.containerBackground(.blue.gradient, for: .subscriptionStore)
.containerBackground(for: .subscriptionStoreHeader) {
Image("premium-header").resizable().scaledToFill()
}
.containerBackground(for: .subscriptionStoreFullHeight) {
LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom)
}Subscription Store Buttons
.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .redeemCode)
.storeButton(.visible, for: .cancellation)
.storeButton(.visible, for: .policies)
.storeButton(.hidden, for: .signIn)Policy Destinations
// URL-based
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
// Custom view
.subscriptionStorePolicyDestination(for: .termsOfService) {
TermsOfServiceView()
}
// Style policy link text
.subscriptionStorePolicyForegroundStyle(.white)Decorative Icons per Option
.subscriptionStoreControlIcon { product, subscriptionInfo in
if subscriptionInfo.subscriptionPeriod.unit == .year {
Image(systemName: "star.fill")
} else {
Image(systemName: "star")
}
}Sign-In Action
.storeButton(.visible, for: .signIn)
.subscriptionStoreSignInAction {
showSignInSheet = true
}Subscription Group Management
The option hierarchy helper views in this section are iOS 18+ APIs for
organizing subscription choices inside SubscriptionStoreView.
Hierarchical Layouts with SubscriptionOptionGroup
SubscriptionStoreView(groupID: "premium_group") {
SubscriptionOptionGroup("Monthly Plans") { product in
product.subscription?.subscriptionPeriod.unit == .month
}
SubscriptionOptionGroup("Annual Plans") { product in
product.subscription?.subscriptionPeriod.unit == .year
}
}Automatic Period Grouping
SubscriptionStoreView(groupID: "premium_group") {
SubscriptionPeriodGroupSet()
}Sections with Headers
SubscriptionStoreView(groupID: "premium_group") {
SubscriptionOptionSection("Standard", isIncluded: { product in
product.subscription?.groupLevel == 1
}) {
Text("Basic features included")
}
SubscriptionOptionSection("Pro", isIncluded: { product in
product.subscription?.groupLevel == 2
}) {
Text("All features included")
}
}Visible Relationships
Filter which subscription levels are shown relative to the current subscription:
// Show all options
SubscriptionStoreView(groupID: "group_id", visibleRelationships: .all)
// Show only upgrades from current subscription
SubscriptionStoreView(groupID: "group_id", visibleRelationships: .upgrade)Custom Subscription Store Controls
SubscriptionStoreButton, SubscriptionStorePicker,
SubscriptionOptionGroup, and related option controls are supported inside a
custom SubscriptionStoreControlStyle.makeBody(configuration:). Do not place
them as arbitrary standalone content outside a custom control style.
Introductory Offers
Checking Eligibility
// Per-product eligibility
let isEligible = product.subscription?.isEligibleForIntroOffer ?? false
// Per-group eligibility
let groupEligible = await Product.SubscriptionInfo.isEligibleForIntroOffer(
for: groupID
)Accessing Offer Details
if let introOffer = product.subscription?.introductoryOffer {
let price = introOffer.displayPrice // Localized price
let period = introOffer.period // SubscriptionPeriod
let count = introOffer.periodCount // Number of periods
let mode = introOffer.paymentMode // .freeTrial, .payAsYouGo, .payUpFront
switch introOffer.paymentMode {
case .freeTrial:
Text("Free for \(count) \(period.unit)")
case .payAsYouGo:
Text("\(price)/\(period.unit) for \(count) periods")
case .payUpFront:
Text("\(price) for \(count) \(period.unit)")
default:
EmptyView()
}
}Introductory offers apply automatically when eligible. No special purchase options needed.
Promotional Offers
Promotional offers require server-side signature generation.
Accessing Promotional Offers
let promoOffers = product.subscription?.promotionalOffers ?? []
for offer in promoOffers {
print("Offer: \(offer.id ?? "nil"), price: \(offer.displayPrice)")
}Purchasing with a Promotional Offer
// 1. Get signature from your server
let signature = Product.SubscriptionOffer.Signature(
keyID: serverKeyID,
nonce: serverNonce,
timestamp: serverTimestamp,
signature: serverSignatureData
)
// 2. Purchase with the offer
guard let offerID = offer.id else { throw StoreError.invalidOffer }
let result = try await product.purchase(options: [
.promotionalOffer(offerID: offerID, signature: signature)
])SwiftUI Automatic Promotional Offer
For StoreKit SwiftUI views on iOS 26+, provide the offer and the compact JWS signature asynchronously:
.subscriptionPromotionalOffer { product, subscription in
subscription.promotionalOffers.first
} compactJWS: { product, subscription, offer in
guard let offerID = offer.id else { throw StoreError.invalidOffer }
return try await offerSigner.compactJWS(
productID: product.id,
offerID: offerID
)
}Preferred Offer Selection
Let the system choose the best offer for each user:
.preferredSubscriptionOffer { product, subscription, eligibleOffers in
// Return the best offer, or nil for no offer
return eligibleOffers.first
}Win-Back Offers
Target former subscribers who cancelled. Available since iOS 18.
Accessing Win-Back Offers
let winBackOffers = product.subscription?.winBackOffers ?? []winBackOffers is the raw offer list for the product. Filter it through
renewalInfo.eligibleWinBackOfferIDs before showing or applying offers.
Checking Eligibility via Renewal Info
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
let eligibleOfferIDs = statuses.flatMap { status -> [String] in
guard case .verified(let renewalInfo) = status.renewalInfo else { return [] }
return renewalInfo.eligibleWinBackOfferIDs
}
let offersByID = Dictionary(uniqueKeysWithValues: winBackOffers.compactMap { offer in
offer.id.map { ($0, offer) }
})
let eligibleWinBackOffers = eligibleOfferIDs.compactMap { offersByID[$0] }
for offer in eligibleWinBackOffers {
// Display or apply only eligible offers.
}Purchasing with a Win-Back Offer
let result = try await product.purchase(options: [
.winBackOffer(winBackOffer)
])Offer Codes
Redemption Sheet
@State private var showRedeemSheet = false
var body: some View {
Button("Redeem Code") { showRedeemSheet = true }
.offerCodeRedemption(isPresented: $showRedeemSheet) { result in
switch result {
case .success:
await storeManager.updateEntitlements()
case .failure(let error):
print("Redemption failed: \(error)")
}
}
}Show Redeem Button on Subscription Store
.storeButton(.visible, for: .redeemCode)Testing Offer Codes
Test offer-code redemption in StoreKit configuration files and sandbox. In StoreKit testing, use the configured offer-code reference name:
try await product.purchase(options: [.codeOffer(referenceName: "SUMMER2024")])Also test the user-facing redemption path with .storeButton(.visible, for: .redeemCode) or .offerCodeRedemption(isPresented:), then verify the resulting
transaction uses transaction.offer?.type == .code and the expected
transaction.offer?.id.
Verifying Applied Offers in Transactions
if let offer = transaction.offer {
switch offer.type {
case .introductory: break // Introductory offer applied
case .promotional: break // Promotional offer applied
case .code: break // Offer code redeemed
case .winBack: break // Win-back offer applied
default: break
}
let offerID = offer.id
}Server-Side Validation
Sending JWS to Server
StoreKit 2 transactions are JWS (JSON Web Signature) tokens. Send the raw JWS string to your server for validation.
case .success(let verification):
let transaction = try checkVerified(verification)
// Send to server for validation
let jwsString = verification.jwsRepresentation
try await sendToServer(jws: jwsString, productID: transaction.productID)
await transaction.finish()Server-Side Verification
On your server, use Apple's App Store Server Library:
verifyAndDecodeTransaction(signedTransaction:)to validate and decodeverifyAndDecodeRenewalInfo(signedRenewalInfo:)for subscription renewal info- The JWS format matches
JWSTransactionfrom the App Store Server API and App Store Server Notifications V2
Device Verification
Bind transactions to specific devices to prevent replay attacks:
let deviceVerification = transaction.deviceVerification
let nonce = transaction.deviceVerificationNonce
// Send both to server for additional validationStoreKit Testing in Xcode
StoreKit Configuration Files
- Create a StoreKit Configuration file in Xcode: File > New > File > StoreKit Configuration File
- Add products matching your App Store Connect configuration
- Set the configuration in the scheme: Edit Scheme > Run > Options > StoreKit Configuration
Configuration File Contents
Define products with:
- Product ID, reference name, product type
- Price and locale
- Subscription group and level (for subscriptions)
- Introductory and promotional offers
- Family Sharing settings
Testing Features
// Simulate Ask to Buy
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])
// Test-only: set purchase date and renewal behavior
try await product.purchase(options: [
.purchaseDate(Date(), renewalBehavior: .default)
])
// Test-only: apply offer code by reference name
try await product.purchase(options: [.codeOffer(referenceName: "SUMMER2024")])StoreKit Testing Capabilities
- Simulate failed transactions, interrupted purchases, refunds
- Test Ask to Buy pending approvals for promotional, win-back, and offer-code purchases
- Speed up subscription renewals (renewals happen in minutes, not months)
- Test grace period and billing retry states
- Clear purchase history between test runs
- Test offer redemption flows
- Simulate subscription expiration and cancellation
Transaction Manager in Xcode
Use Debug > StoreKit > Manage Transactions to:
- View all test transactions
- Delete transactions to reset state
- Request refunds
- Expire subscriptions
- Approve or decline Ask to Buy requests
Subscription Renewal States
Active States (grant access)
switch status.state {
case .subscribed:
// Active, auto-renewing subscription
grantAccess()
case .inGracePeriod:
// Payment failed but grace period active -- still grant access
// Show a gentle prompt to update payment method
grantAccess()
showPaymentUpdatePrompt()Degraded States (consider limited or no access)
case .inBillingRetryPeriod:
// Payment failed, Apple is retrying -- access decision is yours
// Apple recommends granting limited access to encourage payment update
grantLimitedAccess()
showPaymentFailedBanner()
case .expired:
// Subscription ended -- check expirationReason
revokeAccess()
if let reason = renewalInfo.expirationReason {
switch reason {
case .autoRenewDisabled: showResubscribeOffer()
case .billingError: showUpdatePaymentMethod()
case .didNotConsentToPriceIncrease: showPriceInfo()
case .productUnavailable: break
default: break
}
}
case .revoked:
// Apple refunded -- must revoke access
revokeAccess()Expiration Reasons
| Reason | Meaning |
|---|---|
.autoRenewDisabled |
User voluntarily cancelled |
.billingError |
Payment method failed |
.didNotConsentToPriceIncrease |
User did not agree to price increase |
.productUnavailable |
Product no longer available |
.unknown |
Unspecified reason |
Grace Period and Billing Retry
Grace Period
When enabled in App Store Connect, subscribers retain access for a short
period after a billing failure. Check status.state == .inGracePeriod and
the gracePeriodExpirationDate on renewal info.
if status.state == .inGracePeriod,
case .verified(let renewalInfo) = status.renewalInfo {
let expirationDate = renewalInfo.gracePeriodExpirationDate
// Grant access but prompt to update payment method
}Billing Retry
Apple automatically retries failed payments. During billing retry, the
subscription state is .inBillingRetryPeriod. Check renewalInfo.isInBillingRetry.
Refund Handling
Initiating Refund Requests
// From a transaction instance
let refundStatus = try await transaction.beginRefundRequest(in: windowScene)
// From a transaction ID
let refundStatus = try await Transaction.beginRefundRequest(
for: transactionID, in: windowScene
)SwiftUI Refund Sheet
@State private var showRefund = false
Button("Request Refund") { showRefund = true }
.refundRequestSheet(for: transactionID, isPresented: $showRefund) { result in
// Handle refund request dismissal
}Detecting Refunds
Refunds appear via Transaction.updates with a non-nil revocationDate.
Always check revocationDate when evaluating entitlements.
if let revocationDate = transaction.revocationDate {
revokeAccess(for: transaction.productID)
// revocationPercentage indicates partial vs full refund
let percentage = transaction.revocationPercentage
}Family Sharing
Checking Ownership Type
switch transaction.ownershipType {
case .purchased:
// User purchased directly
break
case .familyShared:
// Shared via Family Sharing -- may be revoked if sharer leaves
break
default:
break
}Family Shareable Products
Check product.isFamilyShareable to determine if a product supports
Family Sharing. Enable Family Sharing in App Store Connect per product.
Family Sharing changes arrive via Transaction.updates. When a family
member stops sharing, the transaction is revoked.
Ask to Buy Handling
Ask to Buy applies to child accounts in Family Sharing. The purchase returns
.pending; show a waiting-for-approval state, keep content locked, and unlock
only after the approved transaction arrives from Transaction.updates.
case .pending:
// Show UI indicating purchase needs parental approval
showPendingApprovalState()
// When approved, the transaction arrives via Transaction.updatesTesting Ask to Buy
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])Then approve or decline in Xcode's Transaction Manager. Include this path when testing promotional offers, win-back offers, offer codes, and renewal states so deferred approval does not bypass verification or entitlement updates.
.currentEntitlementTask SwiftUI Modifier
Basic Usage
.currentEntitlementTask(for: "com.app.premium") { state in
self.entitlementState = state
}EntitlementTaskState Pattern
enum EntitlementTaskState<Value> {
case loading
case success(Value) // Value is VerificationResult<Transaction>?
case failure(any Error)
}Pattern-match the verified optional transaction before granting access:
.currentEntitlementTask(for: ProductID.premium) { state in
if case .success(.some(.verified(let transaction))) = state,
transaction.revocationDate == nil {
self.isPremium = true
} else {
self.isPremium = false
}
}Related Task Modifiers
// Load a single product
.storeProductTask(for: "com.app.premium") { taskState in
// taskState: Product.TaskState (.loading, .success(Product), .unavailable, .failure)
}
// Load multiple products
.storeProductsTask(for: ["id1", "id2"]) { taskState in
// taskState: Product.CollectionTaskState (.loading, .success([Product], unavailable:), .failure)
}
// Monitor subscription status
.subscriptionStatusTask(for: "group_id") { taskState in
// Receive subscription status updates
}Subscription Status Listener
Listen for real-time subscription status changes:
func listenForStatusChanges() -> Task<Void, Never> {
Task {
for await (groupID, statuses) in Product.SubscriptionInfo.Status.all {
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
await handleStatusChange(state: status.state, renewalInfo: renewalInfo)
}
}
}
}Product Promotion Management
Control the order and visibility of promoted in-app purchases on the App Store product page:
// Product.PromotionInfo provides device-level promotion customization
// Configure in App Store Connect and override per-device as neededPrice Increase Handling
if case .verified(let renewalInfo) = status.renewalInfo {
switch renewalInfo.priceIncreaseStatus {
case .noIncreasePending: break
case .pending:
// User has not yet consented -- show price increase info
showPriceIncreaseConsent(newPrice: renewalInfo.renewalPrice,
currency: renewalInfo.currency)
case .agreed:
// User accepted the price increase
break
}
}Unfinished Transactions
Transaction.updates emits unfinished transactions once immediately after app
launch. Use Transaction.unfinished when you need an explicit recovery sweep,
such as after a delivery-server outage or a late listener startup:
func processUnfinishedTransactions() async {
for await result in Transaction.unfinished {
guard case .verified(let transaction) = result else { continue }
await deliverContent(for: transaction)
await transaction.finish()
}
}Keep the Transaction.updates listener as the primary always-on path, and use
the sweep as a recovery tool rather than a replacement for the listener.
Common Advanced Mistakes
Missing a recovery path for unfinished transactions
// WRONG: No launch listener and no recovery sweep
init() { }
// CORRECT: Start updates at launch; sweep unfinished transactions when needed
init() {
transactionListener = listenForTransactions()
Task { await processUnfinishedTransactions() }
}Treating billing retry as expired
// WRONG: Revoking access during billing retry
case .inBillingRetryPeriod: revokeAccess()
// CORRECT: Grant limited access and prompt payment update
case .inBillingRetryPeriod:
grantLimitedAccess()
showUpdatePaymentPrompt()Not handling Family Sharing revocation
// WRONG: Assuming family-shared access is permanent
if transaction.ownershipType == .familyShared {
grantPermanentAccess()
}
// CORRECT: Check revocation status and listen for changes
if transaction.ownershipType == .familyShared,
transaction.revocationDate == nil {
grantAccess() // May be revoked later via Transaction.updates
}