For auto-renewable subscriptions, it is necessary to call the transaction completion after the purchase.
I understand that this needs to be called at the correct timing.
StoreKit v1: https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/finishing_a_transaction
Question
If the transaction does not finish for a long period, is the purchase cancelled and a refund issued? If so, how long is this period?
For example
case1: When I renewed the auto-renewable subscription from the OS settings app but did not open my own app.
case2: When there was a malfunction in my own app.
Similar forum
https://developer.apple.com/forums/thread/656255
StoreKit
RSS for tagSupport in-app purchases and interactions with the App Store using StoreKit.
Selecting any option will automatically load the page
Post
Replies
Boosts
Views
Activity
This is a hybrid app built with JavaScript (Vue) + Capacitor. It is a reader app and has been authorized by Apple to use the External Link Account Entitlement, allowing users to manage their subscriptions outside of the app.
I have implemented the External Link Account API. When I click on "Gerenciar Assinatura em...", I use the External Link Account API to check if the modal is available (using ExternalLinkAccount.canOpen()). I always get "false".
my plugin in swift:
my app:
I believe this is due to the fact that I am in a development environment. My project is configured correctly in the following files: info.plist and App.entitlements. I also have the authorization in my profile visible in Xcode. I have attached screenshots for validation. The question is: should the External Link Account API work in a test environment? I am testing the build in Xcode with a physical iPhone with iOS 18.
file info.plist:
file App.entitlements:
xcode with authorization in my profile:
If you could let me know if I am doing something wrong, I would greatly appreciate it.
I don't know if this is a iOS 18.1 beta bug or some StoreKit server issues but Product.SubscriptionInfo.Status is returning an empty array in production even if the user has a valid subscription that is months away from expiring or renewing.
I myself ran into this issue this morning but of course everything is fine in development mode so that makes it quite challenging to debug.
Anyone else has this issue?
Hello,
We have been approved for the Advanced commerce API and we are trying to implement dynamically created subscriptions via the SubscriptionCreateRequest.
We followed the Sending Advanced Commerce API requests from your app (https://developer.apple.com/documentation/storekit/sending-advanced-commerce-api-requests-from-your-app) documentation but we are not able to make it work correctly.
We created a generic subscription in the Appstore connect, product ID: com.example.subscription
Then in the app we load the subscription:
try await Product.products(for: ["com.example.subscription"])
We do the JWS serialization on our backend and then we wrap the jwt and convert it to Data in the app as this:
let request = """
{
"signatureInfo": {
"token": "\(result.signedPayload)"
}
}
"""
let advancedCommerceRequestData = Data(request.utf8)
Lastly, we apply the purchase options on the generic product as this:
try await product.purchase(
options: [
Product.PurchaseOption.custom(
key: "advancedCommerceData",
value: advancedCommerceRequestData
)
]
)
It doesn't show any error, but on the payment sheet it shows the data from the generic subscription and not the data that was in the SubscriptionCreateRequest.
Here is an example of the generated jwt:
eyJraWQiOiI4V0tNQjhLWTI0IiwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJpc3MiOiI0MDZkYmEyOS04ZjIyLTQ3ZDUtYWI1Mi1kY2M2NTQ5OTE1Y2MiLCJiaWQiOiJjby5oZXJvaGVyby5IZXJvaGVybyIsImlhdCI6MTc0NjQzNTcxNCwiYXVkIjoiYWR2YW5jZWQtY29tbWVyY2UtYXBpIiwibm9uY2UiOiJhMzY2MGIwMS1kMDcyLTRlZDYtYmYyMS01MWU1Y2U5MDRmYTUiLCJyZXF1ZXN0IjoiZXlKdmNHVnlZWFJwYjI0aU9pSkRVa1ZCVkVWZlUxVkNVME5TU1ZCVVNVOU9JaXdpY21WeGRXVnpkRWx1Wm04aU9uc2ljbVZ4ZFdWemRGSmxabVZ5Wlc1alpVbGtJam9pTVdSaVlqZG1ZbVl0WWpFNE55MDBZMlJoTFRrNE16WXRNalUzTTJZeU1UaGpOekZpSW4wc0luTjBiM0psWm5KdmJuUWlPaUpEV2tVaUxDSjJaWEp6YVc5dUlqb2lNU0lzSW1OMWNuSmxibU41SWpvaVExcExJaXdpZEdGNFEyOWtaU0k2SWxNd01qRXRNRGd0TVNJc0ltUmxjMk55YVhCMGIzSnpJanA3SW1ScGMzQnNZWGxPWVcxbElqb2lVM1ZpYzJOeWFYQjBhVzl1SUZCbGRISWc0b0tzSURVaUxDSmtaWE5qY21sd2RHbHZiaUk2SWxOMVluTmpjbWx3ZEdsdmJpQlFaWFJ5SU9LQ3JDQTFJbjBzSW5CbGNtbHZaQ0k2SWxBeFRTSXNJbWwwWlcxeklqcGJleUprYVhOd2JHRjVUbUZ0WlNJNklsTjFZbk5qY21sd2RHbHZiaUJRWlhSeUlPS0NyQ0ExSWl3aVpHVnpZM0pwY0hScGIyNGlPaUpUZFdKelkzSnBjSFJwYjI0Z1VHVjBjaURpZ3F3Z05TSXNJbkJ5YVdObElqb3hOVEF3TUN3aWMydDFJam9pY1dkeGIzUnNlSEY1WVdGaFlsOTRiV3RvWlhWdGFHWjJhbXhtWDBWVlVqQTFJbjFkZlE9PSJ9.kJ0f_q2A11Mn9OBmvX6SRmtW5P--volFTVcq_Gohs3N51ECfZqS3WHOxOZc7aojq_qiUHGFp_evmHP51f3LzSw
Topic:
App & System Services
SubTopic:
StoreKit
Tags:
Subscriptions
StoreKit
In-App Purchase
Advanced Commerce API
I'm currently working on transitioning to StoreKit 2. In order to see if my users are legacy users who purchased the app before I implemented an in-app purchase, I am trying to use the original purchase date for the app. Unfortunately, it's returning 0 seconds since 1970.
func updateOriginalPurchaseStatus() async throws {
let transaction = try await checkVerified(AppTransaction.shared)
self.originalPurchaseVersion = transaction.originalAppVersion
self.originalPurchaseDate = transaction.originalPurchaseDate
}
This is from the transaction:
[3] = {
key = "originalPurchaseDate"
value = number (number = 0)
}
Currently trying to figure out when I actually purchased the app, but it might be as early as 2012. And I likely used a download code.
Hi everyone,
I’m struggling to get StoreKit 2 to fetch products in my SwiftUI app while using a sandbox user. I think I’ve followed all necessary setup steps in Xcode, App Store Connect, and my physical test device, but Product.products(for:) always returns an empty array. I’d appreciate any insights!
What I’ve Done
Local App Setup (Xcode 16.2)
Created a blank SwiftUI Xcode project.
Enabled In-App Purchase capability under Signing & Capabilities.
Implemented minimal StoreKit 2 code to fetch available products (see below).
Using the correct bundle identifier, which matches App Store Connect.
App Store Connect Configuration
Registered the app with the same bundle identifier.
Created an Auto-Renewable Subscription with:
Product ID: v1 (matches my code).
All fields filled (pricing, localization, etc.).
Status: Ready for Review.
Linked the subscription to the latest app version in App Store Connect.
Sandbox User & Testing Setup
Created a sandbox tester account.
Logged in with the sandbox user under Settings → Developer → Sandbox Apple ID. This was on my physical device (iOS 18.2).
Installed and ran the app directly from Xcode (⌘+R).
Issue: StoreKit Returns No Products
Product.products(for:) does not return any products.
There are no errors thrown, just an empty array.
I confirmed that StoreKit Configuration is set to None in Xcode.
No StoreKit-related logs appear in the Console.
Code Snippets
//StoreKitManager.swift
import StoreKit
import SwiftUI
@MainActor
class StoreKitManager: ObservableObject {
@Published var products: [Product] = []
@Published var errorMessage: String?
func fetchProducts() async {
do {
let productIDs: Set<String> = ["v1"] // Matches App Store Connect
let fetchedProducts = try await Product.products(for: productIDs)
print(fetchedProducts) // Debug output
DispatchQueue.main.async {
self.products = fetchedProducts
}
} catch {
DispatchQueue.main.async {
self.errorMessage = "Failed to fetch products: \(error.localizedDescription)"
}
}
}
}
//ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject private var storeKitManager = StoreKitManager()
var body: some View {
VStack {
if let errorMessage = storeKitManager.errorMessage {
Text(errorMessage).foregroundColor(.red)
} else if storeKitManager.products.isEmpty {
Text("No products available")
} else {
List(storeKitManager.products, id: \.id) { product in
VStack(alignment: .leading) {
Text(product.displayName).font(.headline)
Text(product.description).font(.subheadline)
Text("\(product.price.formatted(.currency(code: product.priceFormatStyle.currencyCode ?? "USD")))")
.bold()
}
}
}
Button("Fetch Products") {
Task {
await storeKitManager.fetchProducts()
}
}
}
.padding()
.onAppear {
Task {
await storeKitManager.fetchProducts()
}
}
}
}
#Preview {
ContentView()
}
Additional Information
iOS Version: 18.2
Xcode Version: 16.2
macOS Version: 15.3.1
Device: Physical iPhone (not simulator)
TestFlight Build: Not used (app is run directly from Xcode)
StoreKit Configuration: Set to None
Both the legacy StoreKit API and the new StoreKit 2 API return the incorrect storefront countryCode. My actual Apple ID region is Germany, and my Sandbox test user is set to France, yet the SDK consistently returns USA.
Expected Results:
The returned storefront countryCode should reflect the correct region - sandbox user region if signed in and real user region if not signed in with sandbox).
Actual Results:
Returned country code is USA with both
SKPaymentQueue.default().storefront?.countryCode
and
await Storefront.current?.countryCode.
Signing out/in, device reboot and even reset do not help, I'm stuck with USA storefront.
We have subscriptions for our app that are in the "waiting for review" state. The subscriptions page has the message "Your first subscription must be submitted with a new app version. Create your subscription, then select it from the app’s In-App Purchases and Subscriptions section on the version page before submitting the version to App Review."
However I don't see any section in the app to enter this. It seems to be a similar issue to what is being described at https://stackoverflow.com/questions/73098652/app-store-connect-in-app-purchase-and-subscriptions-section-missing?rq=2, but I have all requirements fulfilled; all compliance and tax forms have been completed, and I have made changes to my subscriptions in response to app review.
I did add these subscriptions to my app in a previous round in the approval process, and I thought that was why it wasn't appearing now. However, my app is now live and users are not seeing the subscriptions options when they try to enter the app (which had previously worked in sandbox mode)
In my app, I need the user to redeem a code outside the app and then capture the transaction in the callback/response from the App Store. How can I test this process?
Here's my function for opening a URL:
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
I am trying to get the transaction object in the TransactionObserver class:
// Implementation details...
}
Here is the link to this class.
However, I am not receiving the expected callback in this observer.
I am testing on a TestFlight build. My question is: Is it possible to test offer codes in TestFlight? Or is there something wrong with my implementation?
Topic:
App & System Services
SubTopic:
StoreKit
Tags:
Subscriptions
In-App Purchase
StoreKit Test
StoreKit
Hi everyone,
I'm experiencing an issue with APNs server notifications where I receive a 404 error when trying to validate the signedPayload from Apple's notification. Below is a sanitized version of my code:
class ServerNotificationAppleController extends Controller
{
// URL for StoreKit keys (Sandbox environment)
private $storeKitKeysUrl = 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/keys';
public function handleNotification(Request $request)
{
\Log::info($request);
$signedPayload = $request->input('signedPayload');
if (!$signedPayload) {
return response()->json(['error' => 'signedPayload not provided'], 400);
}
// Step 1: Create your JWT token (token creation logic can be in a separate service)
$jwtToken = $this->generateAppleJWT();
// Step 2: Send a request to the StoreKit keys endpoint
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $jwtToken,
])->get($this->storeKitKeysUrl);
Log::info('Apple Keys Status:', ['status' => $response->status()]);
Log::info('Apple Keys Body:', ['body' => $response->body()]);
if ($response->status() !== 200) {
return response()->json(['error' => "Apple public keys couldn't be retrieved"], 401);
}
$keysData = $response->json();
// Step 3: Validate the signedPayload
$validatedPayload = $this->validateSignedPayload($signedPayload, $keysData);
if (!$validatedPayload) {
return response()->json(['error' => 'Invalid signedPayload'], 400);
}
// Process the validated data as needed
Log::info("Apple Purchase Data:", (array)$validatedPayload);
return response()->json(['message' => 'Notification processed successfully'], 200);
}
private function generateAppleJWT()
{
// API key details (replace placeholders with actual values)
$keyId = config('services.apple.key_id'); // e.g., <YOUR_KEY_ID>
$issuerId = config('services.apple.issuer_id'); // e.g., <YOUR_ISSUER_ID>
$privateKey = file_get_contents(storage_path(config('services.apple.private_key')));
// Set current UTC time and expiration time (20 minutes later)
$nowUtc = Carbon::now('UTC');
$expirationUtc = $nowUtc->copy()->addMinutes(20);
// Create the payload with UTC timestamps
$payload = [
'iss' => $issuerId,
'iat' => $nowUtc->timestamp,
'exp' => $expirationUtc->timestamp,
'aud' => 'appstoreconnect-v1',
'bid' => 'com.example.app', // Replace with your Bundle ID if necessary
];
// Generate the JWT token
return JWT::encode($payload, $privateKey, 'ES256', $keyId);
}
private function validateSignedPayload($signedPayload, $keysData)
{
try {
$jwkKeys = JWK::parseKeySet($keysData);
return JWT::decode($signedPayload, $jwkKeys, ['RS256']);
} catch (\Exception $e) {
Log::error("Apple Purchase Validation Error: " . $e->getMessage());
return null;
}
}
}
I’m particularly puzzled by the fact that I receive a 404 error when trying to retrieve the public keys from the StoreKit keys endpoint. Has anyone encountered this issue or can provide insight into what might be causing the error?
Any help or suggestions would be greatly appreciated. Thanks!
Hello, thank you for your time. I'm using several physical devices to test IAPs in builds from xCode. Some of my test devices are logged into child accounts from my family account.
Child accounts "Ask Permission" from devices logged into adult accounts in the family. when you attempt to make a purchase. I'm hoping to be able to use these devices to test my IAPs but I get the following error after supplying the password for the sandbox account:
Unable to Ask Permission
You can't ask permission because you have signed in with iCloud and iTunes accounts that are not associated with each other.
[Environment: Sandbox]
Is there any way to make this work?
I have an iOS app with a main target and an app extension. I want the app extension to write data to the SwiftData in App Groups, while the main target reads the data. However, currently, the app extension only has permission to read data, not to write it.Is the issue due to my incorrect configuration or an Apple-imposed restriction?
Hi,
we had a case where one of our users wanted us to stop renewing his subscription in our app since he was unable to do it himself.
Is there a way for us to stop the auto renewal?
Kind regards
Ondřej Leitmančík
Hi All,
We are developing our app with an approved External Link Account Entitlement.
During the development process (such as installing from Xcode or creating an Ad-hoc build and installing it on a phone), the open() function of the External Link Account API displays the modal our native language. The app only localized to that language.
However, after uploading the app with the same configuration to TestFlight, the modal somehow appears in English instead.
What could be causing this issue with the External Link Account modal? How can the open() function display the modal in another language when installed from Xcode or an Ad-hoc release build, but in English when installed from TestFlight? How can we show only our native lanugage version only to our Users?
Thank you in advance
We use Transaction.currentEntitlements in StokeKit 2 to unlock functionality based on a Non-Consumable IAP but we have a case involving a refund that seems wrong and I am trying to understand the interation between transactionId, originalTransactionId & revocationReason.
The Context:
We have a universal App on macOS and iOS that offers a shared Non-Consumable IAP. For this example I have named it "app.lifetime"
On macOS we use StoreKit 2 and I am calling the Transaction.currentEntitlements and Transaction.all functions.
On iOS we are still using StoreKit 1.
This example customer:
Originally purchased "app.lifetime" on 2024-10-27
Was refunded by Apple for "app.lifetime" on 2024-10-29
Re-purchased "app.lifetime on 2025-02-24 (I have seen an email receipt of this transaction but it never shows up in Transaction data)
(all the above happened on the mac via StoreKit 2)
The Transactions (all lightly redacted for privacy):
on macOS the following is returned from Transaction.currentEntitlements...
{
"appTransactionId" : "...8123",
"bundleId" : "app",
"currency" : "USD",
"deviceVerification" : "...",
"deviceVerificationNonce" : "...",
"environment" : "Production",
"inAppOwnershipType" : "PURCHASED",
"originalPurchaseDate" : 1729997808000,
"originalTransactionId" : "...9955",
"price" : 1,
"productId" : "app.lifetime",
"purchaseDate" : 1729997808000,
"quantity" : 1,
"signedDate" : 1740416289102,
"storefront" : "USA",
"storefrontId" : "143441",
"transactionId" : "...7511",
"transactionReason" : "PURCHASE",
"type" : "Non-Consumable"
}
Note in the above example the originalTransactionId & transactionId are different. Transaction.all however returns both transactions:
[
{
"appTransactionId" : "...8123",
"bundleId" : "app",
"currency" : "USD",
"deviceVerification" : "...",
"deviceVerificationNonce" : "...",
"environment" : "Production",
"inAppOwnershipType" : "PURCHASED",
"originalPurchaseDate" : 1729997808000,
"originalTransactionId" : "...9955",
"price" : 1,
"productId" : "app.lifetime",
"purchaseDate" : 1729997808000,
"quantity" : 1,
"revocationDate" : 1730224102000,
"revocationReason" : 0,
"signedDate" : 1740415969925,
"storefront" : "USA",
"storefrontId" : "143441",
"transactionId" : "...9955",
"transactionReason" : "PURCHASE",
"type" : "Non-Consumable"
},
{
"appTransactionId" : "...8123",
"bundleId" : "app",
"currency" : "USD",
"deviceVerification" : "...",
"deviceVerificationNonce" : "...",
"environment" : "Production",
"inAppOwnershipType" : "PURCHASED",
"originalPurchaseDate" : 1729997808000,
"originalTransactionId" : "...9955",
"price" : 1,
"productId" : "app.lifetime",
"purchaseDate" : 1729997808000,
"quantity" : 1,
"signedDate" : 1740416289102,
"storefront" : "USA",
"storefrontId" : "143441",
"transactionId" : "...7511",
"transactionReason" : "PURCHASE",
"type" : "Non-Consumable"
}
]
Note here that the original transaction ("...9955") includes a revocationDate and revocationReason that match the expected refund but the secondary transaction that seems to match on all other details is missing the revocation info.
Looking at the iOS SK1 receipt data to compare, after a receipt refresh I see only a single transaction "...9955" which includes the cancellation info and transaction "...7511" is not present at all. The impact of this is that on iOS we are considering the purchase void but on macOS we are following currentEntitlements and consdering it still valid.
Calling the inApps/v1/history/... server API with the "...7511" transactionId that is shown in the currentEntitlements response returns the "...9955" transaction with the correct revocation status but "...7511" is no returned at all.
To Summarise:
currentEntitlements on macOS shows transaction "...7511" as active and with an originalTransactionId of "...9955"
all on macOS includes both "...7511" as active and "...9955" as revoked
iOS reciept data shows only "...9955" as revoked
Server API shows only "...9955" as revoked event when explicitly called with "...7511"
Neither of them show a more recent purchase the same customer made for the same IAP product.
My questions are:
Is this a StoreKit bug or am I mis-understanding something? If it's a bug how can I work around it to ensure revoked purchases aren't still appearing in currentEntitlements?
Under what conditions can StoreKit generate multiple transactionIds for the same underlying originalTransactionId? I had assumed (and the docs suggest) this only happens for subscriptions but here it is happening for a Non-Consumable IAP.
Why would transactionId "...7511" only be present on macOS/SK2 and not visible at all on iOS/SK1 or API?
I don't understand why the latest IAP from 2025-02-24 that the customer assures me they made (and has shown me the receipt for is not showing up in the Transactions history at all. Any ideas?
Dear Apple Support,
We are currently in the process of migrating from Apple Server Notifications v1 to v2. As you know, once we switch from v1 to v2 in App Store Connect, this change is irreversible. Our team needs to ensure that our backend environment, developed in Python with Django and using the app-store-server-library==1.5.0, is fully prepared to handle all possible notification types before making that final switch.
While we can receive a basic test notification from Apple, it doesn’t provide the range of data or scenarios (e.g., INITIAL_BUY, RENEWAL, GRACE_PERIOD, etc.) that our code must be able to process, store in our database, and respond to accordingly. Without comprehensive, example-rich raw notification data, we are left guessing the structure and full scope of incoming v2 notifications.
Could you please provide us with a set of raw, fully representative notification payloads for every notification type supported in v2? With these examples, we can simulate the entire lifecycle of subscription events, verify our logic, and confidently complete our migration.
Thank you very much for your assistance.
In the sandbox environment, when I quickly and repeatedly purchase an item, Transaction.id will be repeated.
Will there be duplication in the production environment?
func pay(productId: String, orderId: String) async {
guard !productId.isEmpty, !orderId.isEmpty else { return }
let orderObj = ApplePayOrderModel.init(orderId: orderId, productId: productId)
do {
let result = try await Product.products(for: [productId])
guard let product = result.first else {
return
}
let purchaseResult = try await product.purchase()
switch purchaseResult {
case .success(let verification):
switch verification {
case .verified(let transaction):
orderObj.transactionId = String(transaction.id)
await transaction.finish()
case .unverified(let transaction, let error):
await transaction.finish()
}
case .userCancelled:
break
case .pending:
break
@unknown default:
break
}
} catch {
print("error \(error.localizedDescription)")
}
}
Topic:
App & System Services
SubTopic:
StoreKit
Im building a small iphone app with StoreKit and currently testing it in testflight right on my mac, not on iphone. StoreKit part almost exactly copied from SKDemo from one of the Apple's WWDC. For some users and for myself Transaction.currentEntitlements always returns .unverified results. I double-checked Apple Connect settings, i checked my internet connection and everything is fine. Is there some pitfalls for testflight on mac? How can I find out what is causing this problem?
The majority of our sandbox calls to verifyReceipt end in an ETIMEDOUT error. This is making it very difficult to verify our purchase flow for our pending release. We have not yet migrated to StoreKit 2 and still rely on this API endpoint.
The Apple API status page reports no issues.
Is anyone else encountering this?
Hello,
Is anyone else seeing Purchase.PurchaseResult.UserCancelled, despite a successful transaction?
I had a user notify me today that he:
Attempted a purchase
Entered payment credentials
Was asked to opt in to email subscription notifications
Opted In
Was shown my app's "User Canceled Purchase" UI
Attempted to repurchase
Was alerted that he was "Already Subscribed"
I have adjusted my code to check Transaction.currentEntitlements on receiving a .userCancelled result, to avoid this in the future. Is this logically sound?
Here is my code - please let me know if you see any issues:
func purchase(product: Product, userId: String) async throws -> StoreKit.Transaction {
let purchaseUUID = UUID()
let options: Set<Product.PurchaseOption> = [.appAccountToken(purchaseUUID)]
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
guard case .verified(let tx) = verification else {
throw PurchaseError.verificationFailed // Show Error UI
}
return try await processVerified(tx)
case .userCancelled:
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result, tx.productID == product.id, tx.revocationDate == nil {
return try await processVerified(tx)
}
}
throw PurchaseError.cancelled // Show User Cancelled UI
case .pending:
throw PurchaseError.pending // Show Pending UI
@unknown default:
throw PurchaseError.unknown // Show Error UI
}
}
@MainActor
func processVerified(_ transaction: StoreKit.Transaction) async throws -> StoreKit.Transaction {
let id = String(transaction.id)
if await transactionCache.contains(id) {
await transaction.finish()
return transaction // Show Success UI
}
let (ok, error) = await notifyServer(transaction)
guard ok else {
throw error ?? PurchaseError.serverFailure(nil) // Show Error UI
}
await transaction.finish()
await transactionCache.insert(id)
return transaction // Show Success UI
}
The only place the "User Cancelled Purchase" UI is displayed on my app is after the one instance of "throw PurchaseError.cancelled" above.
This happened in Production, but I have also seen userCancelled happen unexpectedly in Sandbox.
Thank you for your time and help.