In-App Purchases and Subscriptions with StoreKit 2

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

FieldNotes is free, with an optional subscription, FieldNotes Pro, that unlocks unlimited notebooks, the augmented-reality map on Augmented Reality with ARKit and RealityKit and end-to-end encrypted note sharing from Security and Privacy. This page builds the store with StoreKit 2, the Swift-concurrency API that replaced the original SKPaymentQueue API, using the 27 releases (iOS, iPadOS, macOS, watchOS and visionOS 27), Xcode 27 and Swift 6.4, with the app’s deployment minimum of 26. The monthly-with-12-month-commitment billing plan needs 26.4 and the new offer-code redemption API needs 27, so both are guarded with if #available.

The page adds one type to FieldNotesServices: StoreManager, an @Observable @MainActor class that loads the two Pro products, runs purchases, listens for transactions from launch, and publishes whether the person is entitled to Pro. A StoreKit configuration file, Products.storekit, lets all of it run in Xcode without App Store Connect.

Products and What FieldNotes Sells

App Store Connect offers four product types:

Type Behaviour Example

Consumable

Used up, can be bought again; not restored

Extra AI summaries (FieldNotes does not sell these)

Non-consumable

Bought once, owned forever, restored on every device

A one-off "lifetime Pro" unlock

Auto-renewable subscription

Renews every period until cancelled; belongs to a subscription group

FieldNotes Pro, monthly or yearly

Non-renewing subscription

Time-limited access that does not renew; the app tracks expiry

A season pass

FieldNotes Pro is one subscription group, FieldNotes Pro, with two products. A person can hold only one product of a group at a time, and moving between them is an upgrade, downgrade or crossgrade that the App Store handles.

Product ID Period Notes

com.example.fieldnotes.pro.monthly

1 month

Billed monthly, cancel any time

com.example.fieldnotes.pro.yearly

1 year

Billed up front, or (26.4 and later) monthly with a 12-month commitment on the same product ID

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

Product, Transaction, AppStore.sync()

Yes

Yes

Yes

Yes

Yes

Product.purchase(options:)

Yes

Yes

Yes

Yes

Not available (use PurchaseAction)

PurchaseAction (@Environment(\.purchase))

Yes

Yes

Yes

Yes

Yes

StoreView, ProductView, SubscriptionStoreView

Yes

Yes

Yes

Yes

Yes

manageSubscriptionsSheet(isPresented:)

Yes

Yes

Not available (use the App Store app)

Not available

Yes

12-month commitment billing plan (pricingTerms, billingPlanType, commitmentInfo)

New in 26.4

New in 26.4

New in 26.4

New in 26.4

New in 26.4

offerCodeRedemption(options:isPresented:onCompletion:)

New in 27

New in 27

New in 27

Not available

New in 27

StoreKit testing in Xcode (SKTestSession)

Yes

Yes

Yes

Yes

Yes

Every example on this page needs a paid Apple Developer Program membership and a signed Paid Apps agreement in App Store Connect to sell real products; the local Products.storekit configuration described in StoreKit Configuration Files and Testing in Xcode needs neither.

How a Purchase Flows

In StoreKit 2 the App Store signs every transaction as a JWS (JSON Web Signature, the same format as a JWT, see JWT and JOSE). StoreKit verifies the signature on the device before the app sees it and hands the app a VerificationResult<Transaction>: .verified or .unverified. There is no receipt to parse. A purchase can complete in the app, but it can also complete elsewhere: after Ask to Buy approval, on another device, as a renewal at 3 a.m., or as a refund. All of those arrive through Transaction.updates, which is why the listener has to run from launch.

Figure: purchase to unlock

sequenceDiagram autonumber actor P as Person participant UI as SubscriptionStoreView participant SM as StoreManager participant SK as StoreKit (on device) participant AS as App Store Note over SM,SK: At launch, StoreManager starts listening to Transaction.updates P->>UI: taps Subscribe (FieldNotes Pro yearly) UI->>SK: purchase(product) SK->>P: system payment sheet, Face ID or password SK->>AS: purchase request AS-->>SK: signed JWS transaction SK->>SK: verify the JWS signature and bind it to this device SK-->>UI: PurchaseResult.success(VerificationResult) UI->>SM: onInAppPurchaseCompletion SM->>SM: .verified: refresh entitlements, isPro = true SM->>SK: transaction.finish() Note over AS,SM: Later: renewal, refund, Ask to Buy approval or purchase on another device AS-->>SK: new signed JWS transaction SK-->>SM: Transaction.updates yields VerificationResult SM->>SM: refresh entitlements, unlock or lock Pro SM->>SK: transaction.finish()

