Fix: StoreKit 2 transaction.finish() requestEncodeFailed in Sandbox

Fix: StoreKit 2 transaction.finish() requestEncodeFailed in Sandbox
Quick answer: The Sandbox environment can fail to commit transaction.finish() with an internal StoreKitInternalError.requestEncodeFailed while the call itself returns normally, so the same purchase and its renewals stay in the queue and are re-delivered on every launch. Signing out of the Sandbox Apple Account in Settings > Developer, clearing the sandbox purchase history and signing back in usually resets that queue. For day-to-day testing, use a local StoreKit configuration file instead of a sandbox account.

Why this happens

Transaction.finish() is declared as an async function that does not throw. That design is deliberate: StoreKit treats finishing as best-effort bookkeeping, so your app never has to handle a failure path. The side effect is that a real failure is invisible to your code. It only shows up in the console as:

Error finishing transaction:StoreKitServiceError(
    error: StoreKitInternalError.requestEncodeFailed,
    environment: Sandbox
)

requestEncodeFailed is an internal StoreKit error, produced during Sandbox testing on iOS 27 betas: the request that should mark the transaction as finished fails to serialize inside storekitd, so the finish never lands on the server. The transaction therefore stays in the unfinished queue. Transaction.updates delivers it again at the next launch, together with every renewal that descends from that original purchase, and each new finish() call hits the same wall.

The visible symptoms all follow from that single stuck entry:

  • The subscription still works, because Transaction.currentEntitlements is computed from the receipt, not from your finish() calls.
  • Renewals replay forever, because the parent purchase is never marked completed.
  • After expiry, product.purchase() can return .success(.verified(transaction)) carrying an old expired transaction without showing the confirmation sheet — StoreKit believes a purchase for that product is still in flight. currentEntitlements is empty, so your gate correctly refuses to unlock, but the user is now unable to start a new purchase.

How to fix it

1. Reset the sandbox purchase state

The queue lives on the server tied to your Sandbox Apple Account, so the fix is to clear it rather than to change your code.

  1. On the test device, open Settings > Developer.
  2. Sign out of the Sandbox Apple Account shown there.
  3. Clear the sandbox purchase history for that account (the same Developer screen or App Store Connect’s sandbox testers section).
  4. Sign back in with the sandbox account, or create a fresh sandbox tester in App Store Connect.
  5. Delete and reinstall the app so no local state carries over, then launch and purchase again.

For this exact report, signing out of the sandbox account in Settings > Developer and signing back in was enough to break the loop. If you have been testing for a while, a brand-new sandbox tester is the cleanest option — each tester has its own purchase history.

2. Switch logic testing to a local StoreKit configuration file

A .storekit file runs the whole purchase, expiry and repurchase cycle locally, with no server involved, which is exactly the path the server-side Sandbox is breaking.

  1. In Xcode choose File > New > File and add a StoreKit Configuration File.
  2. Add your auto-renewable subscription with a short renewal rate.
  3. Select the scheme, open Product > Scheme > Edit Scheme > Run > Options, and set the StoreKit Configuration to that file.
  4. Run from Xcode and walk the purchase → expire → repurchase cycle.

Use the local configuration for verifying UI, entitlement gating and renewal handling. Keep Sandbox (or TestFlight) for the smaller set of things only the server can validate, such as server-side receipt checks and account switching.

3. Never gate purchases on a successful finish()

Because finish() cannot report failure, your app has to stay correct even when the queue never drains. Two habits make that true.

First, drive entitlements from currentEntitlements, not from the updates stream:

func refreshEntitlements() async {
    var unlocked = false
    for await result in Transaction.currentEntitlements {
        guard case .verified(let transaction) = result else { continue }
        if transaction.expirationDate.map({ $0 > .now }) ?? true {
            unlocked = true
        }
    }
    await MainActor.run { self.isSubscribed = unlocked }
}

Second, make the updates listener idempotent so a replay does not re-deliver a purchase or re-run analytics multiple times. This debug-only helper remembers which transaction IDs you already handled and avoids re-doing the work on every launch:

#if DEBUG
private let handledKey = "debug.handledTransactionIDs"

private func alreadyHandled(_ id: UInt64) -> Bool {
    let handled = Set(UserDefaults.standard.array(forKey: handledKey) as? [String] ?? [])
    if handled.contains(String(id)) { return true }
    var updated = handled
    updated.insert(String(id))
    UserDefaults.standard.set(Array(updated), forKey: handledKey)
    return false
}
#endif

Call alreadyHandled(transaction.id) before delivering the entitlement in your Transaction.updates loop. Reset the key when you reset the sandbox account.

4. Guard against purchases that return an expired transaction

When the queue is stuck, purchase() can hand back a stale transaction without presenting a sheet. Check the expiry before granting anything, so a replayed old transaction can never unlock the UI:

let result = try await product.purchase()
switch result {
case .success(.verified(let transaction)):
    let stillActive = transaction.expirationDate.map { $0 > .now } ?? true
    guard stillActive else {
        await transaction.finish()
        await refreshEntitlements()
        return
    }
    await unlock(with: transaction)
    await transaction.finish()
case .success(.unverified), .pending, .userCancelled:
    break
@unknown default:
    break
}

If the sheet never appears and currentEntitlements is empty, you are almost certainly looking at a stuck sandbox queue — go back to step 1.

5. Retest on a current build

This failure comes from the Sandbox/storekitd side and has been reported during the iOS 27 beta cycle. Update Xcode and the device to the latest available versions before filing anything, and re-run the expiry-then-repurchase path on a fresh sandbox tester to confirm the behavior rather than relying on an account that still has the old stuck transaction.

How to prevent it

  • Test purchase logic with a local .storekit configuration; reserve Sandbox for receipt and server validation.
  • Treat Transaction.currentEntitlements as the source of truth for unlocking, and the updates stream only as a trigger to re-check.
  • Keep the Transaction.updates listener alive for the whole app lifetime and make its body idempotent.
  • Recreate or rotate sandbox testers between test cycles so one account does not accumulate stuck transactions.
  • Always check expirationDate and the verification result before granting an entitlement.

FAQ

Is requestEncodeFailed caused by my transaction handling?

No. It is an internal StoreKit error raised while committing the finish request in the Sandbox environment. The same code path succeeds against a local StoreKit configuration file, which is the quickest way to confirm the problem is not in your app.

Can I catch or retry the error in Swift?

Not directly. Transaction.finish() is async and non-throwing, so there is no error value to inspect. The only retry mechanism available to you is calling finish() again later, plus the server-side reset described above.

Why does purchase() return a transaction without showing the confirmation sheet?

StoreKit believes an unfinished purchase for that product already exists in the queue, so it resumes that transaction instead of starting a new one. Once the stale transaction expires, the result is a verified but expired transaction, which your expiry check should reject.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.