Subscription
This guide covers subscription purchasing, offers, and ongoing subscription management in your app.
iOS Commitment Billing Plans#
StoreKit 26.4 supports auto-renewable subscriptions that can be paid monthly while committing the customer to a longer period, such as a 12-month commitment, or paid up front for the full subscription period. OpenIAP exposes these iOS billing plans without changing the Android subscription-offer flow.
- Fetch products first and read
pricingTermsIOSto show the availablemonthlyandup-frontplans. - Pass
billingPlanType: 'monthly'when the user chooses monthly billing with commitment. - Read
PurchaseIOS.billingPlanTypeIOSandPurchaseIOS.commitmentInfoIOSafter purchase to record the selected commitment. - Use
RenewalInfoIOS.renewalBillingPlanTypeandRenewalInfoIOS.commitmentInfofor upcoming commitment renewal state when StoreKit returns it.
| OpenIAP field | Use it for |
|---|---|
ProductSubscriptionIOS.pricingTermsIOS | Displaying StoreKit billing choices before purchase. |
RequestSubscriptionIosProps.billingPlanType | Selecting monthly or up-front. |
PurchaseIOS.billingPlanTypeIOS | Persisting the plan selected on the completed transaction. |
PurchaseIOS.commitmentInfoIOS | Tracking current billing period, total periods, expiration, and commitment price. |
RenewalInfoIOS.commitmentInfo | Inspecting the next commitment renewal state. |
import OpenIap
try await OpenIapModule.shared.requestPurchase(
RequestPurchaseProps(
request: .subscription(
RequestSubscriptionPropsByPlatforms(
apple: RequestSubscriptionIosProps(
sku: "premium_annual",
billingPlanType: .monthly
)
)
),
type: .subs
)
)See: Subscription Billing Plan iOS types · RequestSubscriptionIosProps · PurchaseIOS
Subscription State#
After purchase, use Active Subscriptions to check which product or plan is currently active across Apple, Google Play, Meta Horizon, Amazon Fire OS, and Vega OS. That page explains how OpenIAP normalizes store-specific subscription group behavior into productId, currentPlanId, and purchase-token based entitlement checks.
Subscription Offers#
Subscription offers represent different pricing plans for the same subscription product:
- Base Plan: The standard pricing for a subscription
- Introductory Offers: Special pricing for new subscribers (free trial, discounted period)
- Promotional Offers: Limited-time discounts configured in the app stores
Platform Differences
| Platform | Behavior |
|---|---|
| iOS | Base plan is used by default. Introductory offers are automatically applied when eligible. Promotional offers require server-side signature via withOffer. |
| Android | Subscription offers are required when purchasing. You must pass subscriptionOffers with offer tokens from fetchProducts(). |
Platform Implementation
iOS Overview#
iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.
- Introductory Offers: Automatically applied when user is eligible (no code needed)
- Promotional Offers: Requires server-side signature generation
Fetching Subscription Info#
import OpenIap
let iapStore = OpenIapStore()
// Fetch subscription products
try await iapStore.fetchProducts(skus: ["premium_monthly"], type: .subs)
let subscription = iapStore.iosSubscriptionProducts.first { $0.id == "premium_monthly" }
// Check for introductory offer
if let introOffer = subscription?.subscriptionOffers?.first(where: {
$0.type == .introductory
}) {
print("Intro offer: \(introOffer.displayPrice)")
print("Payment mode: \(introOffer.paymentMode)")
print("Period: \(String(describing: introOffer.period))")
}
// Check for promotional offers
if let offers = subscription?.subscriptionOffers {
for offer in offers where offer.type == .promotional {
print("Promo: \(offer.id) - \(offer.localizedPriceIOS ?? offer.displayPrice)")
}
}Introductory Offers#
iOS automatically applies introductory prices (free trials, intro pricing) configured in App Store Connect. No additional code is needed - users will see the introductory offer when eligible.
func displayIntroOffer(_ subscription: ProductSubscriptionIOS) -> String? {
guard let offer = subscription.subscriptionOffers?.first(where: {
$0.type == .introductory
}) else {
return nil
}
return offer.displayPrice
}
// Check eligibility
let isEligible = try await iapStore.isEligibleForIntroOfferIOS(sku: "premium_monthly")
if isEligible, let offerText = displayIntroOffer(subscription) {
print(offerText)
}Promotional Offers#
Promotional offers require server-side signature generation. These offers are for existing or lapsed subscribers. Pass the selected SubscriptionOffer.id as offerId when requesting the signature.
import OpenIap
func purchaseWithPromoOffer(
subscriptionId: String,
offerId: String
) async throws {
// 1. Generate signature on your backend
let nonce = UUID().uuidString
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let signatureResponse = try await generateSignatureOnServer(
productId: subscriptionId,
offerId: offerId,
nonce: nonce,
timestamp: timestamp
)
// 2. Purchase with the promotional offer
_ = try await iapStore.requestPurchase(
sku: subscriptionId,
type: .subs,
withOffer: DiscountOfferInputIOS(
identifier: offerId,
keyIdentifier: signatureResponse.keyIdentifier,
nonce: nonce,
signature: signatureResponse.signature,
timestamp: timestamp
),
autoFinish: false
)
}Purchase Subscription#
For iOS, simply request the purchase. Introductory offers are applied automatically when eligible.
import OpenIap
func purchaseSubscription(subscriptionId: String) async throws {
// iOS: Simply request purchase
// Intro offer is applied automatically when eligible
_ = try await iapStore.requestPurchase(
sku: subscriptionId,
type: .subs,
autoFinish: false
)
}Handling Subscription#
After a successful subscription purchase, you need to handle the subscription lifecycle including verification, status checking, and renewal management.
iOS vs Android#
| Aspect | iOS | Android |
|---|---|---|
| Purchase Data | Can get purchase data from Transaction.all (including expired). Client can check expiry/renewal info. | Client cannot access expiry time. Must use Google Play Developer API for subscription status. |
| Cancellation Status | renewalInfo.willAutoRenew available client-side | No client-side API. Must use Google Play Developer API to detect cancellation. |
| Server Validation | Recommended for security | Mandatory for proper subscription management |
Subscription Scenarios#
Understanding how subscriptions behave in different scenarios is crucial for proper implementation:
Cancellation Scenario
- User cancels subscription on Day 1
- Subscription remains valid until Day 30 (end of billing period)
getAvailablePurchases()still returns this purchase- iOS:
renewalInfo.willAutoRenew = false(client-side)
Android: Must check via Google Play Developer API (server-side only)
Restore Purchase Scenarios
| Scenario | Result |
|---|---|
| Day 15 (still valid) | Purchase returned, access granted until Day 30 ✓ |
| Day 35 (expired) | iOS: currentEntitlements returns emptyAndroid: queryPurchases returns emptyNo access granted ✓ |
Refund Scenario (Tricky Case)
When to Validate#
Server-side validation is needed:
- After purchase - Verify purchase is legitimate. Consider using IAPKit to make your life easier - it provides backend verification with minimal setup via verifyPurchaseWithProvider.
- On restore - Check current status (active/cancelled/refunded/expired). See Check Subscription Status for platform-specific implementation details.
- Periodically - Detect refunds and cancellations for active subscriptions
Verify Subscription#
Always verify subscription purchases with your backend or IAPKit before granting access to premium content.
import OpenIap
func verifySubscription(_ purchase: PurchaseIOS) async -> Bool {
do {
return try await yourBackend.verifyAppleSubscription(
productId: purchase.productId,
jws: purchase.purchaseToken ?? ""
)
} catch {
print("Verification error: \(error.localizedDescription)")
return false
}
}Check Active Subscriptions#
Check if the user has an active subscription to determine access.
import OpenIap
let iapStore = OpenIapStore()
// Check if user has any active subscription
let hasActive = try await iapStore.hasActiveSubscriptions()
if hasActive {
print("User has premium access")
}
// Get all active subscriptions
try await iapStore.getActiveSubscriptions()
let activeSubscriptions = iapStore.activeSubscriptions
for subscription in activeSubscriptions {
print("Active subscription: \(subscription.productId)")
if let expiration = subscription.expirationDateIOS {
print("Expires: \(expiration)")
}
}Check Subscription Status (Active/Cancelled/Refunded/Expired)#
After restoring purchases or checking subscriptions, you need to determine the current status. Each platform handles this differently.
iOS: Using subscriptionStatusIOS
iOS provides detailed subscription status through subscriptionStatusIOS() which returns the StoreKit 2 subscription state.
| State | Description | User Access |
|---|---|---|
subscribed | Active subscription | ✅ Grant access |
expired | Subscription has expired | ❌ Deny access |
revoked | Refunded by Apple | ❌ Deny access |
inGracePeriod | Billing failed but grace period active | ✅ Grant access (temporary) |
inBillingRetryPeriod | Billing retry in progress | ⚠️ Consider granting access |
import OpenIap
let iapStore = OpenIapStore()
// Method 1: Using subscriptionStatusIOS for detailed state
func checkSubscriptionStatus(sku: String) async throws -> (hasAccess: Bool, status: String) {
let statuses = try await iapStore.subscriptionStatusIOS(sku: sku)
for status in statuses {
switch status.state {
case "subscribed":
print("✅ Active subscription")
return (true, "active")
case "expired":
print("❌ Subscription expired")
return (false, "expired")
case "revoked":
print("💰 Subscription was refunded")
return (false, "refunded")
case "inGracePeriod":
print("⚠️ Billing issue - grace period active")
return (true, "grace_period")
case "inBillingRetryPeriod":
print("🔄 Billing retry in progress")
return (true, "billing_retry")
default:
continue
}
}
return (false, "unknown")
}
// Method 2: Using ActiveSubscription for quick checks
func checkFromActiveSubscriptions() async throws {
try await iapStore.getActiveSubscriptions()
let subs = iapStore.activeSubscriptions
for sub in subs {
let renewalInfo = sub.renewalInfoIOS
// Check if cancelled (will not auto-renew)
let isCancelled = renewalInfo?.willAutoRenew == false
// Check expiration reason
let expirationReason = renewalInfo?.expirationReason
// Check if expired
let isExpired: Bool
if let expiry = sub.expirationDateIOS {
isExpired = expiry < Double(Date().timeIntervalSince1970 * 1000)
} else {
isExpired = false
}
print("Product: \(sub.productId)")
print(" Active: \(sub.isActive)")
print(" Cancelled: \(isCancelled)")
print(" Expired: \(isExpired)")
if let reason = expirationReason {
print(" Expiration Reason: \(reason)")
}
}
}For the complete list of subscription state values and expiration reasons, see SubscriptionStatusIOS in the Types reference.
Summary: Status Check Comparison
| Status | iOS (Client) | Android (Client) | Android (Server) |
|---|---|---|---|
| Active | state == "subscribed" | Purchase exists ⚠️ | expiryTimeMillis > now |
| Cancelled | willAutoRenew == false | Not available ❌ | cancelReason is set |
| Expired | state == "expired" | Purchase not returned | expiryTimeMillis < now |
| Refunded | state == "revoked" | Not available ❌ | Check refund endpoint |
| Grace Period | state == "inGracePeriod" | Not available ❌ | expiryTimeMillis extended |
Manage Subscriptions#
Allow users to manage their subscriptions through the platform's native UI.
import OpenIap
let iapStore = OpenIapStore()
// Open subscription management page
func manageSubscriptions() async {
try await iapStore.deepLinkToSubscriptionsIOS()
}See Also#
- Subscription Upgrade/Downgrade - Change subscription plans
- Subscription Lifecycle - Understand subscription states and transitions
- Verify Purchase - Server-side verification guides
Native References#
- Apple · App Store Subscriptions overview
- Apple · Product.SubscriptionInfo
- Google · Add subscription support
- Google · Subscription lifecycle