Implementing Grace Period for Failed Subscription Billing
Grace Period is a temporary window that Apple and Google give users after failed billing: expired card, insufficient funds, temporary bank error. During grace period, the subscription remains active. The developer's task is to correctly read this status and not cut off users from content prematurely.
Without grace period implementation, the app blocks access immediately after failed billing. The user updates their card but has already left — direct loss in retention.
Grace Period on iOS (StoreKit 2)
Apple automatically activates grace period if it's enabled in App Store Connect → Subscriptions → [Subscription Group] → Grace Period. Duration options: 3, 6, or 16 days.
To read the status, we use Product.SubscriptionInfo.Status:
import StoreKit
func checkSubscriptionStatus(productId: String) async -> SubscriptionAccessLevel {
guard let product = try? await Product.products(for: [productId]).first,
let statuses = try? await product.subscription?.status else {
return .notSubscribed
}
for status in statuses {
switch status.state {
case .subscribed:
return .active
case .inGracePeriod:
// Billing failed, but grace period is active
// Show soft warning, don't block content
return .gracePeriod
case .inBillingRetryPeriod:
// Grace period expired, Apple continues billing attempts (up to 60 days)
// Content is NOT accessible
return .billingRetry
case .expired, .revoked:
return .notSubscribed
default:
continue
}
}
return .notSubscribed
}
enum SubscriptionAccessLevel {
case active, gracePeriod, billingRetry, notSubscribed
}
What to Show the User in Grace Period
Key principle: don't block content, but show a soft banner encouraging payment data update. Aggressive paywall in grace period is poor UX and violates Apple guidelines.
// SwiftUI banner
if subscriptionStatus == .gracePeriod {
GracePeriodWarningBanner(
message: "Failed to process payment. Update your card details to retain access.",
actionTitle: "Manage Subscription",
action: { openManageSubscriptions() }
)
}
// Open system subscription management screen
func openManageSubscriptions() {
Task {
try? await AppStore.showManageSubscriptions(in: windowScene)
}
}
Grace Period on Android (Google Play Billing)
In Google Play Billing, grace period is implemented through purchaseState and isAutoRenewing:
// Via RTDN (Real-Time Developer Notifications) or PurchasesUpdatedListener
// Google sends SubscriptionNotification.SUBSCRIPTION_IN_GRACE_PERIOD
fun handleSubscriptionNotification(notification: SubscriptionNotification) {
when (notification.notificationType) {
SubscriptionNotification.SUBSCRIPTION_IN_GRACE_PERIOD -> {
// Content is accessible, show warning
showGracePeriodWarning()
}
SubscriptionNotification.SUBSCRIPTION_EXPIRED -> {
// Grace period expired
revokeAccess()
}
}
}
Server-side handling is preferable: RTDN arrives on backend via Pub/Sub, backend updates user status, mobile client receives current status on next request.
Server-Side Validation — More Reliable Than Client-Side
Client-side check via StoreKit is convenient for UI but shouldn't be the only source of truth. Correct architecture:
- Server receives transactions via App Store Server Notifications V2 (types
DID_FAIL_TO_RENEW,GRACE_PERIOD_EXPIRED) - Updates the
subscription_statusfield in database - Mobile client on
/me/subscriptionrequest receives current status
This is the only reliable way if the user opens the app days later — StoreKit cache may not have updated.
What's Included in the Work
- Enabling grace period in App Store Connect / Google Play Console
- Client-side reading of
inGracePeriod/inBillingRetryPeriod(StoreKit 2) - UI banner with warning and link to manage subscription
- Content access logic: grace = allowed, billingRetry = blocked
- Optional: server-side handling of App Store Server Notifications
Timeline
2–3 days — client-side part with UI logic. With server-side notification handling: 4–5 days. Pricing is calculated individually.







