StoreKit 2: Delayed Transaction and Entitlement Updates After Promo Code Subscription Redemption

I’m implementing a subscription purchase flow using promo code redemption via an external App Store URL.

Flow:

  1. User taps “Purchase” in the app (spinner shown)
  2. App opens the promo redemption URL (apps.apple.com/redeem)
  3. User completes redemption in the App Store
  4. User returns to the app
  5. 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:

  1. Is this entitlement propagation delay expected when redeeming promo codes through the App Store?
  2. Is there a recommended API or flow for immediately determining whether a subscription has been successfully redeemed?
  3. 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
    }
}
StoreKit 2: Delayed Transaction and Entitlement Updates After Promo Code Subscription Redemption
 
 
Q