I’m implementing a subscription purchase flow using promo code redemption via an external App Store URL.
Flow:
- User taps “Purchase” in the app (spinner shown)
- App opens the promo redemption URL (apps.apple.com/redeem)
- User completes redemption in the App Store
- User returns to the app
- The app must determine whether the subscription was purchased within a reasonable time window
The app listens to Transaction.updates and also checks Transaction.currentEntitlements when the app returns to the foreground.
Issue: After redeeming a subscription promo code via the App Store and returning to the app, the app cannot reliably determine whether the subscription was successfully purchased within a short, user-acceptable time window.
In many cases, neither Transaction.updates nor Transaction.currentEntitlements reflects the newly redeemed subscription immediately after returning to the app. The entitlement may appear only after a significant delay, or not within a 60-second timeout at all, even though the promo code redemption succeeded.
Expected: When the user returns to the app after completing promo code redemption, StoreKit 2 should report the updated subscription entitlement shortly thereafter (e.g. within a few seconds) via either Transaction.updates or Transaction.currentEntitlements.
Below is the minimal interactor used in the sample project. The app considers the purchase successful if either a verified transaction for the product is received via Transaction.updates, or the product appears in Transaction.currentEntitlements when the app returns to the foreground. Otherwise, the flow fails after a 60-second timeout.
Questions:
- Is this entitlement propagation delay expected when redeeming promo codes through the App Store?
- Is there a recommended API or flow for immediately determining whether a subscription has been successfully redeemed?
- Is there a more reliable way to detect entitlement changes after promo code redemption without triggering user authentication prompts (e.g., from AppStore.sync())?
import UIKit
import StoreKit
final class PromoPurchaseInteractor {
private let timeout: TimeInterval = 60
private struct PendingOfferRedemption {
let productId: String
let completion: (Result<Bool, Error>) -> Void
}
private var pendingRedemption: PendingOfferRedemption?
private var updatesTask: Task<Void, Never>?
private var timeoutTask: Task<Void, Never>?
enum DefaultError: Error {
case generic
case timeout
}
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
updatesTask?.cancel()
timeoutTask?.cancel()
}
func purchaseProduct(using offerUrl: URL, productId: String, completion: @escaping (Result<Bool, Error>) -> Void) {
guard pendingRedemption == nil else {
completion(.failure(DefaultError.generic))
return
}
pendingRedemption = PendingOfferRedemption(productId: productId, completion: completion)
startPurchase(using: offerUrl)
}
@objc private func willEnterForeground() {
guard let pendingRedemption = pendingRedemption else { return }
startTimeoutObserver()
Task {
if await hasEntitlement(for: pendingRedemption.productId) {
await MainActor.run {
self.completePurchase(result: .success(true))
}
}
}
}
private func startPurchase(using offerURL: URL) {
startTransactionUpdatesObserver()
UIApplication.shared.open(offerURL) { [weak self] success in
guard let self = self else { return }
if !success {
self.completePurchase(result: .failure(DefaultError.generic))
}
}
}
private func completePurchase(result: Result<Bool, Error>) {
stopTransactionUpdatesObserver()
stopTimeoutObserver()
guard let _ = pendingRedemption else { return }
pendingRedemption?.completion(result)
pendingRedemption = nil
}
private func startTransactionUpdatesObserver() {
updatesTask?.cancel()
updatesTask = Task {
for await update in Transaction.updates {
guard case .verified(let transaction) = update else { continue }
await MainActor.run { [weak self] in
guard let self = self,
let pending = self.pendingRedemption,
transaction.productID == pending.productId
else { return }
self.completePurchase(result: .success(true))
}
await transaction.finish()
}
}
}
private func stopTransactionUpdatesObserver() {
updatesTask?.cancel()
updatesTask = nil
}
private func startTimeoutObserver() {
guard pendingRedemption != nil else { return }
timeoutTask?.cancel()
timeoutTask = Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
await MainActor.run { [weak self] in
self?.completePurchase(result: .failure(DefaultError.timeout))
}
}
}
private func stopTimeoutObserver() {
timeoutTask?.cancel()
timeoutTask = nil
}
private func hasEntitlement(for productId: String) async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if transaction.productID == productId {
return true
}
}
return false
}
}