iOS subscription state machine explained: active, grace period, billing retry, and expired states
A complete guide to the five subscription states in StoreKit 2 — what triggers each transition, which states grant access, and the App Store Server Notification events that signal every change.
When a user subscribes to your iOS app, the journey doesn't end at the first successful payment. Apple's subscription system operates as a state machine — a defined set of states each subscription moves through as renewals succeed, fail, get cancelled, or get recovered. Understanding these states is fundamental: deliver the wrong entitlement in any of them and you either lock out a paying customer or give away access you've already lost payment for.
StoreKit 2, introduced with iOS 15, unified subscription state into a clear enumeration: Product.SubscriptionInfo.Status. Combined with App Store Server Notifications v2, you have real-time visibility into exactly where every subscription sits in its lifecycle. This guide maps every state, explains the transitions between them, and flags the implementation mistakes that silently cost indie developers renewal revenue.
The five subscription states in StoreKit 2
Apple defines subscription status via the Product.SubscriptionInfo.Status enum in StoreKit 2. Each case represents a distinct combination of access rights and billing conditions. Before StoreKit 2, developers inferred state from raw receipt fields — a brittle approach that left many apps handling edge cases incorrectly. The current model is explicit:
| State | Enum case | Grant access? | What it means |
|---|---|---|---|
| Subscribed | .subscribed |
Yes | Active subscription; auto-renewal is on and the most recent payment succeeded. |
| In Grace Period | .inGracePeriod |
Yes | The latest renewal payment failed, but Apple's grace period is still running. User retains full access. |
| In Billing Retry | .inBillingRetryPeriod |
No | Grace period ended; Apple is still attempting recovery charges but the user no longer has entitlement. |
| Expired | .expired |
No | Subscription lapsed — either the user voluntarily cancelled, or billing recovery exhausted all retries. |
| Revoked | .revoked |
No | Access was revoked — typically when a Family Sharing purchaser removes a member from their group. |
The most critical detail in this table is the grace period row: the subscription status is not active, but you should still grant access. Many implementations check for .subscribed only, which incorrectly locks out users in grace period and accelerates involuntary churn. Correctly written access gates must include both .subscribed and .inGracePeriod.
When reading status in StoreKit 2, use product.subscription?.status and iterate the returned array. A subscription group can have multiple status entries when the user has overlapping transactions — for example, after upgrading to a higher tier. Always resolve to the highest-privilege status when building your entitlement check.
The billing failure cascade: grace period, billing retry, and expiry
The path from a failed renewal payment to subscription expiry runs through two recovery windows. Apple designed this cascade to maximise revenue recovery on your behalf without requiring manual retry logic — but only if your app handles each phase correctly.
Phase 1: Grace period
When a renewal charge fails, Apple can automatically extend a grace period during which the subscription continues to function as if it had renewed successfully. According to Apple's developer documentation, grace periods are either 6 days or 16 days depending on your App Store Connect configuration; you opt in to the programme in the Subscriptions section of your app record.
Do not revoke access during a grace period. Apple's guidelines are explicit: apps should continue to deliver service during the grace period window. Revoking access at the first billing failure pushes users to cancel before Apple has any chance to recover the charge. The grace period is Apple working on your behalf — let it run. The correct action is to surface a soft, non-blocking in-app prompt that links to the user's Apple ID payment settings.
During grace period, the StoreKit 2 status is .inGracePeriod, and your server will receive a DID_FAIL_TO_RENEW notification. The renewalInfo payload contains an isInBillingRetryPeriod flag — when the notification arrives before the grace period expires, this helps you distinguish the phase from outright billing retry.
Phase 2: Billing retry period
If the grace period lapses without a successful charge, Apple transitions the subscription to billing retry. The user no longer has access, but Apple continues attempting automated recovery charges against the payment method on file.
During billing retry, the StoreKit 2 status is .inBillingRetryPeriod. Access should be revoked, but this phase is also where proactive outreach has meaningful impact. A well-timed in-app prompt — shown the next time the user opens the app — inviting them to update their payment method can convert a lapse into a recovery. Apple Search Ads remarketing to users who have recently lapsed is a complementary lever, though the overlap between users in billing retry and those who will re-engage with an ad is typically narrow.
If Apple's automated recovery succeeds at any point during the 60-day window, your server receives a DID_RENEW notification with the BILLING_RECOVERY subtype. The subscription transitions back to .subscribed and access should be restored immediately upon receiving and verifying that notification.
Phase 3: Expiry
After 60 days of failed billing retry attempts, the subscription expires permanently. Status becomes .expired and the path back is a new initial purchase — not payment method recovery. For winback strategy at this stage, see our post on iOS subscription winback campaigns: timing, promotional offers, and automation.
The .expired state covers two distinct scenarios that carry different re-engagement implications:
- Voluntary expiry: the user explicitly turned off auto-renewal before the period ended. The
expirationReasonin the transaction record will be.userCancelled. This user made a deliberate choice — your re-engagement message needs to address the value case. - Involuntary expiry: billing retry was exhausted without recovery. The
expirationReasonwill be.billingError. This user may well want to remain subscribed — a frictionless payment-update prompt is the right lever, not a value pitch.
Treating these two groups identically in your winback sequences is a common and costly mistake; we cover the segmentation logic in more detail in the winback campaigns post.
App Store Server Notifications and state transitions
Polling StoreKit 2 status on app launch is necessary but not sufficient. Subscribers who churn often stop opening the app entirely. App Store Server Notifications v2 — an HTTPS endpoint you register in App Store Connect under your subscription's server notification URL field — push real-time events for every state change, including changes that happen with the user entirely absent from the app. For the full endpoint implementation, see our guide on App Store Server Notifications v2: tracking your iOS subscription lifecycle in real time.
The table below maps the notification events most relevant to subscription state:
| Notification type | Subtype | Resulting state | Recommended server action |
|---|---|---|---|
SUBSCRIBED |
INITIAL_BUY |
Active | Provision entitlement; start trial or paid period. |
DID_RENEW |
— | Active | Extend entitlement by one billing period. |
DID_RENEW |
BILLING_RECOVERY |
Active | Restore access immediately; log the recovery event for LTV modelling. |
DID_FAIL_TO_RENEW |
— | Grace period | Keep access active; surface non-blocking payment-update nudge. |
GRACE_PERIOD_EXPIRED |
— | Billing retry | Revoke access; begin payment-update and winback messaging. |
EXPIRED |
VOLUNTARY |
Expired | Close entitlement; queue value-case winback sequence. |
EXPIRED |
BILLING_RETRY |
Expired | Close entitlement; queue payment-update-focused prompt. |
REVOKE |
— | Revoked | Immediately revoke access for the affected Family Sharing member. |
DID_CHANGE_RENEWAL_STATUS |
AUTO_RENEW_DISABLED |
Active until period end, then Expired | Flag subscriber as at-risk; consider surfacing a retention offer before period ends. |
A critical implementation note: App Store Server Notifications are delivered at least once but may be delivered more than once. Your server endpoint must be idempotent — processing the same notification a second time should not double-provision or double-revoke anything. Always verify the signed payload using Apple's public key and the verifyAndDecodeNotification method (or the equivalent in the App Store Server Library) before acting on any event.
Common state-handling mistakes and their revenue cost
The following mistakes appear frequently in StoreKit integration code found on GitHub, in popular tutorials, and in production apps. Each carries a direct and measurable revenue cost.
Checking only for .subscribed
The most widespread mistake is an access check that tests only the .subscribed case:
// Wrong — silently locks out grace-period subscribers
if subscriptionStatus == .subscribed {
grantAccess()
}
// Correct
if subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod {
grantAccess()
}
Every subscriber locked out during a grace period is a subscriber with a reason to cancel before Apple recovers the payment. The fix is a one-line change with potentially significant involuntary churn impact at scale.
Ignoring the REVOKE notification
Family Sharing is easy to overlook during testing because it requires multiple Apple IDs. When the purchasing family member removes someone from their group, Apple sends a REVOKE notification for that member's linked subscription. If your backend doesn't handle this event, the revoked member retains access indefinitely — access that is no longer being paid for.
Treating DID_CHANGE_RENEWAL_STATUS as expiry
When a user turns off auto-renewal, they are signalling intent to leave — but the subscription remains active and paid until the end of the current billing period. DID_CHANGE_RENEWAL_STATUS with the AUTO_RENEW_DISABLED subtype is not an expiry event. Revoking access on this notification means withdrawing something the user has already paid for, which is grounds for a refund request. Hold access until the actual EXPIRED event arrives.
Not distinguishing voluntary from involuntary expiry
A subscriber whose card declined is in a fundamentally different situation from one who deliberately cancelled. Without branching on expirationReason, your re-engagement sequences will send value-case copy to someone who would happily resubscribe if they could just update their payment method — and payment-update prompts to someone who made a deliberate decision to leave. Neither message lands well. Practitioners at RevenueCat and other subscription analytics platforms consistently note that segmenting lapsed users by churn type is one of the highest-leverage improvements a subscription app can make to its lifecycle communications.
Relying solely on client-side polling
StoreKit 2 status is accurate when a user opens the app. But churned subscribers often stop opening it. Server-side notifications are the only reliable mechanism for tracking the full subscription lifecycle, including users who never return. An app that only updates entitlement state on app launch will overstate its active subscriber count and understate churn — which distorts LTV modelling, pricing decisions, and any revenue forecasting built on those figures.
Sources and further reading
- Apple Developer Documentation: Product.SubscriptionInfo.Status
- Apple Developer Documentation: App Store Server Notifications
- Apple Developer Documentation: Reducing involuntary subscriber churn
- Apple Developer Documentation: Transaction.ExpirationReason
- RevenueCat Engineering Blog: iOS subscription status handling
- Apple Developer Documentation: App Store subscription status types
Share this post
Ready to put this into practice?
AppsOps is the first App Store ops dashboard — PPP-fair pricing for 175 App Store territories, AI metadata localization in 39 languages, AI screenshot localization for 14 Apple device classes, and one-click App Store Connect API push — all from one dashboard, all for $19/month.
Try AppsOps free — no card →