StoreKit 2 в Swift: подписки, транзакции и SubscriptionStoreView в iOS 26
Практический гайд по StoreKit 2 в Swift: загрузка продуктов, JWS-верификация транзакций, SubscriptionStoreView и серверная проверка через App Store Server API для iOS 26.
StoreKit 2 в Swift, это современный фреймворк Apple для покупок и подписок в приложениях, построенный на async/await, с проверкой транзакций через JWS и единым API для iOS 15+, macOS 12+, watchOS 8+, tvOS 15+ и visionOS 1+. В iOS 26 к нему добавились готовые SwiftUI-компоненты SubscriptionStoreView и StoreView, автоматические уведомления о статусе подписки и упрощённая работа с промо-офферами. Ниже полный практический гайд: от загрузки продуктов до серверной верификации.
StoreKit 2 полностью асинхронный: Product.products(for:), product.purchase(), Transaction.updates, всё через async.
Транзакции подписаны Apple как JWS (JSON Web Signature) и проверяются на устройстве через VerificationResult без внешних библиотек.
В iOS 17+ доступен SubscriptionStoreView: готовый SwiftUI-пейволл с автоматической подстройкой под iPad, Mac Catalyst и visionOS.
Для тестирования используйте StoreKit Configuration File. Покупки работают локально в Xcode без App Store Connect.
Серверная верификация нужна для критичного контента: используйте App Store Server API и подпись подписей ES256.
Слушатель Transaction.updates обязателен, иначе отложенные покупки (Ask to Buy, восстановление) не активируются.
Чем StoreKit 2 отличается от StoreKit 1
Оригинальный StoreKit (теперь его называют StoreKit 1 или Original API) построен вокруг SKPaymentQueue, SKProductsRequest и делегатов. Ответы приходят через callback-методы, транзакции хранятся в очереди платежей, и вы обязаны их вручную завершать через finishTransaction. Верификация покупок в StoreKit 1 требовала либо чтения бинарного receipt-файла, либо запроса к verifyReceipt на серверах Apple с оговоркой, что verifyReceipt устаревает, и Apple с 2024 года рекомендует переходить на App Store Server API.
StoreKit 2 переворачивает модель. Вместо делегатов используется async/await, вместо receipt-файла берутся подписанные JWS-транзакции, а вместо очереди работает реактивный поток Transaction.updates. Практическая разница на примере одной операции:
Задача
StoreKit 1
StoreKit 2
Минимальная версия
iOS 3.0
iOS 15.0 (visionOS 1, watchOS 8, macOS 12)
Модель API
Делегаты + очередь платежей
async/await + AsyncSequence
Верификация транзакции
Бинарный receipt + verifyReceipt
JWS-подпись, проверка на устройстве
SwiftUI-компоненты
Нет
SubscriptionStoreView, StoreView, ProductView
Активные подписки
Ручной парсинг receipt
Transaction.currentEntitlements
Тестирование
Только Sandbox
StoreKit Configuration File локально в Xcode
Восстановление покупок
restoreCompletedTransactions
AppStore.sync()
Честно говоря, у меня переход занимает примерно два-три дня на приложение среднего размера. Большая часть времени уходит не на новый API, а на аккуратное удаление старого receipt-парсера и отвязку от третьих библиотек вроде RevenueCat или SwiftyStoreKit, если они использовались только ради удобства.
Настройка продуктов и StoreKit Configuration File
Прежде чем писать код, определите продукты. Есть два пути: App Store Connect (для продакшена) и StoreKit Configuration File (для локальной разработки и unit-тестов). Я всегда рекомендую начинать с конфиг-файла: вы получите работающие покупки за пять минут без ожидания одобрения продуктов и без Sandbox-аккаунта.
В Xcode: File → New → File → StoreKit Configuration File. Выбирайте вариант «Synced with App Store Connect», если продукты уже созданы, или «Blank» для локальной разработки. Далее в схеме запуска: Edit Scheme → Run → Options → StoreKit Configuration, укажите файл.
// В коде идентификаторы продуктов лучше держать в enum,
// чтобы не размазывать сырые строки по проекту.
enum ProductID: String, CaseIterable {
case proMonthly = "com.swiftcrafted.pro.monthly"
case proYearly = "com.swiftcrafted.pro.yearly"
case tipSmall = "com.swiftcrafted.tip.small"
static var all: [String] { allCases.map(\.rawValue) }
}
Важный момент про идентификаторы. Они чувствительны к регистру и должны совпадать байт-в-байт с App Store Connect. Если продукт возвращается пустым, в 90% случаев это опечатка в bundle ID проекта или в идентификаторе продукта. StoreKit Configuration File это игнорирует, но продакшн-запрос упадёт. Я лично словил этот баг при первом шипе, разбор занял полдня.
Для подписок в конфиг-файле создайте Subscription Group. Одна подписка может принадлежать только одной группе, и пользователь одновременно активен в одной подписке из группы. Это база модели: месячный и годовой планы образуют одну группу, дополнительный «Family» тариф идёт во вторую.
Как загружать продукты через async/await
Загрузка продуктов в StoreKit 2, это одна строка. Никаких делегатов, никакого SKProductsRequest. Здесь пригодится понимание Swift 6 concurrency и структурированной конкурентности: весь фреймворк построен на этих примитивах.
import StoreKit
@Observable
final class StoreManager {
var products: [Product] = []
var purchasedProductIDs: Set<String> = []
var loadError: String?
func loadProducts() async {
do {
let loaded = try await Product.products(for: ProductID.all)
// Стабильный порядок для UI: сначала подписки, потом расходуемые.
self.products = loaded.sorted { $0.price < $1.price }
} catch {
self.loadError = "Не удалось загрузить продукты: \(error.localizedDescription)"
}
}
}
Обратите внимание на @Observable. С iOS 17 это предпочтительный способ пробросить состояние в SwiftUI. Если вы ещё не мигрировали с @ObservableObject, посмотрите руководство по Observation framework: там разобраны нюансы миграции и производительности.
Тип Product value-типовой, содержит displayName, description, displayPrice (уже локализованный, с валютой), price (Decimal), type (значения .consumable, .nonConsumable, .autoRenewable, .nonRenewable) и, для подписок, subscription с деталями периода, промо-офферов и группы. Ничего парсить руками не нужно.
Покупка и проверка транзакций через JWS
Инициация покупки, это вызов product.purchase(). Результат — enum с тремя случаями: .success(VerificationResult), .userCancelled, .pending. Последний важен: он означает, что покупка требует одобрения (Ask to Buy у детей, SCA-подтверждение в Европе) и завершится позже, асинхронно, через слушатель.
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
// VerificationResult проверяет JWS-подпись Apple.
let transaction = try checkVerified(verification)
await updateEntitlements()
await transaction.finish() // Обязательно, иначе StoreKit будет пытаться повторно.
return transaction
case .userCancelled:
return nil
case .pending:
// Ask to Buy / SCA: покупка придёт позже через Transaction.updates.
return nil
@unknown default:
return nil
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error // Не доверяем: Apple не подтвердила подпись.
case .verified(let safe):
return safe
}
}
Разберём три критичные детали, на которых спотыкаются даже опытные разработчики:
VerificationResult не отменяет проверку, а делает её явной. StoreKit сам валидирует JWS-подпись через сертификат Apple Root CA, но вы решаете, доверять ли .unverified-транзакции. Никогда не разблокируйте контент по неверифицированной транзакции.
transaction.finish() обязателен для всех типов, кроме .autoRenewable подписок (для них он тоже нужен, но StoreKit будет повторно отдавать транзакцию, пока вы её не «финализируете»). Если забыли, увидите на устройстве бесконечный цикл предложений покупки.
Не завершайте транзакцию до выдачи контента. Порядок такой: проверили подпись, выдали контент, сохранили состояние, вызвали finish(). Если приложение упадёт между «выдали» и «финализировали», StoreKit отдаст транзакцию снова, и вы просто повторите выдачу идемпотентно.
Слушатель Transaction.updates: почему без него никак
Приложение может получить транзакцию не только в момент вызова purchase(). Возможные сценарии: пользователь одобрил Ask to Buy через час, купил подписку на другом устройстве и открыл приложение здесь, восстановил покупки, случилось автопродление, применён промо-код из App Store. Все эти события доставляются через Transaction.updates, это AsyncSequence, за которой вы должны следить с момента запуска приложения.
@Observable
final class StoreManager {
private var updatesTask: Task<Void, Never>?
func startObservingTransactions() {
updatesTask?.cancel()
updatesTask = Task.detached { [weak self] in
// Transaction.updates, это AsyncSequence, живёт всю сессию.
for await verification in Transaction.updates {
guard let self else { return }
do {
let transaction = try await self.checkVerified(verification)
await self.updateEntitlements()
await transaction.finish()
} catch {
// Логируем, но не финализируем: Apple переотправит транзакцию.
print("Unverified transaction: \(error)")
}
}
}
}
deinit { updatesTask?.cancel() }
}
Запускайте слушатель как можно раньше, в идеале в @main App-структуре, в init() корневого объекта состояния. Если запустить его в onAppear первого экрана, вы пропустите транзакции, пришедшие за время между запуском и появлением UI. На visionOS и Mac Catalyst это особенно заметно: там между запуском и первым отрисованным окном может пройти секунда-две. Я поймал этот баг при шипе VR-приложения. Пользователь Ask-to-Buy жаловался, что покупка «пропала», и мы полдня искали.
Отдельный вопрос про Transaction.currentEntitlements. Это AsyncSequence, отдающая все актуальные (не отменённые, не истёкшие) права пользователя. Именно её нужно вызывать при старте приложения, чтобы восстановить состояние:
func updateEntitlements() async {
var active: Set<String> = []
for await verification in Transaction.currentEntitlements {
if case .verified(let transaction) = verification {
active.insert(transaction.productID)
}
}
self.purchasedProductIDs = active
}
SubscriptionStoreView и готовые SwiftUI-пейволлы
С iOS 17 Apple добавила три готовых SwiftUI-компонента: ProductView (одиночный продукт), StoreView (сетка продуктов) и SubscriptionStoreView (полноценный пейволл для подписок). Последний в iOS 26 получил новые опции для настройки фонов, автоматическое соответствие Liquid Glass и адаптивную вёрстку под visionOS.
Что даёт этот подход бесплатно: локализованные цены, показ пробного периода, обработка «Восстановить покупки» и «Ввести промокод», автоматическую отправку транзакций в ваш слушатель, соответствие HIG на всех платформах. В моём последнем проекте с четырьмя платформами это сэкономило примерно неделю верстки.
Если стандартный пейволл вас не устраивает, используйте ProductView для отдельных карточек и подписывайтесь на .onInAppPurchaseStart / .onInAppPurchaseCompletion модификаторы, чтобы отслеживать состояние. Для навигации между пейволлами и остальным приложением пригодится NavigationStack и Router-паттерн в SwiftUI.
Серверная верификация с App Store Server API
Клиентская JWS-верификация надёжна против случайных сбоев, но не против злонамеренной подмены на jailbroken-устройстве. Для критичного контента (премиум-функций с серверной синхронизацией, зачисления виртуальной валюты, отправки цифрового товара) нужна серверная проверка через App Store Server API.
Схема простая. Клиент отправляет transaction.jsonRepresentation (это тот же JWS-токен) на ваш бэкенд. Сервер разбирает JWS, извлекает transactionId, вызывает GET /inApps/v1/transactions/{transactionId} с JWT-авторизацией (алгоритм ES256, ключ из App Store Connect). Apple вернёт подписанную транзакцию, которую вы верифицируете ещё раз, уже с сертификатом, полученным независимо от клиента.
// На клиенте: отправляем JWS-представление транзакции на сервер.
func sendToBackend(_ transaction: Transaction) async throws {
let jws = transaction.jsonRepresentation // Data с JWS
var request = URLRequest(url: URL(string: "https://api.example.com/iap/verify")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jws
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
// Сервер вернёт подтверждение, активируем контент.
_ = try JSONDecoder().decode(EntitlementResponse.self, from: data)
}
На сервере параллельно подпишитесь на App Store Server Notifications V2. Apple будет присылать push-уведомления о продлениях, отменах, возвратах и Grace Period. Без этого вы узнаете об отмене подписки только когда пользователь снова откроет приложение (или через час опроса), а о возврате вообще никогда, пока пользователь не пожалуется.
StoreKit 2 API одинаков на всех платформах, но поведение нет. Собрал наблюдения после запуска одного и того же кода на пяти платформах:
Mac Catalyst и native macOS:SubscriptionStoreView рендерится в отдельном модальном окне. Если вы полагались на .sheet, на Mac это будет полноценное окно, не bottom sheet. Проверяйте вёрстку с шириной от 320pt.
visionOS: пейволлы автоматически становятся «volumetric-friendly». Фон затемняется, кнопки получают правильную glass-подсветку. Но ProductView в маленьком контейнере на visionOS выглядит несоразмерно, добавьте минимальную высоту.
watchOS: покупки поддерживаются с watchOS 6.2, но SubscriptionStoreView недоступен. Используйте ProductView в списке или отправляйте пользователя на iPhone через WKInterfaceDevice. Пользователи почти всегда предпочтут завершить покупку на iPhone.
tvOS: нет клавиатуры для ввода промокодов. Apple откроет системный дизайн ввода. Не пытайтесь верстать свой.
Family Sharing: подписки с включённой Family Sharing вернут transaction.ownershipType == .familyShared. Если у вас разная логика для основного покупателя и члена семьи (например, доступ к аналитике), проверяйте это поле.
Также имейте в виду AppTransaction, это отдельная сущность для проверки, что приложение вообще было приобретено легально (актуально для платных приложений и рефанд-логики). Она не связана с in-app покупками и загружается через AppTransaction.shared. Спецификацию по JWS-структуре можно посмотреть в RFC 7515, если хочется понимать, что именно проверяет StoreKit под капотом.
Часто задаваемые вопросы
Чем StoreKit 2 отличается от StoreKit 1?
StoreKit 2, это переписанный API на Swift Concurrency: вместо делегатов и очереди платежей используются async/await и AsyncSequence. Транзакции подписаны JWS и валидируются на устройстве без сервера. Минимальная версия: iOS 15, macOS 12, watchOS 8. StoreKit 1 остаётся доступен для обратной совместимости, но новые фичи (SubscriptionStoreView, currentEntitlements, App Store Server API) доступны только в StoreKit 2.
Как тестировать покупки в Xcode без App Store Connect?
Создайте StoreKit Configuration File (File → New → File → StoreKit Configuration File), опишите в нём продукты и подключите в схеме запуска через Edit Scheme → Run → Options → StoreKit Configuration. Покупки будут работать локально, без App Store Connect, Sandbox-аккаунта и интернета. Xcode позволяет ускорять время подписки (месяц за минуту), эмулировать отказы платежа и Ask to Buy.
Как проверить транзакцию на сервере?
Отправьте transaction.jsonRepresentation (JWS-токен) на ваш бэкенд. Сервер извлекает transactionId и вызывает GET /inApps/v1/transactions/{transactionId} App Store Server API с JWT-авторизацией (ES256, ключ из App Store Connect). Apple вернёт подписанную транзакцию, которую сервер валидирует независимо от клиента. Параллельно подпишитесь на App Store Server Notifications V2 для отслеживания продлений и возвратов.
Что такое SubscriptionStoreView и когда его использовать?
SubscriptionStoreView, это готовый SwiftUI-компонент пейволла для подписок из iOS 17+. Он отображает все продукты одной subscription group, локализованные цены, пробные периоды, кнопки восстановления и промокода. Используйте его, когда нужен стандартный пейволл: он экономит недели работы и автоматически адаптируется под iPad, Mac Catalyst и visionOS. Если требуется нестандартный дизайн, используйте отдельные ProductView.
Нужна ли отдельная обработка pending-транзакций?
Да. Статус .pending возвращается, когда покупка требует одобрения родителя (Ask to Buy) или дополнительной аутентификации (SCA в Европе). Транзакция завершится позже: через несколько минут, часов или дней. Обрабатывать её нужно в слушателе Transaction.updates, а не в результате purchase(). Покажите пользователю сообщение «Покупка ожидает одобрения» и не блокируйте UI.
Полный разбор ScrollView в SwiftUI и iOS 26: paging и snapping через scrollTargetBehavior, отслеживание позиции через scrollPosition, фазовые анимации scrollTransition и адаптивные карусели. Замена UIScrollView без потери контроля.
Разбираем хаптик-фидбек в SwiftUI: когда хватает .sensoryFeedback, а когда пора идти в Core Haptics. Параметры intensity и sharpness, декларативные AHAP-паттерны, синхронизация с анимациями и правила accessibility для iOS 26.
TipKit в SwiftUI: как показать подсказки popover и inline, задать правила и события, донатить активность пользователя, тестировать и не сломать accessibility. Полный гайд для iOS 26 с примерами кода.