Sell Annual Subscriptions As Monthly Payments With StoreKit

Annual subscriptions convert well when the customer already trusts the product, but the up-front price can still be a hard stop. A language app might be worth $79.99 a year, a strength training app might be worth $119.99 a year, and a pro creator app might be worth much more, but asking for the entire amount today can make the paywall feel heavier than the value.

StoreKit now has a more flexible option for that exact case: a monthly subscription with a 12-month commitment.

The important wording is “monthly subscription with a 12-month commitment.” The customer pays every month, but they are committing to the year. Each monthly billing period creates its own transaction and grants one month of access, while the commitment continues across 12 billing periods.

After the 12th payment, the commitment renews into another 12-month commitment unless the customer cancels before the renewal date.

That can be a good fit when your product has durable, year-round value:

  • a learning app where the user expects a full course or curriculum
  • a fitness app with progressive programs and habit tracking
  • a professional editor, design tool, analytics app, or creator workflow
  • a business app where the value is ongoing and switching costs are real

It is a worse fit when the value is short-lived, seasonal, or casual. If someone wants a meditation pack for one stressful month, a summer travel guide, or a novelty filter app, a yearly commitment paid monthly can feel like a billing trap even if the App Store disclosure is technically clear. This feature should lower payment friction, not hide the real commitment.

What changes

This is not a new product type. You configure the monthly billing plan on a new or existing one-year auto-renewable subscription in App Store Connect. The product ID stays the same. The subscription group stays the same. You are adding another billing plan to the yearly product.

That detail affects almost every line of app code. If your annual product ID is com.example.app.pro.yearly, the up-front annual plan and the monthly commitment plan both use that same product ID. The difference is the billing plan type:

  • .upFront for the normal annual subscription paid in full
  • .monthly for the monthly subscription with a 12-month commitment

Apple’s documentation says this requires the Xcode 26.5 SDK or later. It is available worldwide except the United States and Singapore, and it requires customer devices running iOS 26.4, iPadOS 26.4, macOS 26.4, tvOS 26.4, or visionOS 26.4 or later.

Because this post is being written against beta-era StoreKit 26.5 APIs, treat the exact names as something to recheck in the current Xcode documentation before shipping. The shape is clear, but beta SDKs can move.

Find the monthly billing plan

Fetch the product the same way you already fetch StoreKit products. The new metadata lives under Product.SubscriptionInfo.pricingTerms.

For a normal subscription, pricingTerms contains the default up-front billing plan. For a one-year subscription where the monthly commitment plan is available in the customer’s storefront, it contains an additional entry whose billingPlanType is .monthly.

import StoreKit

enum ProProducts {
    static let yearly = "com.example.app.pro.yearly"
}

@available(iOS 26.4, *)
struct CommitmentBillingPlan {
    let product: Product
    let terms: Product.SubscriptionInfo.PricingTerms

    var monthlyPriceText: String {
        terms.billingDisplayPrice
    }

    var totalCommitmentText: String {
        terms.commitmentInfo.displayPrice
    }

    var periodText: String {
        "12 monthly payments"
    }
}

@available(iOS 26.4, *)
func monthlyCommitmentPlan(for product: Product) -> CommitmentBillingPlan? {
    guard
        let terms = product.subscription?.pricingTerms.first(where: {
            $0.billingPlanType == .monthly
        })
    else {
        return nil
    }

    return CommitmentBillingPlan(product: product, terms: terms)
}

Do not assume this plan exists just because you enabled it in App Store Connect. The metadata is storefront-dependent, and the feature is not available in every country. Your paywall needs a fallback path that shows the standard up-front annual plan, another eligible product, or no annual option.

Also notice the two prices:

  • billingDisplayPrice is the monthly amount the customer pays for each billing period.
  • commitmentInfo.displayPrice is the total amount for the 12-month commitment.

Show both before purchase. A good paywall line is direct:

$9.99/month for 12 months. Total commitment: $119.88.

Avoid softer copy like “annual for only $9.99/month” unless the same screen clearly explains the required 12 payments and total commitment amount.

If you merchandise offers, read them from the selected pricing terms entry. Offers are billing-plan specific, so do not assume the product’s top-level subscription offers apply to the monthly commitment plan.

let introductoryOffers = terms[offers: .introductory]
let promotionalOffers = terms[offers: .promotional]

Render an honest custom paywall

Here is a compact SwiftUI paywall section that only shows the monthly commitment CTA when StoreKit says the billing plan is available.

import StoreKit
import SwiftUI

@available(iOS 26.4, *)
struct AnnualSubscriptionChoice: View {
    let product: Product
    let buyMonthlyCommitment: (Product) async -> Void
    let buyUpFront: (Product) async -> Void

