dev.to article #26 (paste-ready) — Why I picked one-time IAP over subscription for 4 indie iOS apps (StoreKit 2 + 2026 data)
Calendar: 2026-05-22 09:00 PT
Tags:#ios #swift #indie #monetization
Cover image: 1000x420 — 4 app icons + a $2.99 price tag
Word count: 1800 (technical angle, more code than Substack version)
Series: Part of "Day 60 indie iOS dev" series
TL;DR
I shipped 4 indie iOS utility apps. Conventional wisdom in 2026 says go subscription. I went freemium + one-time IAP. The 2026 conversion data + Apple tax math show one-time wins for utility apps with occasional value delivery.
This post: the conversion math, the StoreKit 2 wiring, the paywall trigger logic, and what I'm tracking.
The conversion gap most devs miss
Adapty 2026 benchmark, indie segment:
| Model | Median conversion |
|---|---|
| Hard paywall | 12.11% |
| Freemium + subscription | 2.18% |
5x. Counter-intuitive but consistent across genres.
Hypothesis: friction-resolved decisions convert better than gradient try-then-pay flows. When users hit a paywall, they decide once. When they're in a freemium funnel, they defer indefinitely.
For utility apps specifically (Adapty), download → trial rate is 24%, meaning users actually engage paywalls.
The Apple tax math people forget
Looking only at headline pricing makes subscription look superior:
$2.99 one-time → user pays $2.99
$1.99/month → user pays $23.88/year (8x)
But run the actual unit economics:
$2.99 one-time:
Apple cuts 30% → dev gets $2.09
Net per converting user: $2.09
$1.99/month:
Apple cuts 30% Y1 → dev gets $16.72/yr
But indie retention is 30-50% in Y1 → effective $6.69
Y2 cut drops to 15% → $20.30/yr — but only 15-20% of original users last to Y2
Combine with the 5x conversion gap:
Per 1000 downloads:
One-time: 1000 × 12% × $2.99 × 0.7 = $251
Subscription: 1000 × 2.18% × $1.99 × 5 × 0.7 = $152
One-time wins by $99 per 1000 downloads, before counting churn-related ops cost on subscription.
When subscription actually wins
Three conditions, all must hold:
- Continuous server cost (AI inference, cloud storage, sync)
- Daily habitual usage (meditation, fitness, language learning)
- You have LiveOps capacity (content drops, retention campaigns)
For most indie utility apps, none apply. Defaulting to subscription is fighting your own product against the conversion data.
My 4-app pricing matrix
AutoChoice (decision wheel) → Free + $2.99 one-time
AltitudeNow (altimeter) → Free + $1.99 one-time
DaysUntil (countdown widget) → Free + dual track
($4.99 once OR $0.99/month)
PromptVault (prompt manager) → TBD (likely subscription, AI cost)
DaysUntil splits user behavior:
- 80% set 5-10 events, view passively → one-time fits
- 15% are heavy users with 30+ events → subscription captures their willingness
StoreKit 2 wiring (~90 lines per app, identical across apps)
import Foundation
import StoreKit
import Observation
@MainActor
@Observable
final class IAPManager {
static let premiumProductID = "com.jiejuefuyou.<app>.premium"
var isPremium: Bool = false
var products: [Product] = []
var purchaseInProgress: Bool = false
var lastError: String?
private nonisolated(unsafe) var listenerTask: Task<Void, Never>?
init() {
listenerTask = Task { [weak self] in
for await update in Transaction.updates {
guard case .verified(let t) = update else { continue }
await t.finish()
await self?.refreshEntitlements()
}
}
}
deinit { listenerTask?.cancel() }
func loadProducts() async {
do {
products = try await Product.products(for: [Self.premiumProductID])
} catch {
lastError = error.localizedDescription
}
}
func purchase() async {
guard let product = products.first else {
await loadProducts()
return
}
purchaseInProgress = true
defer { purchaseInProgress = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let t) = verification {
await t.finish()
}
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
lastError = error.localizedDescription
}
}
func restore() async {
do {
try await AppStore.sync()
} catch {
lastError = error.localizedDescription
}
await refreshEntitlements()
}
private func refreshEntitlements() async {
var entitled = false
for await result in Transaction.currentEntitlements {
if case .verified(let t) = result,
t.productID == Self.premiumProductID,
t.revocationDate == nil {
entitled = true
}
}
isPremium = entitled
}
}
A few notes on what's important:
-
Transaction.updateslistener task is mandatory — pending purchases (parental approval, family sharing) only resolve here, not in the.purchase()call site -
refreshEntitlements()iteratesTransaction.currentEntitlementsinstead of querying receipts directly — StoreKit 2's modern path -
AppStore.sync()for restore — required by Apple review (Guideline 3.1.1) -
@MainActor + @Observable— clean SwiftUI integration, noObservableObjectlegacy
PaywallView trigger strategy
5 trigger moments, ordered by conversion likelihood:
// 1. Free tier limit hit (highest conv)
if store.activeList?.choices.count >= 5 && !iap.isPremium {
Button("Add more (Premium)") { showPaywall = true }
}
// 2. Premium-only feature requested
.disabled(!iap.isPremium && requiresPremium)
.overlay {
if !iap.isPremium && requiresPremium {
Image(systemName: "lock.fill").foregroundStyle(.secondary)
}
}
// 3. Settings passive entry point
Section {
Button {
showPaywall = true
} label: {
HStack {
Label("AutoChoice Premium", systemImage: "sparkles")
Spacer()
Text(iap.isPremium ? "Unlocked" : "$2.99")
.foregroundStyle(.secondary)
}
}
}
// 4. Beyond-free-window data access
let cutoff = Date().addingTimeInterval(-7 * 24 * 3600)
let visible = iap.isPremium ? store.history : store.history.filter { $0.date > cutoff }
// 5. Onboarding screen 3 (preview, not forced)
OnboardingScreen(
title: "Premium features",
subtitle: "Unlock infinitely. One-time $2.99.",
isPaywallPreview: true
)
Don't:
- Show paywall on first launch (Apple Guideline 4.0 reject risk)
- Re-show on every launch (annoying)
- Lock all features (users can't experience value)
Anti-pattern: Apple subscription gotchas I'm avoiding
For DaysUntil's subscription side, Apple Guideline 3.1.2 requires:
- Auto-renewal disclosure ("Auto-renews monthly")
- Terms of Use link to Apple's stdEULA
- Privacy Policy link
- Cancellation instructions
VStack(spacing: 4) {
Text("Auto-renews monthly. Cancel anytime in Settings.")
.font(.caption2)
.foregroundStyle(.secondary)
HStack {
Link("Terms", destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!)
Text("·")
Link("Privacy", destination: URL(string: "https://jiejuefuyou.github.io/privacy.html")!)
}
.font(.caption2)
}
Skipping these is a common Guideline 3.1.2 reject.
ASC IAP setup checklist (per app)
For each app, in App Store Connect:
1. My Apps → <app> → Monetization → In-App Purchases
2. + Add → Non-Consumable
3. Reference Name: <app>_premium_unlock
4. Product ID: com.jiejuefuyou.<app>.premium ← MUST match Swift constant
5. Price tier: $2.99 / $1.99 / $4.99 (Tier 2/3/5)
6. Localizations: en-US, ja, zh-Hans (3 markets)
7. Review screenshot (paywall in simulator)
8. Review notes: "One-time non-consumable to unlock premium features"
9. Save → Ready to Submit
10. Submit with next binary
Common reject reasons:
- Product ID mismatch with code → can't load
- Description vague ("premium features") → must list features
- Restore Purchase missing → required by Guideline
- Subscription terms missing → 3.1.2 reject
What I'm tracking
Day 30 metrics:
- Conversion rate (target 5-12%)
- Restore Purchase rate (target 5-10%)
- Refund rate (target <2%)
- Cross-app cross-promo CTR (target 5-15%)
Day 90 metrics:
- DAU growth (organic + ASO)
- Per-app vs combined revenue
- Paywall trigger source distribution
If 5% conv holds, the model is right. If it's 2%, paywall trigger needs work.
TL;DR
- Adapty 2026 data: hard paywall converts 5x better than freemium subscription for utility apps
- Apple tax math: one-time wins after retention reality, not before
- StoreKit 2 makes one-time IAP nearly free to wire (~90 lines)
- 5 paywall trigger moments matter; first-launch is not one of them
- I'll publish Day 30 numbers in 30 days
If you've shipped indie iOS with one-time IAP, drop a comment with your conversion + restore rates. I'd compare.
Code samples available
The IAPManager.swift + PaywallView.swift used in all 4 apps:
https://github.com/jiejuefuyou/autoapp-ios-iap-template (link placeholder, ship after Day 30 review)
See also
- State of Subscription Apps 2026 — RevenueCat
- App Store Conversion Rate by Category 2026 — Adapty
- Apple StoreKit 2 — Apple Developer
Tags
#ios #swift #indie #monetization #storekit #appstore #swiftui
A/B subject candidates
- "Why I picked one-time IAP over subscription for 4 indie iOS apps (StoreKit 2 + 2026 data)"
- "I shipped 4 iOS apps. Subscription was wrong for utility apps. Here's the math."
- "StoreKit 2 + freemium one-time IAP: 90-line wiring + 5 paywall triggers + 2026 conversion data"
Top comments (0)