StoreManager

StoreManager lives in FieldNotesServices so every target, including the widget, can ask whether Pro is unlocked. The FieldNotesApp scene owns one instance for the app’s lifetime.

// Packages/FieldNotesKit/Sources/FieldNotesServices/Store/StoreManager.swift
import Foundation
import Observation
import StoreKit

@Observable @MainActor
public final class StoreManager {
    public static let productIDs: [Product.ID] = [
        "com.example.fieldnotes.pro.monthly",
        "com.example.fieldnotes.pro.yearly",
    ]
    /// The "FieldNotes Pro" group ID from App Store Connect (and from Products.storekit).
    public static let proGroupID = "21500001"

    public private(set) var products: [Product] = []
    public private(set) var isPro = false
    public private(set) var proRenewalState: Product.SubscriptionInfo.RenewalState?

    @ObservationIgnored private var updatesTask: Task<Void, Never>?

    public init() {
        // Start listening before anything else, so no transaction is missed.
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                await self?.handle(result)
            }
        }
    }

    public func loadProducts() async {
        do {
            products = try await Product.products(for: Self.productIDs)
                .sorted { $0.price < $1.price }
        } catch {
            products = []          // offline or misconfigured: the store UI shows a retry button
        }
    }
}

Product.products(for:) returns only the IDs the App Store knows for the current storefront, localized, with displayName, description, displayPrice (already formatted in the person’s currency) and, for subscriptions, a subscription property with the period, group and offers. Never hard-code prices.

The Task created in init inherits the main actor, so handle(_:) runs on the main actor and can update isPro directly. The [weak self] capture keeps the loop from retaining the manager; in FieldNotes the manager lives as long as the app, so the task never ends. How tasks inherit isolation is covered on Async/Await and Tasks and Concurrency in Apps.

The app creates it at launch and injects it next to the note store:

// FieldNotes/FieldNotesApp.swift (excerpt)
import FieldNotesServices
import SwiftUI

@main
struct FieldNotesApp: App {
    @State private var store = AppServices.shared.store   // the single owner, see Background Execution
    @State private var storeManager = StoreManager()   // Transaction.updates starts here

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
                .environment(storeManager)
                .task {
                    await storeManager.loadProducts()
                    await storeManager.refreshEntitlements()
                }
        }
    }
}

Purchasing

A purchase needs a UI context to anchor the payment sheet. Product.purchase(options:) finds one on its own on iOS, macOS and watchOS but does not exist on visionOS, and the purchase(confirmIn:options:) variants take a UIKit scene or an AppKit window. The portable choice in SwiftUI is PurchaseAction, read from the environment as @Environment(\.purchase). StoreManager accepts it as a parameter, so the manager stays free of UI code:

// StoreManager.swift (continued)
extension StoreManager {
    public enum PurchaseOutcome { case unlocked, pending, cancelled }

    public func purchase(_ product: Product, using purchaseAction: PurchaseAction,
                         accountID: UUID? = nil) async throws -> PurchaseOutcome {
        var options: Set<Product.PurchaseOption> = []
        if let accountID { options.insert(.appAccountToken(accountID)) }   // ties it to the FieldNotes account

        switch try await purchaseAction(product, options: options) {
        case .success(let verification):
            await handle(verification)
            return isPro ? .unlocked : .pending
        case .pending:
            return .pending          // Ask to Buy or Strong Customer Authentication; arrives via updates
        case .userCancelled:
            return .cancelled
        @unknown default:
            return .cancelled
        }
    }
}

// FieldNotes/Views/ProUpgradeButton.swift
struct ProUpgradeButton: View {
    @Environment(StoreManager.self) private var storeManager
    @Environment(\.purchase) private var purchase
    let product: Product

    var body: some View {
        Button("Subscribe for \(product.displayPrice)") {
            Task { _ = try? await storeManager.purchase(product, using: purchase) }
        }
    }
}