    private var monthlyPlan: CommitmentBillingPlan? {
        monthlyCommitmentPlan(for: product)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Pro Annual")
                .font(.headline)

            Text("Full access for serious, year-round use.")
                .foregroundStyle(.secondary)

            if let monthlyPlan {
                VStack(alignment: .leading, spacing: 8) {
                    Text("\(monthlyPlan.monthlyPriceText)/month")
                        .font(.title3.bold())

                    Text("\(monthlyPlan.periodText). Total commitment: \(monthlyPlan.totalCommitmentText).")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)

                    Button {
                        Task { await buyMonthlyCommitment(product) }
                    } label: {
                        Text("Pay Monthly")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                }
            }

            Button {
                Task { await buyUpFront(product) }
            } label: {
                Text("Pay \(product.displayPrice) Up Front")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.bordered)
        }
    }
}

The product is still the yearly product. The monthly button does not purchase a different product ID. It purchases the same product with a billing plan option.

Purchase the monthly plan

The purchase call is familiar, but you pass a new purchase option:

import StoreKit

enum StoreError: Error {
    case failedVerification
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .verified(let value):
        return value
    case .unverified:
        throw StoreError.failedVerification
    }
}

@available(iOS 26.4, *)
func purchaseMonthlyCommitment(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase(options: [.billingPlanType(.monthly)])

    switch result {
    case .success(let verificationResult):
        let transaction = try checkVerified(verificationResult)

        // Update local entitlement state or send the signed transaction to your server.
        await transaction.finish()
        return transaction

    case .pending:
        return nil

    case .userCancelled:
        return nil

    @unknown default:
        return nil
    }
}

The first time a customer buys this kind of plan, the App Store presents a system disclosure sheet that explains the monthly payments and cancellation behavior. You still need honest merchandising before that sheet appears. The system disclosure is a safety net, not your paywall copy.

Entitlement is current period, not full commitment

This is the easiest bug to introduce.

For access control, treat each billing period like a normal monthly subscription. Each period produces an independent transaction. That transaction grants access for that month. The full commitment expiration is not the entitlement expiration.

Use Transaction.currentEntitlements at launch and Transaction.updates while the app is running, just like you would for other StoreKit subscriptions. I covered that general entitlement pattern in StoreKit Entitlements: currentEntitlements vs updates, so I will keep the sample here focused on the commitment-specific part.

import Foundation
import StoreKit

@available(iOS 26.4, *)
struct SubscriptionAccess {
    let productID: String
    let billingPlanType: Product.SubscriptionInfo.BillingPlanType?
    let currentPeriodExpiration: Date?
    let commitment: Transaction.CommitmentInfo?

    var isInCommitment: Bool {
        billingPlanType == .monthly && commitment != nil
    }
}

@available(iOS 26.4, *)
func access(from currentEntitlement: Transaction) -> SubscriptionAccess? {
    guard
        currentEntitlement.revocationDate == nil,
        currentEntitlement.isUpgraded == false
    else {
        return nil
    }

    return SubscriptionAccess(
        productID: currentEntitlement.productID,
        billingPlanType: currentEntitlement.billingPlanType,
        currentPeriodExpiration: currentEntitlement.expirationDate,
        commitment: currentEntitlement.commitmentInfo
    )
}

This helper assumes the transaction came from Transaction.currentEntitlements, Transaction.updates, or your subscription-status pipeline. Do not casually replace that source with expirationDate > now. Auto-renewable subscription policy can include grace periods and billing-retry behavior, so use expirationDate for current-period display and for policy only when your grace-period behavior is explicit.

For a monthly commitment plan, transaction.commitmentInfo?.expirationDate means the end of the 12-month commitment. That is useful for progress UI, support screens, and account summaries. It is not the date you use to unlock a year of access up front.

This distinction matters in real life. If payment for month six fails and later recovers, or if the customer upgrades to a higher tier, the latest transaction is the one that tells the truth. Store and display commitment context if you want, but keep entitlement derived from the current verified transaction.

Display commitment progress

For a custom account screen, read Transaction.commitmentInfo from the latest verified transaction for the subscription.

import Foundation
import StoreKit

@available(iOS 26.4, *)
struct CommitmentProgress {
    let billingPeriodNumber: UInt64
    let totalBillingPeriods: UInt64
    let commitmentExpirationDate: Date
    let totalCommitmentPrice: Decimal

    var completedBillingPeriods: UInt64 {
        min(billingPeriodNumber, totalBillingPeriods)
    }

    var remainingBillingPeriods: UInt64 {
        totalBillingPeriods - completedBillingPeriods
    }

    var fractionComplete: Double {
        guard totalBillingPeriods > 0 else { return 0 }
        return Double(completedBillingPeriods) / Double(totalBillingPeriods)
    }
}

