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:
.upFrontfor the normal annual subscription paid in full.monthlyfor 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:
billingDisplayPriceis the monthly amount the customer pays for each billing period.commitmentInfo.displayPriceis 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
.upFrontpricing 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
.monthlyinproduct.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.currentEntitlementsor subscription status, with explicit grace-period behavior. - Use
Transaction.commitmentInfofor progress UI, not entitlement duration. - Offer
manageSubscriptionsSheetorAppStore.showManageSubscriptionsfrom account settings. - Recheck the current Xcode docs before release, because these APIs are new and may shift.
Sources: