A lightweight Swift framework for in-app purchases using StoreKit 2
with full support for Swift Concurrency, async/await, and SPM.
Add this URL to your Xcode project:
https://github.com/Ramiz69/PurchaseKit.git
In Package.swift:
.package(url: "https://github.com/Ramiz69/PurchaseKit.git", from: "4.1.0")
- async/await API
- actor-based PurchasesManager
- Typed errors: StoreKit failures arrive as
PurchasesErrorcodes (networkUnavailable,purchaseNotAllowed, …), matched withcatch PurchasesError.purchaseCancelled - Purchase options: account token, quantity, promotional offers (server-signed JWS), win-back offers
- Subscription status: renewal info, grace period and billing retry, intro-offer eligibility, live status updates
- Dated entitlements:
currentEntitlement(for:)returns the transaction behind an entitlement, e.g. to compute when a trial ends - Subscription bundles and suites (
ProductType.isAutoRenewableSubscription) - Server-verified purchases with manual finishing
- App purchase info (
AppTransaction) for paid-to-free migrations - Every model has a public initializer, so
PurchasesProtocolis fully mockable - DocC documentation
- Support for iOS, macOS, Mac Catalyst, tvOS, watchOS, visionOS (26 and later)
let purchases = PurchasesManager.shared
if let status = try await purchases.subscriptionStatus(inGroup: "com.myapp.subscriptions.premium") {
switch status.state {
case .inGracePeriod, .inBillingRetryPeriod:
showUpdatePaymentBanner() // the renewal payment failed
case .subscribed where !status.willAutoRenew:
showResubscribeHint() // turned off auto-renew
default:
break
}
}
Task {
for await status in purchases.subscriptionStatusUpdates {
await model.apply(status) // renewals, expirations, plan changes
}
}Before showing a free trial, ask isEligibleForIntroOffer(inGroup:). Promotional offers are
signed on your server and passed in PurchaseOptions(promotionalOffer:); win-back offers come
from StoreProduct.winBackOffers and need no signature.
If your server credits purchases to an account, finish transactions only after it has accepted them - a finished consumable is never redelivered:
PurchasesManager.configure(identifiers: ["com.myapp.coins.100"], finishing: .manual)
let (_, transaction) = try await PurchasesManager.shared
.purchase(productID: "com.myapp.coins.100", appAccountToken: user.id)
try await api.creditPurchase(signed: transaction.jwsRepresentation!)
await PurchasesManager.shared.finish(transaction)Also handle transactionUpdates (Ask to Buy, pending payments, replays at launch) and drain
unfinishedTransactions() at launch. Everything here is additive: the default stays
.automatic, and existing code behaves exactly as before.
StoreKit has no scene-less purchase call on visionOS, so purchase(productID:) is not available there. Use purchase(productID:confirmIn:) and pass the scene the customer is interacting with:
let result = try await PurchasesManager.shared.purchase(productID: "pro.monthly", confirmIn: scene)In SwiftUI, StoreKit's PurchaseAction from the environment resolves the scene for you.
Call sites mostly compile unchanged. What breaks:
ProductTypeis a struct with StoreKit's raw values, so a kind Apple adds later is no longer a breaking change. Aswitchover it needs adefaultbranch;ProductType.unknownis gone (an unfamiliar kind keeps its raw value instead);subscriptionBundleandsubscriptionSuiteare new.PurchasesErroris a struct with acode, likeCocoaError.catch PurchasesError.purchaseCancelledstill works; read details withcatch let error as PurchasesError where error.code == .invalidProductIDanderror.productID/error.offerID/error.underlyingError. Aswitchgoes overerror.codeand needs adefaultbranch. Throw it asPurchasesError(.invalidProductID, productID: id).- StoreKit errors are now
PurchasesError. Acatch let error as StoreKitErrorno longer matches; catch the code instead (.networkUnavailable,.purchaseNotAllowed, …). The StoreKit error is kept asunderlyingError. StoreTransaction(…)requiresproductType:afterproductID:.PurchasesProtocolmocks implementpurchase(productID:options:)instead ofpurchase(productID:), plussubscriptionStatuses(inGroup:),isEligibleForIntroOffer(inGroup:)andappPurchaseInfo().ServerVerifiedPurchasesProtocolno longer requirespurchase(productID:appAccountToken:); both short forms are extension methods now.StoreProduct(…)requirescurrencyCode:afterdisplayPrice:.activeSubscriptions()now includes subscription bundles and suites.
The full guide with before/after code: Migrating to PurchaseKit 4.
From 2.0 — one change affects you:
purchase(productID:)returns aStoreTransactioninstead of a rawStoreKit.Transaction. The fields you were reading are on it under the same names, and the raw value is still available as.transaction.StoreKit.Transactionhas no public initializer, so whilepurchasereturned one, a stand-in implementation could only throw and never represent a successful purchase — which defeated the point of having a protocol to mock.
From 1.x — everything in 2.0 applies as well:
- Platforms now start at iOS/macOS/tvOS/watchOS/visionOS 26. Earlier releases are no longer supported; stay on 1.0.6 if you need them.
StoreProduct.productis now optional. It isnilon values you build yourself, becauseStoreKit.Productcannot be constructed outside StoreKit. There is now a public initializer taking the fields directly, soPurchasesProtocolcan be mocked in tests and previews.ProductTypegainedunknownandPurchasesErrorgainedunhandledPurchaseResult. Exhaustiveswitchstatements over either need a new branch. A StoreKit product type this SDK does not recognise now reportsunknowninstead of silently passing asnonConsumable.purchasedProductsno longer replays past events. Every access returns a fresh stream, so several observers can watch at once — previously twofor awaitloops split the events between them and each missed the rest. Read current state withhasEntitlement(for:)orrequestProducts(includingCache:)when a subscriber starts.requestProducts()with no arguments is now a distinct overload onPurchasesProtocol. Calls are unchanged; only a conformer that relied on the default implementation is affected, and that case no longer compiles rather than recursing forever at runtime.PurchasesErrornow conforms toEquatableandLocalizedError, andPurchasesManager.resolved()returns the singleton by throwingnotConfiguredinstead of trapping likeshared.
Examples/PaywallDemo is a SwiftUI paywall that reacts to every subscription state the App Store reports: subscribed, grace period, billing retry, expired (with the reason) and revoked. Open PaywallDemo.xcodeproj and press Run; the scheme loads a local StoreKit configuration, so no App Store Connect setup is needed. A demo-mode menu switches the same screen to a PurchasesProtocol mock for each state.
- 🧭 Online documentation →
- 🛠 In Xcode:
Product > Build Documentation
MIT © Ramiz Kichibekov