Storekit purchases
Skill markdavidgan/apple-dev-skills/platforms/codex/skills/storekit-purchases
Apple platform development skills for Claude Code, Cursor, Kimi Code, Antigravity, Codex CLI, and Agy.
npx -y skills add markdavidgan/apple-dev-skills --skill storekit-purchasesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
StoreKit 2 in-app purchases and subscriptions in Swift — Product fetch, purchase flow, transaction verification, entitlement checks, Transaction.updates listener, restore, and SwiftUI StoreKit views. Use when implementing or debugging IAP, subscriptions, paywalls, "buy" buttons, free trials, restore purchases, receipt/transaction validation, or StoreKit testing. Pairs with app-store-pricing (strategy) and asc-aso (conversion).
SKILL.md
8.5 KB, as published. Nobody here has run it
StoreKit 2 — Purchases & Subscriptions
Implement in-app purchases and subscriptions with the modern async StoreKit 2 API. Strategy (tiers, regional pricing) lives in app-store-pricing; paywall conversion in asc-aso. This skill is the code.
Use StoreKit 2 (
import StoreKit, async/await, iOS 15+). Do not hand-parse receipts or call the legacySKPaymentQueue/verifyReceiptserver endpoint for new work — StoreKit 2 verifies transactions cryptographically on-device viaVerificationResult.
The Five Things Every IAP Implementation Needs
- Load products from the App Store.
- Purchase a product and handle the result.
- Verify the transaction (never trust an unverified transaction).
- Check current entitlements to unlock content (on launch and on change).
- Listen for transactions that arrive outside your purchase call (renewals, Ask-to-Buy approvals, purchases on other devices, refunds).
Miss #5 and subscriptions silently break.
Minimal correct implementation
import StoreKit
@MainActor
@Observable
final class Store {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private var updates: Task<Void, Never>?
init() {
// 5. ALWAYS start the transaction listener before any purchase,
// at app launch, so renewals/Ask-to-Buy/refunds are not missed.
updates = Task.detached { [weak self] in
for await update in Transaction.updates {
await self?.handle(verification: update)
}
}
}
deinit { updates?.cancel() }
// 1. Load products
func loadProducts(ids: [String]) async {
do {
products = try await Product.products(for: ids)
} catch {
// network/StoreKit error — surface, allow retry
}
}
// 2 + 3. Purchase and verify
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification) // 3
await updateEntitlements()
await transaction.finish() // REQUIRED
return transaction
case .userCancelled:
return nil
case .pending:
return nil // Ask-to-Buy / SCA — resolved later via Transaction.updates
@unknown default:
return nil
}
}
// 3. Verification — the ONLY trustworthy source of truth
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error): throw error // do NOT grant entitlement
case .verified(let safe): return safe
}
}
private func handle(verification: VerificationResult<Transaction>) async {
guard let transaction = try? checkVerified(verification) else { return }
await updateEntitlements()
await transaction.finish()
}
// 4. Current entitlements — the source of truth for "what is unlocked"
func updateEntitlements() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
guard let transaction = try? checkVerified(result) else { continue }
if transaction.revocationDate == nil { // not refunded/revoked
owned.insert(transaction.productID)
}
}
purchasedProductIDs = owned
}
}
The non-negotiables
- Always
finish()a transaction. Unfinished consumables/transactions are redelivered forever viaTransaction.updates. - Entitlement =
Transaction.currentEntitlements, not a local bool. Recompute on launch and on everyTransaction.updatesevent. RespectrevocationDate(refunds) and, for subscriptions, expiry. - Verify before granting.
.unverifiedmeans a jailbreak/tamper — deny. - Start the listener at launch, not when the paywall opens.
Subscriptions specifics
- Status: use
Product.SubscriptionInfo.Statusviaproduct.subscription?.status(orProduct.SubscriptionInfo.status(for: groupID)) for the current state including grace period and billing retry — richer thancurrentEntitlementsalone. - Renewal info:
status.renewalInfo(verify it) tells you auto-renew on/off, the next renewal product (upgrade/downgrade/crossgrade pending), and expiration reason. - Free trials / intro offers: check eligibility with
product.subscription?.isEligibleForIntroOffer. Promotional/win-back offers are passed viapurchase(options:)with.promotionalOffer(...). - Grace period & billing retry: keep access during
.inGracePeriodand.inBillingRetryPeriod— revoking immediately on a failed renewal churns paying users. - Subscription groups: one active subscription per group; upgrades/downgrades are crossgrades within the group, handled by the App Store, surfaced via
Transaction.updates.
SwiftUI StoreKit views (iOS 17+) — less code, fewer bugs
For most paywalls, the declarative views handle fetch, purchase, and loading states for you:
import StoreKit
// Whole subscription group with Apple-managed layout + trial eligibility
SubscriptionStoreView(groupID: "ABCDEF12") {
MyMarketingHeader() // your content above the controls
}
.subscriptionStoreButtonLabel(.multiline)
.storeButton(.visible, for: .restorePurchases)
// A single non-consumable
ProductView(id: "com.app.pro")
// A curated set
StoreView(ids: ["com.app.pro", "com.app.coins"])
Hook entitlement changes with .onInAppPurchaseCompletion { product, result in … } or observe your Store. These views still require the launch-time Transaction.updates listener for renewals.
Restore purchases
- Provide a visible Restore Purchases button (App Review requires it for non-consumables/subscriptions).
- Restore = re-evaluate
Transaction.currentEntitlements(it's already synced). Only callAppStore.sync()if the user explicitly taps Restore and entitlements look empty — it can prompt for App Store auth, so never call it automatically on launch.
Asking for a review (adjacent, commonly needed)
import StoreKit
@Environment(\.requestReview) private var requestReview // iOS 16+
// call requestReview() at a delight moment, throttled — Apple caps prompts to 3/year
See asc-aso for when to prompt (conversion impact).
Testing StoreKit
- Local, no sandbox: add a StoreKit Configuration file (
.storekit) to the scheme. Lets you test purchases, trials, renewals (accelerated time), Ask-to-Buy, and refunds entirely on-device/simulator. Transactiontest API: inswift-testing/XCTest useTransaction.currentEntitlementsagainst the config file; accelerate subscription renewals in the.storekiteditor.- Sandbox (real ASC): create Sandbox Apple IDs in App Store Connect → Users and Access → Sandbox. Subscriptions renew on an accelerated real-time schedule.
- Test the unhappy paths explicitly:
userCancelled,pending(Ask-to-Buy),.unverified, refund (revocationDate), expired subscription, grace period.
See swift-testing for the test-authoring patterns and ios-test for the harness.
Common bugs this skill prevents
| Symptom | Cause | Fix |
|---|---|---|
| Subscription "lost" after renewal | No Transaction.updates listener | Start it at launch; recompute entitlements on each event |
| Purchases redelivered every launch | Transaction never finish()ed | finish() after granting entitlement |
| Refunded users keep access | Ignoring revocationDate | Exclude revoked transactions from entitlements |
| Paying users churned on a card glitch | Revoking on first failed renewal | Honor .inGracePeriod / .inBillingRetryPeriod |
| "Works in sandbox, fails for some users" | Trusting .unverified or local bools | Use VerificationResult + currentEntitlements |
| Free trial offered to ex-subscribers | Not checking isEligibleForIntroOffer | Gate the trial on eligibility |