What are the subscription renewal states, how do grace period and billing retry differ, and who should keep access?

28.2.1 · L3 · Interview

Predict first

What access does the user have through a payment failure, with and without grace period?

import StoreKit

enum State: String { case subscribed, grace, billingRetry, expired, revoked }
func hasAccess(_ s: State) -> Bool { s == .subscribed || s == .grace }

// renewal-state raw values StoreKit reports (the App Store Server API status uses the same numbers)
let real: [(String, Product.SubscriptionInfo.RenewalState)] = [
    ("subscribed", .subscribed), ("expired", .expired), ("billingRetry", .inBillingRetryPeriod),
    ("grace", .inGracePeriod), ("revoked", .revoked)]
print(real.map { "\($0.0)=\($0.1.rawValue)" }.joined(separator: " "))

enum Event { case renewed, paymentFailed, graceEnded, retryGaveUp, recovered, refunded }
func next(_ s: State, _ e: Event, graceEnabled: Bool) -> State {
    switch (s, e) {
    case (_, .refunded): return .revoked
    case (.subscribed, .paymentFailed), (.grace, .paymentFailed): return graceEnabled ? .grace : .billingRetry
    case (.grace, .graceEnded): return .billingRetry
    case (.billingRetry, .retryGaveUp), (.grace, .retryGaveUp): return .expired
    case (.grace, .recovered), (.billingRetry, .recovered), (.subscribed, .renewed), (.expired, .renewed): return .subscribed
    default: return s
    }
}
for grace in [true, false] {
    var s = State.subscribed
    var trace = ["subscribed"]
    for e in [Event.paymentFailed, .graceEnded, .recovered, .paymentFailed, .graceEnded, .retryGaveUp] {
        s = next(s, e, graceEnabled: grace)
        trace.append("\(s.rawValue)\(hasAccess(s) ? "+" : "-")")
    }
    print("grace period \(grace ? "on " : "off"):", trace.joined(separator: " > "))
}
  • grace on: subscribed > grace+ > billingRetry- > subscribed+ > grace+ > billingRetry- > expired-; grace off goes straight to billingRetry-
  • access is kept during every failure state
  • billing retry keeps access and grace does not

Mental model. A gym membership whose card bounced: grace is "come in this week while we sort it", billing retry is "the door is locked, but we keep trying your card".

An auto-renewable subscription reports a Product.SubscriptionInfo.RenewalState: .subscribed, .expired, .inBillingRetryPeriod, .inGracePeriod or .revoked. The check prints the raw values StoreKit returns (1 to 5 in that order), the same numbers the App Store Server API uses for a subscription's status, which lets client and server share one vocabulary. When a renewal payment fails, Apple retries for a limited time (billing retry; Apple documents up to 60 days). If you enabled billing grace period in App Store Connect (a few days, configurable per app), the user keeps access while Apple retries (.inGracePeriod); if not, or once grace ends, the state is .inBillingRetryPeriod and access should stop even though Apple is still trying to collect. Recovery returns the user to .subscribed with a new expiry; giving up leads to .expired; a refund or family-sharing loss leads to .revoked.

The model in the check is a small reducer that encodes this policy: with grace on, a failed payment is grace+ (access kept, + marks access), then billingRetry- after grace; with grace off it goes straight to billingRetry-. It is a model of the documented behaviour, not Apple's code, and the exact day counts and timings were not exercised against a live store. Two details interviewers like: RenewalInfo.willAutoRenew turning false does not end access (the user cancelled renewal but paid until the period end), and expirationReason/gracePeriodExpirationDate let you show "update payment" banners at the right time. On the client read Product.SubscriptionInfo.status (or currentEntitlements); on the server derive the same state from Server Notifications V2 (DID_FAIL_TO_RENEW with subtype GRACE_PERIOD, then EXPIRED with subtype BILLING_RETRY).

Only subscribed and grace keep access; the raw values are StoreKit's own.

Common traps

  • Treating "cancelled" as "expired". Turning off auto-renew only stops the next renewal; access lasts until the paid period ends.
  • Keeping access during billing retry but not grace (or the reverse). Grace keeps access, billing retry does not.

They might follow up with

  • How do I nudge a user in grace? Show an "update payment method" banner using the grace expiry date; showManageSubscriptions opens the system sheet.
  • Is the state per user? It is per subscription group and Apple ID, so a family member can have a different state.

Related questions

  • 28.2.2 How do subscription groups and levels govern upgrades, and what are introductory, promotional, offer-code and…
  • 28.3.1 How do you compute what a user is entitled to, and which transactions are excluded from…
  • 28.4.3 How do App Store Server Notifications V2 behave, and how do you process them idempotently and out of order?
  • 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…

How do subscription groups and levels govern upgrades, and what are… →