@available(iOS 26.4, *)
func commitmentProgress(from transaction: Transaction) -> CommitmentProgress? {
    guard
        transaction.billingPlanType == .monthly,
        let info = transaction.commitmentInfo
    else {
        return nil
    }

    return CommitmentProgress(
        billingPeriodNumber: info.billingPeriodNumber,
        totalBillingPeriods: info.totalBillingPeriods,
        commitmentExpirationDate: info.expirationDate,
        totalCommitmentPrice: info.price
    )
}

You can render that as “Payment 4 of 12” or “8 payments remaining.” Keep it informational. The customer is already able to see the authoritative progress and renewal state in the system subscription management UI, so your custom UI should avoid implying that your app can cancel the remaining scheduled payments directly.

For most apps, I would show a simple account row:

import SwiftUI

@available(iOS 26.4, *)
struct CommitmentProgressRow: View {
    let progress: CommitmentProgress

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Annual commitment")
                .font(.headline)

            ProgressView(value: progress.fractionComplete)

            Text("Payment \(progress.billingPeriodNumber) of \(progress.totalBillingPeriods)")
                .foregroundStyle(.secondary)

            Text("\(progress.remainingBillingPeriods) payments remaining")
                .foregroundStyle(.secondary)
        }
    }
}

Do not format Transaction.CommitmentInfo.price with only the current locale. A bare Decimal does not carry the customer’s storefront currency. Prefer Product.SubscriptionInfo.PricingTerms.commitmentInfo.displayPrice when product metadata is available, or persist the localized display price you showed at purchase time. Treat the transaction price as calculation and server-comparison data.

StoreKit SwiftUI can merchandise it

If you use SubscriptionStoreView, you do not need to build all of this selection UI yourself. StoreKit SwiftUI has preferredSubscriptionPricingTerms, which lets you prefer a pricing terms entry for a subscription.

import StoreKit
import SwiftUI

@available(iOS 26.4, *)
struct ProSubscriptionStore: View {
    let groupID: String

    var body: some View {
        SubscriptionStoreView(groupID: groupID) {
            VStack(alignment: .leading) {
                Text("Pro")
                    .font(.largeTitle.bold())
                Text("Year-round access with monthly or up-front billing.")
            }
        }
        .preferredSubscriptionPricingTerms { _, subscriptionInfo in
            subscriptionInfo.pricingTerms.first {
                $0.billingPlanType == .monthly
            }
        }
    }
}

That modifier is merchandising preference, not an entitlement shortcut. You still process transactions the same way, and you still need to think clearly about current-period access versus full commitment progress.

Provide management UI

The system subscription management UI knows how to show the customer’s commitment progress, remaining payments, cancellation guidance, available plans, and renewal behavior. Link to it from your account screen.

In SwiftUI, use manageSubscriptionsSheet:

import StoreKit
import SwiftUI

struct ManageSubscriptionButton: View {
    let subscriptionGroupID: String
    @State private var isPresented = false

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

In UIKit, use AppStore.showManageSubscriptions(in:) or AppStore.showManageSubscriptions(in:subscriptionGroupID:) when you have the current UIWindowScene.

Test the unhappy paths

The happy path is easy: product loads, monthly plan appears, purchase succeeds, one month unlocks.

The important tests are less cheerful:

  • The user is in a storefront where the monthly commitment plan is unavailable.
  • The user is on an OS older than 26.4.
  • The product metadata only contains .upFront pricing terms.
  • The purchase returns .pending.
  • A later monthly renewal arrives through Transaction.updates.
  • The current billing period expires, but the full commitment expiration is still in the future.
  • The customer cancels the next commitment renewal during the current commitment, but still has remaining scheduled payments.
  • The customer upgrades to a higher-tier subscription in the same group.

Starting with Xcode 26.5, StoreKit configuration files can model the monthly billing plan on a one-year auto-renewable subscription. Use that before you lean on sandbox, because it makes the transaction inspector and commitmentInfo much easier to reason about.

Practical checklist

  • Add the monthly billing plan to a new or existing one-year auto-renewable subscription in App Store Connect.
  • Keep the same product ID and subscription group in your app logic.
  • Gate custom UI with OS availability and the presence of .monthly in product.subscription?.pricingTerms.
  • Show the monthly billing amount and total 12-month commitment amount before purchase.
  • Purchase with product.purchase(options: [.billingPlanType(.monthly)]).
  • Grant access from Transaction.currentEntitlements or subscription status, with explicit grace-period behavior.
  • Use Transaction.commitmentInfo for progress UI, not entitlement duration.
  • Offer manageSubscriptionsSheet or AppStore.showManageSubscriptions from account settings.
  • Recheck the current Xcode docs before release, because these APIs are new and may shift.

Sources: