A price-increase consent sheet once slid up on the third onboarding screen for a brand-new user. As an indie developer running a few apps with in-app purchases on the App Store, I see onboarding completion drop noticeably whenever that interruption fires. We are asking someone to agree to a price before they have felt any value — so the drop makes sense.
App Store system messages (price-increase consent, billing issues, win-back offers) are displayed by StoreKit automatically when your app returns to the foreground. You cannot avoid showing them, but you fully control when. By subscribing to StoreKit 2's Message, you move the trigger into your own hands. This assumes the native Swift that Rork Max generates, but the same approach applies if you bridge something like react-native-iap.
The default behavior, and the one lever you hold
The key fact is that messages can be held. When you subscribe to the StoreKit.Message.messages async sequence, any message the system wants to present flows to your app instead. Once you are inside that for await loop, nothing displays until you call message.display(in:) yourself.
So "subscribing" effectively means "suppress auto-presentation and take over." A received message is retained until you display it; if you never do, it arrives again next launch. You will not lose messages, but never showing them hurts both compliance and revenue, so always design a path that eventually flushes everything.
Open exactly one subscription at launch
Keep the subscription to a single loop for the whole app. Running for await in several places risks processing the same message twice. Create one @MainActor coordinator and start the loop right at launch.
import StoreKit
import SwiftUI
@MainActor
final class StoreMessageCoordinator: ObservableObject {
private(set) var pending: [Message] = []
/// Whether it is OK to present right now (driven by screen state)
var canPresent: Bool = false { didSet { flushIfPossible() } }
private var listenTask: Task<Void, Never>?
func start() {
guard listenTask == nil else { return }
listenTask = Task { [weak self] in
for await message in Message.messages {
// The system will not auto-present unless we call display()
self?.enqueue(message)
}
}
}
private func enqueue(_ message: Message) {
// Consent and billing tie directly to revenue/compliance — keep them at the front
switch message.reason {
case .priceIncreaseConsent, .billingIssue:
pending.insert(message, at: 0)
default:
pending.append(message)
}
flushIfPossible()
}
private func flushIfPossible() {
guard canPresent, let scene = Self.activeScene() else { return }
let next = pending
pending.removeAll()
for message in next {
do { try message.display(in: scene) }
catch { pending.append(message) } // put it back if we cannot present
}
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
}
}Flip canPresent based on screen state and you control timing completely. Note that display(in:) requires a UIWindowScene; calling it before a scene is frontmost fails, so only present when a foregroundActive scene is available.