.appAccountToken(_:) stores a UUID of the app’s choosing in the signed transaction. FieldNotes passes the account ID from AccountManager, so its server can match App Store Server Notifications to the right account. .pending is not a failure: the transaction may still succeed hours later and will arrive through Transaction.updates.

Verifying and Finishing Transactions

handle(_:) is the single place where a transaction changes what the person can do. It is called from the purchase result and from the updates listener.

// StoreManager.swift (continued)
extension StoreManager {
    func handle(_ result: VerificationResult<Transaction>) async {
        switch result {
        case .verified(let transaction):
            await refreshEntitlements()
            await transaction.finish()            // tell the App Store the content was delivered
        case .unverified(let transaction, let error):
            // Signed by someone else, or for another device: never unlock anything.
            print("Unverified transaction \(transaction.id): \(error)")
        }
    }
}

Only finish a transaction after the app has delivered what was bought. An unfinished transaction is delivered again through Transaction.updates at the next launch, which is the retry mechanism. For consumables, finishing is essential: until it happens the same product cannot be bought again.

On-device verification protects against a tampered app or a replayed transaction on another device, but a jailbroken device can still lie to its own app. When Pro unlocks a server feature, such as sharing through the FieldNotes server, the server must also check the purchase with the App Store Server API or App Store Server Notifications V2; the VerificationResult also exposes the raw jwsRepresentation for that purpose.

Entitlements and Subscription Status

Transaction.currentEntitlements yields the latest transaction for every product the person is currently entitled to: active subscriptions, owned non-consumables, including purchases made on other devices and through Family Sharing. It is the source of truth for isPro:

// StoreManager.swift (continued)
extension StoreManager {
    public func refreshEntitlements() async {
        var entitled = false
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result,
                  Self.productIDs.contains(transaction.productID),
                  transaction.revocationDate == nil,              // not refunded
                  !transaction.isUpgraded else { continue }      // superseded by another product
            if let expiry = transaction.expirationDate, expiry < .now { continue }
            entitled = true
        }
        isPro = entitled
    }
}

isPro is published through Observation, so every view that reads it re-renders when a renewal or refund arrives. To check a single product, Transaction.currentEntitlements(for:) (18.4 and later, so always available at FieldNotes’s minimum) filters by product ID.

Entitlement answers whether Pro is active. Subscription status answers how: subscribed, in a billing grace period, in billing retry, expired or revoked. FieldNotes uses it to show a "Payment problem, update your payment method" banner while a subscription is in grace period, instead of silently locking the person out:

// StoreManager.swift (continued)
extension StoreManager {
    public func refreshStatus() async {
        let statuses = (try? await Product.SubscriptionInfo.status(for: Self.proGroupID)) ?? []
        proRenewalState = statuses
            .filter { if case .verified = $0.transaction { true } else { false } }
            .map(\.state)
            .first
    }
}

// In a view, the modifier form keeps the status current without a manual refresh:
// .subscriptionStatusTask(for: StoreManager.proGroupID) { taskState in
//     if case .success(let statuses) = taskState,
//        statuses.contains(where: { $0.state == .inGracePeriod }) { showPaymentBanner = true }
// }

A person with Family Sharing may have more than one status for the same group (their own and a family member’s); FieldNotes takes the first verified one, which is enough for a banner.

Restoring Purchases with AppStore.sync()

Because currentEntitlements already includes purchases made on other devices, most people never need to restore anything. App Review still requires a visible Restore Purchases control for non-consumables and subscriptions, for the rare case where the device’s transaction data is out of date. AppStore.sync() forces a fresh sync with the App Store and asks the person to authenticate, so call it only from that explicit button:

// FieldNotes/Views/SettingsView+Store.swift (excerpt)
Button("Restore Purchases") {
    Task {
        try? await AppStore.sync()
        await storeManager.refreshEntitlements()
    }
}

Merchandising with StoreKit Views

StoreKit’s SwiftUI views load products, format prices, show the right legal text and buttons for each platform, and run the purchase. They are the fastest way to a store that passes review.

SubscriptionStoreView for FieldNotes Pro

