Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Covers subscription control styles, offer management, testing, server-side validation, and advanced subscription handling patterns for StoreKit 2.
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)Specify where controls appear within the view:
.subscriptionStoreControlStyle(.picker, placement: .bottom)
// Placement options: .bottom, .leading, .trailing, .scrollView,
// .bottomBar, .buttonsInBottomBarControl 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 default.containerBackground(.blue.gradient, for: .subscriptionStore)
.containerBackground(for: .subscriptionStoreHeader) {
Image("premium-header").resizable().scaledToFill()
}
.containerBackground(for: .subscriptionStoreFullHeight) {
LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom)
}.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .redeemCode)
.storeButton(.visible, for: .cancellation)
.storeButton(.visible, for: .policies)
.storeButton(.hidden, for: .signIn)// URL-based
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
// Custom view
.subscriptionStorePolicyDestination(for: .termsOfService) {
TermsOfServiceView()
}
// Style policy link text
.subscriptionStorePolicyForegroundStyle(.white).subscriptionStoreControlIcon { product, subscriptionInfo in
if subscriptionInfo.subscriptionPeriod.unit == .year {
Image(systemName: "star.fill")
} else {
Image(systemName: "star")
}
}.storeButton(.visible, for: .signIn)
.subscriptionStoreSignInAction {
showSignInSheet = true
}SubscriptionStoreView(groupID: "premium_group") {
SubscriptionOptionGroup("Monthly Plans") { product in
product.subscription?.subscriptionPeriod.unit == .month
}
SubscriptionOptionGroup("Annual Plans") { product in
product.subscription?.subscriptionPeriod.unit == .year
}
}SubscriptionStoreView(groupID: "premium_group") {
SubscriptionPeriodGroupSet()
}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")
}
}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 button for a specific subscription option
SubscriptionStoreButton(option)
// Custom picker with confirmation
SubscriptionStorePicker(pickerContent: { option in
Text(option.displayName)
}, confirmation: { selectedOption in
Text("Subscribe to \(selectedOption.displayName)")
})// Per-product eligibility
let isEligible = product.subscription?.isEligibleForIntroOffer ?? false
// Per-group eligibility
let groupEligible = await Product.SubscriptionInfo.isEligibleForIntroOffer(
for: groupID
)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()
}
}Promotional offers require server-side signature generation.
let promoOffers = product.subscription?.promotionalOffers ?? []
for offer in promoOffers {
print("Offer: \(offer.id ?? "nil"), price: \(offer.displayPrice)")
}// 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)
]).subscriptionPromotionalOffer(offer: promoOffer, signature: signature)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
}Target former subscribers who cancelled. Available since iOS 18.
let winBackOffers = product.subscription?.winBackOffers ?? []let statuses = try await Product.SubscriptionInfo.Status.status(for: groupID)
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
let eligibleIDs = renewalInfo.eligibleWinBackOfferIDs
// Display offers matching these IDs
}let result = try await product.purchase(options: [
.winBackOffer(winBackOffer)
])@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)")
}
}
}.storeButton(.visible, for: .redeemCode)if let offerType = transaction.offerType {
switch offerType {
case .introductory: break // Introductory offer applied
case .promotional: break // Check transaction.offerID
case .code: break // Offer code redeemed
case .winBack: break // Win-back offer applied
default: break
}
}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()On your server, use Apple's App Store Server Library:
verifyAndDecodeTransaction(signedTransaction:) to validate and decodeverifyAndDecodeRenewalInfo(signedRenewalInfo:) for subscription renewal infoJWSTransaction from the App Store Server API and
App Store Server Notifications V2Bind transactions to specific devices to prevent replay attacks:
let deviceVerification = transaction.deviceVerification
let nonce = transaction.deviceVerificationNonce
// Send both to server for additional validationDefine products with:
// 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")])Use Debug > StoreKit > Manage Transactions to:
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()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()| 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 |
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
}Apple automatically retries failed payments. During billing retry, the
subscription state is .inBillingRetryPeriod. Check renewalInfo.isInBillingRetry.
// 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
)@State private var showRefund = false
Button("Request Refund") { showRefund = true }
.refundRequestSheet(for: transactionID, isPresented: $showRefund) { result in
// Handle refund request dismissal
}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
}switch transaction.ownershipType {
case .purchased:
// User purchased directly
break
case .familyShared:
// Shared via Family Sharing -- may be revoked if sharer leaves
break
default:
break
}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 applies to child accounts in Family Sharing. The purchase
returns .pending and must be approved by the family organizer.
case .pending:
// Show UI indicating purchase needs parental approval
showPendingApprovalState()
// When approved, the transaction arrives via Transaction.updatestry await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])Then approve or decline in Xcode's Transaction Manager.
.currentEntitlementTask(for: "com.app.premium") { state in
self.entitlementState = state
}enum EntitlementTaskState<Value> {
case loading
case success(Value) // Value is VerificationResult<Transaction>?
case failure(any Error)
}Use map and flatMap for transformations:
.currentEntitlementTask(for: ProductID.premium) { state in
self.isPremium = state.map { $0 != nil } ?? false
}// 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
}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)
}
}
}
}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 neededif 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
}
}Process transactions that were not finished in previous sessions:
func processUnfinishedTransactions() async {
for await result in Transaction.unfinished {
guard case .verified(let transaction) = result else { continue }
await deliverContent(for: transaction)
await transaction.finish()
}
}Call this at app launch alongside the Transaction.updates listener to
ensure no purchases are lost.
// WRONG: Only listen for updates, ignore pending deliveries
init() { transactionListener = listenForTransactions() }
// CORRECT: Process unfinished transactions AND listen for updates
init() {
transactionListener = listenForTransactions()
Task { await processUnfinishedTransactions() }
}// WRONG: Revoking access during billing retry
case .inBillingRetryPeriod: revokeAccess()
// CORRECT: Grant limited access and prompt payment update
case .inBillingRetryPeriod:
grantLimitedAccess()
showUpdatePaymentPrompt()// 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
}skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references