DEV Community

孫昊
孫昊

Posted on

Why I picked one-time IAP over subscription for 4 indie iOS apps (StoreKit 2 + 2026 data)

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Continuous server cost (AI inference, cloud storage, sync)
  2. Daily habitual usage (meditation, fitness, language learning)
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

A few notes on what's important:

  1. Transaction.updates listener task is mandatory — pending purchases (parental approval, family sharing) only resolve here, not in the .purchase() call site
  2. refreshEntitlements() iterates Transaction.currentEntitlements instead of querying receipts directly — StoreKit 2's modern path
  3. AppStore.sync() for restore — required by Apple review (Guideline 3.1.1)
  4. @MainActor + @Observable — clean SwiftUI integration, no ObservableObject legacy

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
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Tags

#ios #swift #indie #monetization #storekit #appstore #swiftui


A/B subject candidates

  1. "Why I picked one-time IAP over subscription for 4 indie iOS apps (StoreKit 2 + 2026 data)"
  2. "I shipped 4 iOS apps. Subscription was wrong for utility apps. Here's the math."
  3. "StoreKit 2 + freemium one-time IAP: 90-line wiring + 5 paywall triggers + 2026 conversion data"

Top comments (0)