SubscriptionStoreView(groupID:) shows every product in a subscription group, with custom marketing content on top. The .storeButton(_:for:) modifier adds the restore and redeem-code buttons, and .onInAppPurchaseCompletion routes the result to StoreManager:

// FieldNotes/Views/ProStoreView.swift
import FieldNotesServices
import StoreKit
import SwiftUI

struct ProStoreView: View {
    @Environment(StoreManager.self) private var storeManager
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        SubscriptionStoreView(groupID: StoreManager.proGroupID) {
            VStack(spacing: 12) {
                Image(systemName: "note.text.badge.plus")
                    .font(.system(size: 56))
                    .foregroundStyle(.tint)
                Text("FieldNotes Pro").font(.largeTitle.bold())
                Text("Unlimited notebooks, the AR notes map and encrypted sharing.")
                    .multilineTextAlignment(.center)
            }
            .padding()
        }
        #if os(watchOS)
        .storeButton(.visible, for: .restorePurchases)
        #else
        .subscriptionStoreControlStyle(.picker)
        .storeButton(.visible, for: .restorePurchases, .redeemCode)
        #endif
        .onInAppPurchaseCompletion { _, result in
            if case .success(.success(let verification)) = result {
                await storeManager.handle(verification)
                if storeManager.isPro { dismiss() }
            }
        }
    }
}

#Preview {
    ProStoreView()
        .environment(StoreManager())
}

.picker and the redeem-code button are not available on watchOS, hence the #if. The preview works without App Store Connect when the scheme’s StoreKit configuration is Products.storekit (see StoreKit Configuration Files and Testing in Xcode). On visionOS the same view renders in a window with glass material; on watchOS it becomes a scrolling list. The design guidance for paywalls, including what must be visible before someone subscribes, is in Human Interface Guidelines — In-app purchase.

StoreView and ProductView

StoreView(ids:) merchandises any set of products, not only one subscription group, and ProductView(id:) shows a single product inline, for example as a row in SettingsView:

// SettingsView (excerpt): an inline upsell row, hidden once Pro is active
if !storeManager.isPro {
    ProductView(id: "com.example.fieldnotes.pro.yearly") {
        Image(systemName: "star.circle.fill")
    }
    #if os(iOS) || os(macOS)
    .productViewStyle(.compact)          // not available on watchOS and visionOS
    #endif
}

// A full store of every FieldNotes product
StoreView(ids: StoreManager.productIDs)

Managing the subscription

People manage and cancel subscriptions in the system’s subscription settings. On iOS, iPadOS and visionOS the app can open them in a sheet, scoped to the Pro group so the commitment progress described below is shown:

#if os(iOS) || os(visionOS)
struct ManageProButton: View {
    @State private var isPresented = false

    var body: some View {
        Button("Manage Subscription") { isPresented = true }
            .manageSubscriptionsSheet(isPresented: $isPresented,
                                      subscriptionGroupID: StoreManager.proGroupID)
    }
}
#endif

On macOS and watchOS the sheet is not available; link to the App Store’s subscription page instead.

Monthly Subscriptions with a 12-Month Commitment

The 2026 billing option lets people pay for a yearly subscription monthly, in exchange for committing to twelve payments. It is not a new product: in App Store Connect you enable the monthly billing plan on an existing 1-year subscription, so com.example.fieldnotes.pro.yearly keeps its product ID and now offers two pricing terms: billed up front, or billed monthly with a 12-month commitment. Cancelling stops the commitment from renewing, but the remaining payments of the current commitment are still billed.

The APIs arrived in 26.4 and need an app built with the Xcode 26.5 SDK or later. The billing plan is offered worldwide except in the United States and Singapore; in those storefronts pricingTerms contains only the up-front entry. Because FieldNotes’s minimum is 26, every use is guarded:

// StoreManager.swift (continued)
extension StoreManager {
    /// The yearly product's monthly commitment plan, if this storefront offers it.
    @available(iOS 26.4, macOS 26.4, watchOS 26.4, visionOS 26.4, *)
    public func commitmentTerms(for product: Product) -> Product.SubscriptionInfo.PricingTerms? {
        product.subscription?.pricingTerms.first { $0.billingPlanType == .monthly }
    }

