Всё цифровое, чем пользуются внутри приложения (премиум-функции, подписка на контент, монеты, отключение рекламы), продаётся только через встроенные покупки (in-app purchase). Физические товары и услуги в реальном мире (поездка на такси, худи, ночь в отеле) через них проводить нельзя: для них используют Apple Pay или свою форму оплаты картой.
Цифровое, открывается в приложении (guideline 3.1.1): подписки, уровни, игровая валюта, разблокировка Pro. Apple забирает от 15 до 30 процентов, а кнопка, ведущая на более дешёвую цену на сайте, приводит к реджекту сборки (кроме витрин ниже).
Физические товары и услуги вне приложения (3.1.3(e)): доставка, билеты, поездки, заказы на маркетплейсе. Встроенные покупки здесь запрещены, и Apple ничего не берёт.
Живые услуги один на один (3.1.3(d)): репетитора или врача на звонке с одним человеком можно оплатить в обход встроенных покупок; занятие для многих так оплатить нельзя.
Контент, купленный в другом месте (3.1.3(a), (b)): reader-приложения (видео, музыка, книги, новости) могут просто пускать пользователя к купленному на сайте; другие мультиплатформенные сервисы тоже, если те же позиции продаются и через встроенные покупки.
Исключения по витринам: с мая 2025 года на витрине США можно ставить ссылку на оплату на сайте, а в ЕС и ещё нескольких странах действуют свои условия альтернативных платежей.
iOSMiddle
StoreKit и релиз в App Store
StoreKit 2, подписки и офферы, права доступа, серверные уведомления, возвраты, тестирование покупок, App Review, поэтапный релиз, принудительное обновление
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Расходуемые покупки (монеты, кредиты) покупают снова и снова, нерасходуемые (вечная разблокировка) один раз и навсегда, автовозобновляемые подписки продлеваются до отмены, а невозобновляемые подписки (сезонный абонемент) просто заканчиваются. От типа зависит, кто хранит запись о покупке и что нужно уметь восстанавливать.
Расходуемая (consumable): тратится и покупается заново. Баланс хранит ваше приложение или сервер: StoreKit не ведёт его как право доступа. Валюта, купленная так, не может сгорать (guideline 3.1.1), а возврат денег означает, что её нужно забрать.
Нерасходуемая (non-consumable): покупается один раз и доступна на всех устройствах с тем же Apple Account. Её обязательно нужно уметь восстанавливать, и для неё можно включить Семейный доступ.
Автовозобновляемая подписка: живёт в группе подписок и продлевается, пока пользователь не отменит её в Настройках. Пейволл обязан показывать цену, период и то, что подписка продлевается, со ссылками на условия и политику конфиденциальности (guideline 3.1.2).
Невозобновляемая подписка: фиксированный срок без продления. StoreKit не следит за её окончанием: срок вы считаете от даты покупки сами и обязаны сделать её доступной на всех устройствах пользователя, то есть нужны свои аккаунты.
Загрузите продукты через Product.products(for:), вызовите purchase() у выбранного, убедитесь, что результат равен .success(.verified(transaction)), выдайте контент и затем вызовите transaction.finish(). Покупки, которые завершаются позже, приходят через Transaction.updates.
Загрузка: показывайте displayName и displayPrice из Product, никогда не зашитую в код цену: цена и валюта зависят от витрины пользователя. Неизвестный ID молча пропадает из результата, поэтому пустой список обычно означает опечатку, незаполненные метаданные в App Store Connect или неподписанное соглашение Paid Apps.
Покупка: purchase() показывает системное окно покупки Apple. В UIKit передавайте сцену через purchase(confirmIn:) (iOS 17+); в SwiftUI есть @Environment(\.purchase) и готовые StoreView и SubscriptionStoreView.
Проверка: StoreKit 2 уже проверил подпись Apple. .unverified означает, что открывать ничего нельзя.
Сначала выдать, потом завершить: незавершённая транзакция снова придёт при следующем запуске, поэтому краш между этими шагами ничего не теряет.
**.pending** не ошибка: это Ask to Buy или платёж, которому нужно дополнительное подтверждение. Покажите «ждём подтверждения» и дайте слушателю Transaction.updates довести дело до конца.
CFBundleShortVersionString задаёт версию, которую пользователь видит в App Store (2.4.0); CFBundleVersion задаёт номер сборки (1873). Номер сборки меняется при каждой загрузке; версия меняется раз в релиз и должна быть выше последней одобренной.
Где они живут: в настройках сборки MARKETING_VERSION и CURRENT_PROJECT_VERSION, которые Info.plist читает как $(MARKETING_VERSION) и $(CURRENT_PROJECT_VERSION). Оба значения состоят максимум из трёх целых чисел через точку.
Одна версия, много сборок: у 2.4.0 может быть десять сборок в TestFlight, и на ревью отправляется одна из них. Загрузка номера сборки, который у этой версии уже есть, падает как redundant binary upload.
Номер сборки пусть ставит CI (номер запуска или последняя сборка в TestFlight плюс один): так он только растёт, и никто не правит его руками.
Расширения (виджеты, notification extension) должны иметь ту же версию и сборку, что и приложение, иначе загрузка выдаёт предупреждение. Если передать обе настройки в xcodebuild в командной строке, они применятся ко всем таргетам.
Вызовите действие requestReview из окружения SwiftUI (в UIKit AppStore.requestReview(in:)) в удачный момент, а показывать ли диалог, решит система. Он появляется не чаще трёх раз для приложения за 365 дней и ничего не сообщает обратно, поэтому приложение не может ни узнать о показе, ни рассчитывать на него.
Когда: сразу после успеха (заказ оформлен, уровень пройден), никогда не при запуске, не посреди задачи и не после ошибки.
Ограничения, которых не видно: пользователь может отключить такие запросы для всех приложений в Настройках. В сборке для разработки диалог появляется всегда, в TestFlight никогда.
Никаких своих запросов: guideline 5.6.1 требует системный API для запроса отзыва, поэтому самодельный диалог «Поставьте 5 звёзд» получает реджект.
Кнопка «Оценить приложение» должна открывать страницу в магазине с ?action=write-review. Кнопка, которая вызывает requestReview, часто ничего не делает и выглядит сломанной.
История: старый API SKStoreReviewController устарел в iOS 18.
Многие транзакции вообще не проходят через ваш вызов purchase(): продления, одобрения Ask to Buy, покупки на другом устройстве, возвраты денег, offer codes, активированные в App Store, и всё, что осталось незавершённым после краша. Они приходят только через Transaction.updates, и слушатель, запущенный поздно, их пропускает.
Как они приходят: возврат денег выглядит как та же транзакция с заполненным revocationDate, а незавершённые транзакции приходят один раз, сразу после запуска.
Без слушателя: деньги списаны, а пользователь по-прежнему видит пейволл, и транзакция так и не завершается. StoreKit даже пишет предупреждение в консоль Xcode, когда purchase() вызывается без слушателя.
Запускайте один раз и рано: в инициализаторе App или в application(_:didFinishLaunchingWithOptions:), в объекте, который живёт столько же, сколько приложение. Слушатель в .task экрана пейволла работает, только пока этот экран открыт.
Один путь в коде: кнопка и слушатель вызывают одну и ту же функцию (проверить, выдать, пересчитать права доступа, finish()), чтобы логика существовала в одном месте.
Обновления не равны состоянию: updates сообщает, что что-то изменилось; Transaction.currentEntitlements говорит, чем пользователь владеет сейчас. Пересчитывайте по нему и при запуске.
finish() вызывается до того, как монеты начислены. Завершённая транзакция больше никогда не приходит, поэтому, когда creditCoins падает, покупку уже никто не вернёт: пользователь заплатил, монет нет, и повторной попытки не будет.
**Что означает finish()**: «контент выдан, больше не сообщай мне об этом». Незавершённая транзакция возвращается через Transaction.updates после каждого запуска и лежит в Transaction.unfinished; завершённая уходит из обоих мест навсегда.
Исправление: сначала начислить, а finish() вызвать только после подтверждения сервера. При ошибке оставить транзакцию незавершённой, и следующий запуск или повтор из Transaction.unfinished доставит её снова.
Идемпотентность становится обязательной: теперь одна транзакция может дойти до сервера дважды (таймаут, ответ на который потерялся, повтор после перезапуска), поэтому бэкенд начисляет по каждому transaction.id один раз, а на второй запрос отвечает «уже начислено».
Обратный баг: вообще не вызывать finish(). Покупка возвращается при каждом запуске, и сервер без такой проверки каждый раз начисляет монеты заново.
Тост был половиной бага: «попробуйте ещё раз» подталкивает ко второй покупке. Скажите, что монеты уже в пути, и повторяйте тихо.
Пересчитывайте права доступа из Transaction.currentEntitlements при запуске и после каждого обновления: там лежит последняя подписанная транзакция по каждой активной подписке и нерасходуемой покупке. Флаг в UserDefaults остаётся всего лишь копией, которая никогда не узнает об окончании подписки, возврате денег, Семейном доступе или другом устройстве.
Что содержит последовательность: все нерасходуемые покупки, последнюю транзакцию каждой автовозобновляемой подписки, которая активна или в grace period, и невозобновляемые подписки (срок их окончания проверяете сами). Возвращённые и отозванные покупки туда не попадают.
В чём ошибается флаг: он остаётся true после окончания плана или возврата денег, остаётся false на новом iPhone или после переустановки и ничего не знает о плане, которым поделился член семьи (ownershipType == .familyShared).
Только проверенные записи: пропускайте результаты .unverified (именно так выглядит поддельная транзакция на джейлбрейкнутом устройстве) и записи с isUpgraded, которые заменил апгрейд.
Кэш годится как подсказка: храните последний известный уровень, чтобы нарисовать первый кадр, и затем перезаписывайте его пересчитанным значением. StoreKit держит свою копию транзакций на устройстве, поэтому пересчёт работает и офлайн.
Если есть бэкенд: запись на сервере, которую обновляют уведомления Apple, решает, какой серверный контент доступен; currentEntitlements решает, что приложение открывает локально.
В StoreKit 2 восстановление в основном происходит само: транзакции синхронизируются на все устройства с тем же Apple Account и появляются в currentEntitlements. Кнопка остаётся, потому что её требует App Review, а AppStore.sync() за ней нужен для редкого случая, когда копия на устройстве устарела.
Почему её требует ревью: guideline 3.1.1 требует механизм восстановления для восстанавливаемых покупок (нерасходуемых и подписок). Пейволл с кнопкой «Купить» и без «Восстановить» даёт классический реджект.
**Что делает sync(): принудительно заново синхронизирует транзакции пользователя с App Store. Обычно он просит пользователя пройти аутентификацию, поэтому вызывайте его только по явному нажатию, никогда не при запуске и не по таймеру.
После возврата: пересчитайте права доступа и сообщите результат: доступ открыт или «покупок для этого Apple Account не найдено». Обычная причина второго варианта в другом Apple Account, а не в баге.
Расходуемые покупки не восстанавливаются: их баланс должен жить на вашем сервере, в аккаунте пользователя.
Свои аккаунты**: если покупки привязаны к вашему логину, восстановление также означает загрузку прав доступа с бэкенда после входа.
Группа подписок объединяет планы, которые заменяют друг друга; у пользователя может быть не больше одного активного плана в группе. Планы ранжируются по уровням: апгрейд начинается сразу с пропорциональным возвратом денег, даунгрейд ждёт следующего продления, а кроссгрейд зависит от периодов.
Уровни: уровень 1 означает самый высокий тариф. Планы одного уровня с разными периодами (Pro на месяц и Pro на год) являются кроссгрейдами друг друга.
Апгрейд (на уровень выше): начинается немедленно, и Apple возвращает неиспользованную часть старого плана. Старая транзакция приходит снова с isUpgraded == true, и начинается новая.
Даунгрейд (на уровень ниже): текущий план работает до своей даты окончания, затем продлевается более дешёвый. До этого момента RenewalInfo.autoRenewPreference указывает на новый продукт, и приложение может написать «Basic с 12 марта».
Кроссгрейд (тот же уровень): сразу, если периоды одинаковые, иначе со следующей даты продления.
Одна группа на продукт: месячный и годовой планы одного продукта кладите в одну группу. Отдельные группы позволяют пользователю платить за оба сразу, и у каждой группы своё право на вводное предложение.
На сервере: изменение сообщает DID_CHANGE_RENEWAL_PREF с подтипом UPGRADE или DOWNGRADE.
Показывайте пробный период, только когда isEligibleForIntroOffer отвечает «да»: одно вводное предложение на группу подписок на один Apple Account. Introductory offers рассчитаны на новых подписчиков, promotional offers на текущих или ушедших (подписываются вашим сервером), а offer codes представляют собой коды, которые пользователь активирует.
Право на оффер: только App Store знает, был ли у этого Apple Account пробный период или план в этой группе, поэтому никогда не решайте это по своему флагу. Пейволл «7 дней бесплатно» для пользователя без права на пробный период списывает деньги в первый же день: это запрос на возврат и плохой отзыв.
Introductory offer: бесплатный пробный период, pay as you go (сниженная цена на несколько периодов) или pay up front. Настраивается для каждого плана в App Store Connect; StoreKit сам применяет его к покупке, если пользователь имеет на него право.
Promotional offer: для текущих или бывших подписчиков, например скидка, когда пользователь собирается отменить подписку. Приложение передаёт .promotionalOffer(...) в purchase(options:) с подписью, которую ваш сервер делает ключом In-App Purchase, поэтому модифицированный клиент не может выдумать скидку.
Offer codes: одноразовые или собственные коды для маркетинга и поддержки, активируются в App Store или в приложении через .offerCodeRedemption(isPresented:) (в UIKit AppStore.presentOfferCodeRedeemSheet(in:)). Результат приходит в Transaction.updates.
Win-back offers (iOS 18): настраиваются в App Store Connect для ушедших подписчиков, и App Store может показывать их сам.
Если продление не прошло, Apple повторяет списание до 60 дней (billing retry). Если включён billing grace period, пользователь сохраняет доступ в его первые дни; после этого доступ закрывается, пока одна из попыток не пройдёт. В обоих состояниях нужно сообщение «обновите способ оплаты».
Grace period (.inGracePeriod): включается в App Store Connect, до 28 дней. План по-прежнему считается активным и остаётся в currentEntitlements; покажите неблокирующий баннер с датой из gracePeriodExpirationDate.
Billing retry (.inBillingRetryPeriod): доступа нет, но пользователь подписку не отменял. Покажите «оплата не прошла, обновите способ оплаты», а не пейволл, как будто это новая продажа.
Списать деньги может только Apple: дайте ссылку на https://apps.apple.com/account/billing или откройте AppStore.showManageSubscriptions(in:). С iOS 16.4 StoreKit умеет показывать и своё окно о проблеме с оплатой (Message с причиной .billingIssue).
Восстановление: когда повторное списание проходит, продление приходит в Transaction.updates; пересчитайте права доступа и уберите баннер. Если оплата восстановилась в grace period, дата продления не сдвигается.
На сервере: DID_FAIL_TO_RENEW (подтип GRACE_PERIOD, если действует grace period), GRACE_PERIOD_EXPIRED, DID_RENEW с подтипом BILLING_RECOVERY или EXPIRED с подтипом BILLING_RETRY, если все попытки провалились.
Решение о возврате принимает Apple, а не вы. Когда возврат одобрен, приложение снова получает транзакцию в Transaction.updates с заполненным revocationDate, она пропадает из currentEntitlements, а ваш сервер получает уведомление REFUND. beginRefundRequest открывает форму возврата Apple прямо в приложении.
В приложении: пересчитайте права доступа по этому обновлению, и платный уровень пропадёт сам. Для расходуемых покупок на устройстве пересчитывать нечего: монеты забирает сервер.
На сервере: REFUND несёт подписанную транзакцию с revocationDate и revocationReason; отзовите ровно эту покупку по её ID транзакции. REFUND_REVERSED возвращает её обратно, а REVOKE означает, что член семьи потерял доступ через Семейный доступ.
**beginRefundRequest**: Transaction.beginRefundRequest(for:in:) в UIKit, .refundRequestSheet(for:isPresented:) в SwiftUI. Результат .success означает только, что запрос отправлен; решение Apple приходит письмом и тем же обновлением.
Зачем это предлагать: пользователь, который не нашёл, где вернуть деньги, оспаривает списание в банке или ставит одну звезду.
Данные о потреблении: на каждый запрос возврата ваш сервер также получает CONSUMPTION_REQUEST; у вас есть 12 часов, чтобы ответить через Send Consumption Information (пользовались ли контентом), и только с согласия пользователя на передачу этих данных.
Используйте три слоя: файл конфигурации .storekit для быстрых локальных запусков и юнит-тестов, песочницу (sandbox) для настоящих продуктов из App Store Connect и серверных уведомлений и TestFlight для релизной сборки в руках тестировщиков. Каждый слой что-то подделывает, поэтому ни один из них в одиночку не доказывает, что покупки работают.
Файл конфигурации StoreKit: продукты описаны в Xcode, без сети и без аккаунта. Менеджер транзакций Xcode и SKTestSession в тестах умеют продлевать подписку за секунды, делать возврат, переводить продление в billing retry или grace period и включать Ask to Buy, так что любое состояние проверяется на CI. Не ловит: ID продуктов, которые расходятся с App Store Connect, и ваш сервер: транзакции подписаны локальным сертификатом Xcode, который отклонит сервер, проверяющий цепочку Apple, а уведомления не отправляются.
Песочница: настоящие продукты, sandbox Apple Account, созданный в App Store Connect, ускоренные продления и уведомления на ваш sandbox URL. Не ловит: настоящие платежи и поведение банков.
TestFlight: настоящая сборка, покупки бесплатны и идут в окружении sandbox. Не ловит: реальные цены, налоги и настоящие возвраты.
App Review покупает в песочнице с вашей продакшен-сборкой, поэтому продакшен-сервер должен принимать транзакции с environment, равным Sandbox, и проверять их через sandbox-эндпоинт Apple. Иначе покупка ревьюера ничего не открывает, и сборку отклоняют.
Прочитайте указанный пункт guidelines и заметки ревьюера, затем исправьте и отправьте снова или ответьте в App Store Connect, чтобы объяснить или спросить, что именно нужно. Апелляция в App Review Board нужна, только когда правило применили неверно, а ускоренное ревью стоит просить только для критического исправления или события с датой.
Сначала ответ: переписка в App Store Connect идёт к ревьюеру. Многие реджекты оказываются недопониманием (до функции не смогли добраться, нет демо-аккаунта), и короткий ответ с шагами, записью экрана или данными для входа снимает их без новой сборки.
Только метаданные: неверный скриншот или описание исправляются в App Store Connect и отправляются снова с той же сборкой.
Частые причины, которые надо исправлять: краши или сломанные сценарии на устройстве ревьюера (2.1), нет демо-аккаунта у приложения с обязательным входом, цифровые товары в обход встроенных покупок (3.1.1), нельзя удалить аккаунт в приложении (5.1.1(v)), текст запроса разрешения не объясняет, зачем оно нужно.
Багфиксы не берут в заложники: для приложения, которое уже в магазине, обновление с исправлением багов не задерживают из-за постороннего нарушения guidelines (кроме юридических и связанных с безопасностью); ответьте, что исправите это в следующей отправке.
Апелляция: через форму App Review Board на сайте для разработчиков, с указанием пункта и того, почему он не применим.
Ускоренное ревью: через ту же страницу контактов, для исправления краша или события с жёстким сроком. Это одолжение, а не платная очередь; команды, которые просят его каждый релиз, перестают его получать.
Выпустите проверку заранее: при запуске приложение получает с сервера минимальную и рекомендуемую версии, сравнивает их со своим CFBundleShortVersionString и показывает блокирующий экран или баннер со ссылкой на страницу в App Store. Перевести можно только те сборки, в которых проверка уже есть.
Проверка должна пережить изменение, ради которого она нужна: держите эндпоинт с политикой и его JSON стабильными и не делайте проверку зависимой от того, что вы выводите из эксплуатации (новой версии API, запиненного ключа, логина). Если проверка сломается, старые сборки так и не увидят предложение обновиться.
Сравнивайте как числа: как строки "1.10.0" < "1.9.0" даёт true. Используйте compare(_:options: .numeric) и одинаковое число компонентов с обеих сторон, потому что "1.9" оказывается меньше "1.9.0".
Проверяйте и на сервере: передавайте версию приложения в заголовке и отвечайте версиям ниже минимальной понятной ошибкой (например, 426 Upgrade Required), которую приложение превращает в экран обновления.
Не оставляйте пользователей в тупике: у кого iOS ниже нового deployment target, тот обновиться не может вообще, поэтому выбирайте минимум с учётом этого и поднимайте его, только когда новая сборка доступна во всех витринах.
Блокируйте, только когда иначе нельзя: сломанные платежи, дыра в безопасности, удалённый API; в остальных случаях хватит баннера. На блокирующем экране одна кнопка в магазин и никаких сетевых запросов за ней.
Apple отправляет App Store Server Notifications V2 на зарегистрированный вами HTTPS-эндпоинт, а App Store Server API отвечает на вопросы по запросу. Каждый payload представляет собой JWS с подписью Apple, которую сервер проверяет сам, а сообщение приложения «я купил Pro» остаётся запросом, который может подделать кто угодно.
Уведомления
Тело POST-запроса выглядит как {"signedPayload": "<JWS>"}. В расшифрованном виде там notificationType и subtype (SUBSCRIBED, DID_RENEW, DID_CHANGE_RENEWAL_STATUS при выключении автопродления, DID_FAIL_TO_RENEW, EXPIRED, REFUND, REVOKE), notificationUUID и подписанные данные транзакции и продления (в примере расшифровано событие grace period).
Быстро отвечайте 200 и обрабатывайте из очереди. Apple повторяет неудачные доставки, поэтому событие может прийти дважды и с опозданием: дедуплицируйте по notificationUUID, обновляйте по ID транзакции и сравнивайте signedDate, а не порядок прихода.
После события надёжнее запросить текущий статус (Get All Subscription Statuses) и сохранить его, чем применять изменения как дельты. Пропуски закрывает Get Notification History.
Проверка JWS
Заголовок x5c несёт цепочку сертификатов: убедитесь, что она ведёт к Apple Root CA - G3, проверьте подпись, затем bundleId и environment. App Store Server Library от Apple (Swift, Java, Python, Node.js) делает это в SignedDataVerifier.
Server API
Аутентификация через JWT, подписанный вашим ключом In-App Purchase. Через него ищут транзакцию, которую прислало приложение, читают статус подписки при входе пользователя и поднимают историю для обращений в поддержку.
verifyReceipt и чек приложения (receipt) устарели: новый код отправляет jwsRepresentation или ID транзакции.
На вашем сервере, по вашему ID пользователя: каждый магазин знает только о своих покупках. При покупке iOS-приложение передаёт UUID вашего аккаунта как appAccountToken, Apple подписывает его в каждую транзакцию и уведомление, а бэкенд выдаёт этому аккаунту доступ на всех платформах.
Поток
Сервер создаёт один UUID на аккаунт (не email и без персональных данных); приложение передаёт его через .appAccountToken(uuid).
После purchase() приложение отправляет jwsRepresentation. Сервер проверяет его, сверяет токен с вошедшим пользователем и сохраняет ID исходной транзакции за аккаунтом.
Продления, возвраты и окончание подписки приходят серверными уведомлениями по этому ID исходной транзакции, поэтому запись остаётся актуальной, даже если пользователь больше никогда не откроет приложение на iPhone.
Android и сайт спрашивают у вашего сервера, что есть у аккаунта, и никогда не спрашивают Apple.
Что решить заранее
Одна подписка Apple, два аккаунта: второй аккаунт на том же iPhone видит ту же исходную транзакцию. Выберите правило (подписка остаётся за первым аккаунтом или переходит только через поддержку) и соблюдайте его на сервере.
Двойное списание: подписчик с сайта никогда не должен видеть iOS-пейволл, поэтому любой пейволл сначала спрашивает сервер.
Управление остаётся у магазина: iOS-план отменяют или меняют только в настройках Apple, поэтому на сайте вместо кнопки «Отменить» написано «Управляется через App Store».
Покупки без токена (сделанные до появления аккаунтов) привязываются при первом входе, по транзакции, которую присылает приложение.
Guidelines: iOS-приложение может открывать купленное на сайте (3.1.3(b)), если тот же план продаётся в приложении и через встроенные покупки.
Остановите распространение, затем катите вперёд: поставьте поэтапный релиз на паузу, по возможности отключите падающий путь с сервера и выпустите исправление (или код предыдущей версии под более высоким номером версии) с запросом ускоренного ревью. Те, кто уже обновился, ждут эту сборку.
Первый час
Измерить: смотрите долю пользователей без крашей по версиям в Crashlytics или Xcode Organizer, в разрезе ОС, устройства и состояния аккаунта. Треть пользователей указывает на условие: миграцию данных, локаль, одну версию iOS, фича-флаг.
Поставить поэтапный релиз на паузу в App Store Connect: автоматические обновления останавливаются (суммарно до 30 дней), ручные нет. Релиз, который уже на 100%, на паузу не ставится.
Исправить с сервера, если причина там: поле ответа, значение конфига, фича-флаг. Kill switch помогает, только если приложение читает его до краша.
Катить вперёд: хотфикс или откат кода: возьмите последний хороший тег, поднимите версию до 2.4.1, соберите и отправьте с запросом ускоренного ревью и цифрами по крашам. Версию нельзя удалить с устройств, а снятие приложения с продажи останавливает все загрузки, но не трогает уже установленные копии.
Сообщить пользователям: через поддержку и соцсети тем, кто не может открыть приложение, и сообщением в приложении тем, кто может.
Что должно было быть
Поэтапный релиз по умолчанию, внутренние и TestFlight-пользователи получают сборку первыми, и алерты по крашам для каждой версии в первые часы.
Удалённые флаги, которые читаются в начале запуска, и защита от цикла крашей: после двух запусков, упавших до первого экрана, старт в безопасном режиме без кэшей и необязательных функций.
Миграции, проверенные со всех поддерживаемых старых версий и не способные заблокировать запуск.
Пайплайн, который может выпустить хотфикс за час.