StoreKit 2 Without RevenueCat: An Indie Production Guide#
When I built monetization for MeteoHealth — a weather-sensitivity health app with a freemium model — the first question wasn't "how do I write the code," it was "do I even need a middleman between my app and the App Store." Base features in MeteoHealth are free, and the Pro subscription starting at $2.99/mo unlocks advanced analytics and forecasts. The industry default for this problem is to wire up RevenueCat in half an hour and stop thinking about the details. I deliberately took a different path and have run pure StoreKit 2 in production for six months. This article isn't a theoretical API walkthrough — it's a report on what that decision actually cost and paid off: what turned out simpler than expected, what needed real attention, and where I'd tell you not to follow my lead.
Why I didn't wire up RevenueCat#
"RevenueCat is free up to $2,500/mo in revenue" is true, and for a lot of indies that settles the question right there. But three things about MeteoHealth made pure StoreKit 2 the more rational call:
- Single platform. The app is iOS-only, no Android, no web. RevenueCat's core value — a single abstraction layer over the App Store and Google Play — simply doesn't apply when there's nothing to abstract over.
- One subscription, one group. The monetization model is simple: a free tier and a single Pro level. No pricing matrix, no regional price experiments that would justify a separate dashboard.
- Control over entitlement logic. I wanted the check that gates Pro features to live in my own code, not inside a third-party SDK's black box that can change shape in a major update.
It's worth separating fact from opinion here. Fact: StoreKit 2 genuinely covers 100% of what a single-subscription, single-platform iOS app needs — purchasing, restoring, status checks, offer codes. Opinion: if MeteoHealth had three pricing tiers, a regional paywall experiment, and Android plans on the roadmap, I'd reach for RevenueCat without hesitation — and I'll explain why later on.
Architecture: Product, Transaction, and currentEntitlements#
The core of subscription logic in StoreKit 2 isn't UI or App Store Connect — it's three entities: Product (the item description, fetched from the store), Transaction (proof of purchase, cryptographically signed by Apple), and Transaction.currentEntitlements (the current set of active user rights at this moment, with no manual caching required).
I bundled all of this into a single @MainActor class that lives for the app's lifetime and listens for transaction changes in the background:
import StoreKit
@MainActor
final class SubscriptionManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs = ["pro.monthly", "pro.yearly"]
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = observeTransactionUpdates()
Task {
await loadProducts()
await refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
func refreshEntitlements() async {
var active: Set<String> = []
for await result in Transaction.currentEntitlements {
guard let transaction = try? checkVerified(result) else { continue }
if transaction.revocationDate == nil {
active.insert(transaction.productID)
}
}
purchasedProductIDs = active
}
private func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let transaction = try? self?.checkVerified(result) else { continue }
await transaction.finish()
await self?.refreshEntitlements()
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case failedVerification
}The one thing that burned me: Transaction.currentEntitlements is an async sequence, not a snapshot. Call refreshEntitlements() before StoreKit has finished syncing with the App Store after a cold launch, and you can briefly get an empty entitlement set and show a paywall to a paying subscriber. The fix isn't a single call at startup — it's keeping a Task listening on Transaction.updates alive for the app's entire lifetime, as in the code above.
Checking subscription status: grace periods and billing retry#
The most common mistake in homegrown implementations is checking only .subscribed and cutting access for every other state. That breaks the grace period and billing retry — states Apple specifically designed so you don't lose paying users over an expired card.
Product.SubscriptionInfo.Status gives you the detail you need:
extension SubscriptionManager {
/// Returns true if the user should keep Pro access,
/// including grace period and billing retry — not just `.subscribed`.
func hasActiveProAccess(for group: Product.SubscriptionInfo?) async -> Bool {
guard let statuses = try? await group?.status else { return false }
for status in statuses {
switch status.state {
case .subscribed, .inGracePeriod:
return true
case .inBillingRetryPeriod:
// Apple is still retrying the charge — don't cut access yet.
return true
case .expired, .revoked:
continue
default:
continue
}
}
return false
}
}For MeteoHealth this wasn't a theoretical edge case: users with expired or reissued cards aren't rare, and the difference between "show a billing warning" and "instantly disable Pro analytics" directly affects retention. Since the app has no backend for subscriptions, all of this logic lives on-device, with no server that would need to re-validate receipts. For a single subscription and a single tier, that's a fair trade-off: less infrastructure, fewer points of failure.
SubscriptionStoreView: a paywall without your own UI layer#
Before iOS 17, you built a paywall by hand: your own layout, your own loading states, your own purchase handler. SubscriptionStoreView removes most of that boilerplate — pass it a subscription group ID and StoreKit handles the rest (prices, localization, restore, loading state):
struct PaywallView: View {
let groupID: String
var body: some View {
SubscriptionStoreView(groupID: groupID) {
VStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.largeTitle)
Text("Unlock Pro Forecasts")
.font(.title2.bold())
Text("Advanced analytics and extended forecasts for $2.99/mo")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreControlStyle(.prominentPicker)
.onInAppPurchaseCompletion { product, result in
if case .success(.success) = result {
// refresh entitlements, dismiss paywall, etc.
}
}
}
}I didn't drop SubscriptionStoreView entirely — I used it as the base scaffold and passed marketing content (icon, headline, Pro feature copy) through the closure above. The one limitation I ran into: tier-card layout customization is limited to the built-in subscriptionStoreControlStyle set, and for a genuinely custom paywall (complex feature-comparison tables, animations) you still have to build the view from scratch on top of Product and .task(id:).
Win-back offers: bringing lapsed subscribers back#
Starting with iOS 18, Apple added win-back offers — offers aimed at users whose subscription has already lapsed, not at active subscribers. It's a distinct offer type, configured in App Store Connect for a specific subscription, and unlike promotional offers, it's only available to former subscribers.
extension SubscriptionManager {
func availableWinBackOffer(for product: Product) async -> Product.SubscriptionOffer? {
guard let status = try? await product.subscription?.status.first,
case .expired = status.state else { return nil }
let eligible = await product.subscription?.eligibleWinBackOffers ?? []
return eligible.first
}
func redeem(_ offer: Product.SubscriptionOffer, for product: Product) async throws {
let result = try await product.purchase(options: [.winBackOffer(offer)])
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
}
}
}For a freemium app like MeteoHealth, this closes a specific scenario: a user subscribed to Pro for a month to check a forecast before a trip, cancelled, and six months later sees a discounted comeback offer inside the app — without me having to write and maintain my own retention machinery. The catch: the offer has to be configured in App Store Connect ahead of time, or eligibleWinBackOffers always returns an empty array.
StoreKit Testing in Xcode: testing without real money#
One of the most underrated parts of StoreKit 2 isn't the API — it's the tooling. A .storekit configuration file in Xcode lets you describe products, subscription groups, promotional offers, and win-back offers locally, with zero trips to App Store Connect and no real payments.
What I actually use from this toolkit:
- StoreKit Configuration File — a local product description; the StoreKit schema syncs with this file when running in the simulator or on a device via Xcode.
- Transaction Manager in Xcode — you can manually revoke a transaction to verify the app correctly reacts to a user refund.
- Simulating billing failures and retries right from the scheme settings — no need to wait for a real Apple ID card to expire to test
.inBillingRetryPeriodhandling. - Testing win-back offers locally — configure an offer in the
.storekitfile and immediately see how it's shown to an already-lapsed test account, no need to wait through a real subscription cycle.
The practical payoff: the entire pipeline — first purchase through cancellation, grace period, and win-back offer — can be exercised in a single day in the simulator, without sending a single real dollar to the App Store.
StoreKit 2 vs RevenueCat: when I'd still recommend RevenueCat#
This is the part where I owe you honesty rather than a "correct" answer sold as universal. Pure StoreKit 2 suited MeteoHealth precisely because of its simple monetization model and iOS-only architecture. That's not a blanket recommendation.
| Criterion | StoreKit 2 (native) | RevenueCat |
|---|---|---|
| Cost | Always free | Free up to $2,500/mo revenue, then a revenue share |
| Cross-platform (iOS + Android + Web) | Build separately for each platform | Single API and dashboard for all |
| Time to first subscription live | A couple of days (Product, Transaction, entitlements) | A couple of hours |
| MRR, churn, conversion analytics | Build it yourself | Ready-made dashboard out of the box |
| Paywall A/B testing | Manual experiment implementation | Built-in experiments |
| Server-side sync (webhooks) | App Store Server Notifications by hand | Managed webhooks |
| Control over logic and API updates | Full, dependent only on Apple | Dependent on SDK releases |
I'd recommend RevenueCat if you have: (1) a product across multiple platforms and don't want to duplicate subscription logic; (2) several pricing tiers and plans to experiment with prices and offers more often than once a quarter; (3) a team without deep StoreKit expertise for whom shipping speed matters more than fine-grained control; (4) revenue below the $2,500/mo threshold where RevenueCat is still free — in which case there's no savings from building it yourself, you're just spending time for nothing. None of those applied to MeteoHealth, so pure StoreKit 2 stays in production — but I'll revisit that decision the day a second platform shows up.
Looking back, I'd have adopted App Store Server Notifications v2 sooner — even without a backend of my own, a simple serverless notification handler closes gaps that are only visible client-side with a delay (a bank-initiated chargeback dispute, for instance). None of that changes the article's conclusion: for a single-tier iOS app, StoreKit 2 without a middleman is a sound, defensible architectural call — as long as you're willing to learn the subscription states once instead of delegating that understanding to a third-party SDK. The time spent learning Transaction, currentEntitlements, and SubscriptionStoreView pays for itself in knowing exactly why a given user does or doesn't see Pro features — and that knowledge won't evaporate with the next major iOS update.