    @available(iOS 26.4, macOS 26.4, watchOS 26.4, visionOS 26.4, *)
    public func purchaseCommitment(_ product: Product, using purchaseAction: PurchaseAction) async throws {
        let result = try await purchaseAction(product, options: [.billingPlanType(.monthly)])
        if case .success(let verification) = result { await handle(verification) }
    }
}

// FieldNotes/Views/CommitmentOfferRow.swift
struct CommitmentOfferRow: View {
    @Environment(StoreManager.self) private var storeManager
    @Environment(\.purchase) private var purchase
    let yearly: Product

    var body: some View {
        if #available(iOS 26.4, macOS 26.4, watchOS 26.4, visionOS 26.4, *),
           let terms = storeManager.commitmentTerms(for: yearly) {
            VStack(alignment: .leading) {
                // Both amounts must be visible before the person subscribes.
                Text("\(terms.billingDisplayPrice) per month for 12 months")
                Text("Total commitment: \(terms.commitmentInfo.displayPrice)")
                    .font(.footnote).foregroundStyle(.secondary)
                Button("Pay Monthly") {
                    Task { try? await storeManager.purchaseCommitment(yearly, using: purchase) }
                }
            }
        }
    }
}

With SubscriptionStoreView, a single modifier makes the store feature the commitment plan instead:

SubscriptionStoreView(groupID: StoreManager.proGroupID)
    .preferredSubscriptionPricingTerms { _, subscriptionInfo in
        subscriptionInfo.pricingTerms.first { $0.billingPlanType == .monthly }
    }

In the app, each monthly payment is an ordinary transaction that grants one month; the entitlement logic in refreshEntitlements() needs no change, because it already checks the transaction’s own expirationDate. The transaction’s commitmentInfo (billingPeriodNumber, totalBillingPeriods, price, expirationDate) is only for showing progress such as "payment 4 of 12". Never use commitmentInfo.expirationDate to decide access: a billing issue that recovers late moves it. Billing grace period does not apply to commitment plans, so revoke access while a monthly payment is in billing retry.

@available(iOS 26.4, macOS 26.4, watchOS 26.4, visionOS 26.4, *)
func commitmentProgress(of transaction: Transaction) -> String? {
    guard transaction.billingPlanType == .monthly, let info = transaction.commitmentInfo else { return nil }
    return "Payment \(info.billingPeriodNumber) of \(info.totalBillingPeriods)"
}

The first time someone subscribes to a commitment plan, the system shows a one-time disclosure sheet with the number of payments and cancellation terms; the app does nothing for it. On the server, the plan adds billingPlanType and commitmentInfo fields to the decoded JWS transaction and renewal info, and the life-cycle article lists which App Store Server Notifications (DID_RENEW, DID_CHANGE_RENEWAL_STATUS, DID_FAIL_TO_RENEW, EXPIRED) to handle.

Offer Codes (27)

Offer codes give a free or discounted period, for example to a university field course. In 27 the SwiftUI redemption sheet takes a set of RedeemOption values and returns the resulting transaction as a VerificationResult, so the app can unlock immediately instead of waiting for Transaction.updates:

struct RedeemCodeButton: View {
    @Environment(StoreManager.self) private var storeManager
    @State private var isPresented = false

    var body: some View {
        Button("Redeem Code") { isPresented = true }
        #if !os(watchOS)
            .modifier(RedeemSheet(isPresented: $isPresented, storeManager: storeManager))
        #endif
    }
}

#if !os(watchOS)
private struct RedeemSheet: ViewModifier {
    @Binding var isPresented: Bool
    let storeManager: StoreManager

    func body(content: Content) -> some View {
        if #available(iOS 27, macOS 27, visionOS 27, *) {
            content.offerCodeRedemption(options: [], isPresented: $isPresented) { result in
                if case .success(let verification) = result {
                    Task { await storeManager.handle(verification) }
                }
            }
        } else {
            content.offerCodeRedemption(isPresented: $isPresented)   // 26: the result arrives via updates
        }
    }
}
#endif

RedeemSheet is a page-local helper. Xcode 27 can also test offer-code redemption locally.

StoreKit Configuration Files and Testing in Xcode

