StoreKit 2 без RevenueCat: продакшен-гайд инди-разработчика#
Когда я делал монетизацию для MeteoHealth — метеозависимого health-приложения с freemium-моделью — первый вопрос был не «как писать код», а «нужен ли вообще посредник между приложением и App Store». Базовые функции в MeteoHealth бесплатны, а Pro-подписка от $2.99/мес открывает расширенную аналитику и прогнозы. Индустриальный дефолт для такой задачи — подключить RevenueCat за полчаса и не думать о деталях. Я сознательно пошёл другим путём и полгода прожил с чистым StoreKit 2 в проде. Эта статья — не теоретический разбор API, а отчёт о том, что из этого решения получилось: что оказалось проще, чем ожидалось, что потребовало отдельного внимания, и в каких ситуациях я бы всё-таки посоветовал не повторять мой путь.
Почему я не подключил RevenueCat#
Аргумент «RevenueCat бесплатен до $2500/мес выручки» — правда, и для многих инди это закрывает вопрос сразу. Но у MeteoHealth три обстоятельства делали чистый StoreKit 2 более рациональным выбором:
- Одна платформа. Приложение только под iOS, без Android и веб-версии. Главная ценность RevenueCat — единый слой абстракции над App Store и Google Play — здесь просто не работает: абстрагировать нечего.
- Одна подписка, одна группа. Модель монетизации простая: free-тир и один Pro-уровень. Никакой матрицы тарифов, никаких региональных экспериментов с ценами, которые оправдывали бы отдельный дашборд.
- Контроль над логикой entitlement. Я хотел, чтобы проверка доступа к Pro-функциям была частью моего кода, а не чёрным ящиком стороннего SDK, который может измениться в мажорном апдейте.
Здесь важно разделить факт и мнение. Факт: StoreKit 2 действительно закрывает 100% того, что нужно однотипному iOS-приложению с одной подпиской — покупку, восстановление, проверку статуса, offer-коды. Мнение: если бы у MeteoHealth было три тарифа, региональный пейволл-эксперимент и планы на Android — я бы взял RevenueCat не задумываясь, и ниже объясню почему.
Архитектура: Product, Transaction и currentEntitlements#
Ядро подписочной логики в StoreKit 2 — не UI и не App Store Connect, а три сущности: Product (описание товара, приходит из стора), Transaction (факт покупки, криптографически подписан Apple) и Transaction.currentEntitlements (актуальный набор действующих прав пользователя на этот момент, без ручного кэширования).
Я собрал всё это в один @MainActor-класс, который живёт всё время работы приложения и слушает изменения транзакций в фоне:
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
}Ключевой момент, на котором я один раз обжёгся: Transaction.currentEntitlements — это асинхронная последовательность, а не снапшот. Если вызвать refreshEntitlements() до того, как StoreKit успел синхронизироваться с App Store после холодного старта, можно на долю секунды получить пустой набор прав и показать пейволл подписчику. Решение — не полагаться на единственный вызов при запуске, а держать Task на Transaction.updates живым всё время работы приложения, как в коде выше.
Проверка статуса подписки: грейс-период и billing retry#
Самая частая ошибка в самодельных реализациях — проверять только .subscribed и резать доступ всем остальным состояниям. Это ломает грейс-период и billing retry — состояния, специально придуманные Apple, чтобы не терять платящих пользователей из-за просроченной карты.
Product.SubscriptionInfo.Status даёт нужную детализацию:
extension SubscriptionManager {
/// Возвращает true, если пользователь должен сохранить доступ к Pro —
/// включая грейс-период и billing retry, а не только `.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 всё ещё пытается провести платёж — не резать доступ раньше времени.
return true
case .expired, .revoked:
continue
default:
continue
}
}
return false
}
}Для MeteoHealth это оказалось не теоретическим кейсом: пользователи с истёкшими или перевыпущенными картами — не редкость, и разница между «показать предупреждение о проблеме с оплатой» и «мгновенно отключить Pro-аналитику» напрямую влияет на retention. Поскольку у приложения нет собственного бэкенда для подписок — вся логика живёт на устройстве, без сервера, которому нужно было бы валидировать чеки повторно. Для одной подписки и одного тарифа это честный компромисс: меньше инфраструктуры, меньше точек отказа.
SubscriptionStoreView: пейволл без своего UI-слоя#
До iOS 17 паяволл приходилось собирать руками: своя вёрстка, свои состояния загрузки, свой обработчик покупки. SubscriptionStoreView убирает большую часть этой рутины — достаточно передать ID группы подписок, а всё остальное (цены, локализация, restore, состояние загрузки) StoreKit берёт на себя:
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 {
// обновить entitlements, закрыть пейволл и т.д.
}
}
}
}Я не отказался от SubscriptionStoreView полностью — использовал его как базовый каркас, а маркетинговый контент (иконку, заголовок, описание Pro-функций) прокинул через closure выше. Единственное ограничение, с которым я столкнулся: кастомизация макета карточек тарифов ограничена набором готовых subscriptionStoreControlStyle, и для по-настоящему нестандартного пейволла (сложные сравнительные таблицы фич, анимации) всё равно придётся собирать вью с нуля поверх Product и .task(id:).
Win-back offers: возвращаем ушедших подписчиков#
С iOS 18 Apple добавила win-back offers — предложения для пользователей, у которых подписка уже истекла, а не для тех, кто ещё активен. Это отдельный тип оффера, настраивается в App Store Connect для конкретной подписки, и в отличие от promotional offers доступен именно бывшим подписчикам.
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()
}
}
}Для freemium-приложения вроде MeteoHealth это закрывает конкретный сценарий: пользователь оформил Pro на месяц ради прогноза перед поездкой, отписался — и через полгода видит на экране приложения предложение вернуться со скидкой, без необходимости писать и поддерживать собственную ретеншн-механику. Условие для показа — offer должен быть настроен в App Store Connect заранее; без этого eligibleWinBackOffers всегда вернёт пустой массив.
StoreKit Testing в Xcode: тестирование без реальных денег#
Один из самых недооценённых кусков StoreKit 2 — не API, а инструментарий. .storekit-конфигурация в Xcode позволяет описать продукты, подписочные группы, промо-офферы и win-back офферы локально, без единого захода в App Store Connect и без реальных платежей.
Что я реально использую из этого набора:
- StoreKit Configuration File — локальное описание продуктов; схема StoreKit синхронизируется с этим файлом при запуске в симуляторе или на устройстве через Xcode.
- Transaction Manager в Xcode — можно вручную аннулировать транзакцию, чтобы проверить, что приложение корректно реагирует на возврат оплаты пользователю.
- Симуляция сбоев оплаты и билинг-ретраев прямо из настроек схемы — не нужно ждать, пока реальная карта Apple ID «протухнет», чтобы проверить обработку
.inBillingRetryPeriod. - Тестирование win-back офферов локально — можно настроить оффер в
.storekit-файле и сразу увидеть, как он показывается уже отписавшемуся тестовому аккаунту, без ожидания реального цикла подписки.
Практический вывод: весь пайплайн — от первой покупки до отмены, грейс-периода и win-back-предложения — можно прогнать за один день работы в симуляторе, не отправляя ни одного реального доллара в App Store.
StoreKit 2 vs RevenueCat: когда я всё же посоветую RevenueCat#
Здесь стоит быть честным, а не продавать читателю «правильный» выбор. Чистый StoreKit 2 подошёл MeteoHealth именно из-за простоты модели монетизации и iOS-only архитектуры. Но это не универсальный совет.
| Критерий | StoreKit 2 (нативно) | RevenueCat |
|---|---|---|
| Стоимость | Бесплатно всегда | Бесплатно до $2500/мес выручки, дальше % от revenue |
| Кросс-платформенность (iOS + Android + Web) | Нужно писать отдельно под каждую платформу | Единый API и дашборд для всех |
| Время интеграции первой подписки | От пары дней (Product, Transaction, entitlements) | От пары часов |
| Аналитика по MRR, churn, конверсии | Нужно строить самому | Готовый дашборд из коробки |
| A/B-тестирование пейволлов | Ручная реализация экспериментов | Встроенные эксперименты |
| Server-side синхронизация (webhooks) | App Store Server Notifications вручную | Managed webhooks |
| Контроль над логикой и обновлениями API | Полный, зависит только от Apple | Зависит от обновлений SDK |
Я бы посоветовал RevenueCat, если у вас: (1) продукт на нескольких платформах и не хочется дублировать логику подписок; (2) несколько тарифов и планы экспериментировать с ценами и офферами чаще, чем раз в квартал; (3) команда без глубокой экспертизы в StoreKit, которой важнее скорость выхода в прод, чем контроль над деталями; (4) выручка ниже порога в $2500/мес, при котором RevenueCat всё ещё бесплатен — тогда экономии на «своей» разработке попросту нет, вы платите временем впустую. Для MeteoHealth ни один из этих пунктов не сработал, поэтому чистый StoreKit 2 остаётся в проде — но я пересмотрю решение в тот день, когда появится вторая платформа.
Оглядываясь назад, я бы раньше начал использовать App Store Server Notifications v2 — даже без собственного бэкенда, простой serverless-обработчик уведомлений закрывает часть кейсов, которые на клиенте видны с задержкой (например, оспаривание платежа через банк). Это не меняет вывод статьи: для однотарифного iOS-приложения StoreKit 2 без посредников — рабочее и оправданное архитектурное решение, если вы готовы один раз разобраться в состояниях подписки вместо того, чтобы делегировать это стороннему SDK. Время, потраченное на изучение Transaction, currentEntitlements и SubscriptionStoreView, окупается тем, что вы полностью понимаете, почему конкретный пользователь видит или не видит Pro-функции — и это понимание никуда не денется при следующем мажорном апдейте iOS.