A StoreKit configuration file describes products, subscription groups, prices and offers locally. When a scheme’s Run > Options > StoreKit Configuration points at it, StoreKit talks to Xcode’s local test environment instead of the App Store: no network, no sandbox account, instant renewals, and a Transaction Manager (Debug > StoreKit > Manage Transactions) to approve Ask to Buy, refund, expire or fail renewals. Create one with File > New > File from Template > StoreKit Configuration File, name it Products.storekit, and either add the products by hand or sync them from App Store Connect.

The file is JSON that Xcode’s editor maintains; edit it in the editor, not by hand. An abbreviated excerpt shows its shape:

{
  "subscriptionGroups" : [
    {
      "id" : "21500001",
      "name" : "FieldNotes Pro",
      "subscriptions" : [
        {
          "productID" : "com.example.fieldnotes.pro.monthly",
          "displayPrice" : "3.99",
          "recurringSubscriptionPeriod" : "P1M",
          "groupNumber" : 2,
          "type" : "RecurringSubscription",
          "localizations" : [ { "locale" : "en_US", "displayName" : "FieldNotes Pro Monthly",
                                "description" : "All Pro features, billed monthly" } ]
        },
        {
          "productID" : "com.example.fieldnotes.pro.yearly",
          "displayPrice" : "34.99",
          "recurringSubscriptionPeriod" : "P1Y",
          "groupNumber" : 1,
          "type" : "RecurringSubscription",
          "localizations" : [ { "locale" : "en_US", "displayName" : "FieldNotes Pro Yearly",
                                "description" : "All Pro features, billed yearly" } ]
        }
      ]
    }
  ]
}

Since Xcode 26.5, the editor’s Billing Plan picker adds the monthly-with-12-month-commitment plan to the yearly product, and the Transaction inspector shows the resulting commitmentInfo. Editor > Subscription Renewal Rate speeds renewals up (for example, a monthly renewal every 30 seconds) to test expiry and win-back offers.

Unit tests drive the same file through SKTestSession from the StoreKitTest framework. Add Products.storekit to the test target’s resources, then:

// FieldNotesKitTests/StoreManagerTests.swift
import FieldNotesServices
import StoreKit
import StoreKitTest
import Testing

@MainActor
struct StoreManagerTests {
    @Test func buyingYearlyUnlocksPro() async throws {
        let session = try SKTestSession(configurationFileNamed: "Products")
        session.disableDialogs = true
        session.clearTransactions()

        let manager = StoreManager()
        _ = try await session.buyProduct(identifier: "com.example.fieldnotes.pro.yearly", options: [])
        await manager.refreshEntitlements()
        #expect(manager.isPro)

        try session.expireSubscription(productIdentifier: "com.example.fieldnotes.pro.yearly")
        await manager.refreshEntitlements()
        #expect(!manager.isPro)
    }
}

Swift Testing itself is covered on Unit Testing with Swift Testing and Swift Testing.

The three test environments build on each other:

Environment Needs Use it for

Xcode (Products.storekit)

Nothing: no account, no network

Daily development, previews, unit and UI tests, edge cases via the Transaction Manager

Sandbox

App Store Connect products, a Sandbox Apple Account, a paid developer account

Real signed transactions, the server’s App Store Server API and Notifications V2 handling

TestFlight

A TestFlight build

Beta testers buying with accelerated renewals, never charged

App Review and Payment Rules

The App Review Guidelines' section 3.1 governs payments. The rules FieldNotes has to follow:

  • 3.1.1: digital content and features used in the app, such as FieldNotes Pro, must be sold through In-App Purchase. Physical goods and person-to-person services use other payment methods.

  • 3.1.2: auto-renewable subscriptions must provide ongoing value, work on all the app’s supported devices, and clearly state what the person gets, the price, the period and, for commitment plans, the total commitment before purchase. Terms of use and a privacy policy link must be reachable from the paywall.

  • Restore: a way to restore purchases must exist (AppStore.sync() or the .restorePurchases store button).

  • Regional exceptions: some storefronts allow links to, or alternative, external payment. StoreKit’s External Purchase APIs implement those programmes and require an entitlement per region; FieldNotes does not use them.

In-App Purchases are submitted for review with the app. In 2026 App Store Connect groups products, in-app events and custom product pages into one review submission, and the App Store Connect API’s reviewSubmissions resources cover In-App Purchases too. Submission, metadata and TestFlight are covered on App Store Connect, TestFlight and App Review.

References