The Involuntary Churn Problem

Not every cancellation is a customer who wanted to leave. A large share are simply failed payments: an expired card, an insufficient balance, a bank decline. The user never chose to cancel, but without a recovery mechanism they silently lose access and you lose the revenue.

RevenueCat's State of Subscription Apps 2026 found that about a third of Google Play subscription cancellations are involuntary billing failures (roughly double the App Store's share). The report's phrasing: fixing billing is one of the clearest growth levers available. This codelab shows you how.

You will:

  • Enable billing grace periods in App Store Connect and Google Play so access continues while the store retries the card.
  • Detect an active subscription that is in billing retry with RevenueCat (iOS, Android, React Native).
  • Let users fix their payment method with Customer Center or the store's managementURL.
  • React to the BILLING_ISSUE webhook to re-engage users outside the app.
Mostly configuration, a little code. The biggest wins here come from store and dashboard settings. The in-app code is a short detection check plus a drop-in Customer Center.

Voluntary vs Involuntary Churn

Two very different events both show up as "a subscription ended." Treat them differently.

Type Cause How you recover it
Voluntary User tapped cancel Cancel-flow offers, win-back, value reminders
Involuntary Payment failed at renewal Grace period, billing retry, prompt to fix payment

This codelab targets involuntary churn. The recovery chain is: the store keeps retrying the card for a window of time (the grace period), the user keeps access during that window, and your app nudges them to update their payment method before the window closes.

RevenueCat distinguishes them for you. A voluntary cancel sets unsubscribeDetectedAt on the entitlement; an involuntary failure sets billingIssueDetectedAt. You will use the latter in Step 6. (Voluntary churn is covered in a separate Customer Center codelab.)

Enable Grace Period in App Store Connect

A billing grace period lets a subscriber keep access while Apple retries a failed renewal, so there is no interruption to their service or your revenue if Apple recovers the payment in time.

  1. In App Store Connect, open your app and click Subscriptions in the sidebar.
  2. In the Billing Grace Period section, click Set Up Billing Grace Period.
  3. Choose a duration: 3, 16, or 28 days (it applies across all of the app's subscriptions).
  4. Choose the renewal types (all renewals, or only paid-to-paid) and the environments (sandbox only, or production and sandbox).
  5. Click Confirm.
Weekly subscriptions are capped. Even if you pick 16 or 28 days, weekly subscriptions get a maximum 6-day grace period so the grace is never longer than the subscription itself. You need the Account Holder, Admin, or App Manager role.
iOS 16.4 and later helps automatically. When a grace period is enabled, iOS itself can prompt the customer to correct the billing issue during the retry window, in addition to anything you build in-app.

Enable Grace Period and Account Hold in Google Play

Google Play has two recovery phases. During the grace period the user keeps access while Google retries the card. If that fails, the subscription moves to account hold, where the user loses access while Google keeps trying. Recover within either phase and the subscription continues seamlessly.

  1. In Google Play Console, go to Monetize with Play → Products → Subscriptions.
  2. Open a subscription and select its auto-renewing base plan. Grace period and account hold are configured per base plan.
  3. Set the grace period (how long users keep access while a renewal payment is unresolved).
  4. Set the account hold duration (how long Google keeps retrying after access is paused).
Account hold is auto-calculated now. As of December 1, 2025, Google calculates the account hold duration by default as 60 days minus your grace period, rather than a fixed 30 days. The grace period plus account hold must total at least 30 days. Read the live values in the Console rather than hardcoding them.
Access rule: keep access during grace period, and block access during account hold. RevenueCat handles this for you: the entitlement stays active in grace and becomes inactive on hold (more in the next step).

How RevenueCat Surfaces a Billing Issue

This is the key insight that makes detection simple. While a subscription is in a grace period, RevenueCat keeps the entitlement isActive: true (the user still has access) and sets billingIssueDetectedAt to the time the failure was detected.

So the signal for "paying customer, but their card just failed" is:

text
entitlement.isActive == true  AND  entitlement.billingIssueDetectedAt != null
        =>  active subscriber, in billing retry, nudge them to fix payment

Relevant fields on the entitlement (names are consistent across platforms):

  • isActive: the customer currently has access. Stays true during a grace period.
  • billingIssueDetectedAt: a date/time, set when a payment failure is detected, cleared when payment recovers. Null when there is no issue.
  • willRenew: whether the subscription is set to renew.
  • unsubscribeDetectedAt: set for a voluntary cancel (do not confuse it with a billing issue).
Grace vs hold in CustomerInfo. During an App Store or Google Play grace period, the entitlement is active with billingIssueDetectedAt set. Once Google moves to account hold, the entitlement is no longer active, so your normal entitlement check already blocks access.

Detect a Billing Retry In-App

Read the current CustomerInfo and check the pro entitlement for the billing-issue signal.

iOS (Swift)

swift
let info = try await Purchases.shared.customerInfo()

if let pro = info.entitlements["pro"], pro.isActive {
    if pro.billingIssueDetectedAt != nil {
        // Active subscriber whose payment is failing: show a "fix payment" banner.
        showBillingIssueBanner()
    }
}

Android (Kotlin)

kotlin
val info = Purchases.sharedInstance.awaitCustomerInfo()
val pro = info.entitlements["pro"]

if (pro?.isActive == true && pro.billingIssueDetectedAt != null) {
    // Active subscriber in billing retry.
    showBillingIssueBanner()
}

React Native (TypeScript)

tsx
const info = await Purchases.getCustomerInfo();
const pro = info.entitlements.active['pro'];

if (pro && pro.billingIssueDetectedAt != null) {
  // Active subscriber in billing retry.
  showBillingIssueBanner();
}
Keep it current. Check on app foreground, and on mobile also subscribe to CustomerInfo updates so the banner disappears the moment payment recovers. (See the CustomerInfo listener guide for the React Native pattern.)

Let Users Fix Payment with Customer Center

Once you have detected the issue, give the user a one-tap way to fix it. There are two routes.

Option A: Customer Center (recommended)

Customer Center is RevenueCat's drop-in, self-service subscription management UI. It routes users to manage their subscription and update payment, and also handles restores, cancellations, and retention offers.

swift
// iOS (SwiftUI) - RevenueCatUI
import RevenueCatUI

struct SettingsView: View {
    @State private var showCustomerCenter = false

    var body: some View {
        Button("Manage Subscription") { showCustomerCenter = true }
            .sheet(isPresented: $showCustomerCenter) {
                CustomerCenterView()
            }
    }
}
tsx
// React Native - react-native-purchases-ui
import RevenueCatUI from 'react-native-purchases-ui';

await RevenueCatUI.presentCustomerCenter();

On Android, render the CustomerCenter Composable from purchases-ui.

Customer Center requires a Pro or Enterprise plan and is configured under Project Settings → Monetization Tools → Customer Center in the dashboard.

Option B: Open the store's manage page directly

If you are not using Customer Center, send the user straight to the store's manage-subscription page with customerInfo.managementURL, which RevenueCat fills in for the correct store automatically:

tsx
// React Native
import { Linking } from 'react-native';

const info = await Purchases.getCustomerInfo();
if (info.managementURL) {
  await Linking.openURL(info.managementURL);
}

On iOS open customerInfo.managementURL with UIApplication.shared.open(_:); on Android with an ACTION_VIEW intent. See the Manage Subscriptions guide.

managementURL can be null when there is no active store subscription, and it is not supported for Amazon or Stripe. Guard for null before opening it.

React to Billing Issue Webhooks

The in-app banner only helps users who open the app. To reach the rest, use RevenueCat webhooks on your server to trigger an email or push notification.

  • BILLING_ISSUE: fired as soon as a payment failure is detected. RevenueCat sends one per issue. When a grace period is configured, it includes grace_period_expiration_at_ms, the deadline to recover.
  • CANCELLATION with cancel_reason: BILLING_ERROR: also fired when the failure is detected.
  • EXPIRATION with expiration_reason: BILLING_ERROR: fired only if the grace period ends without recovery. This is when you finally remove access.
  • RENEWAL: fired when the payment recovers, and billingIssueDetectedAt clears.
typescript
// Your webhook endpoint (Express-style)
app.post('/revenuecat/webhook', (req, res) => {
  const event = req.body.event;

  switch (event.type) {
    case 'BILLING_ISSUE':
      // Card failed but access continues. Email "update your payment method"
      // with the deadline from event.grace_period_expiration_at_ms.
      sendFixPaymentEmail(event.app_user_id, event.grace_period_expiration_at_ms);
      break;
    case 'RENEWAL':
      // Payment recovered. Stop the dunning sequence.
      clearDunning(event.app_user_id);
      break;
  }

  res.sendStatus(200);
});
Timing depends on the grace period. With a grace period configured, EXPIRATION (BILLING_ERROR) is deferred until grace ends, giving your dunning sequence time to work. Without one, it fires immediately alongside BILLING_ISSUE, so access is lost at once. This is exactly why the grace periods you set up in Steps 3 and 4 matter.

Test in Sandbox

Verify the whole loop before you ship.

  1. In App Store Connect, enable the grace period for Sandbox (or Production and Sandbox) so you can exercise it with a sandbox tester.
  2. On Google Play, use a license tester on a closed-testing track to drive a subscription into grace and hold.
  3. Trigger a renewal failure, then confirm in your app that the pro entitlement is still isActive and that billingIssueDetectedAt is set, so your banner appears.
  4. Confirm your server received the BILLING_ISSUE webhook with a grace_period_expiration_at_ms.
  5. Recover the payment and confirm the banner disappears and a RENEWAL webhook arrives.
Sandbox time is compressed and store-controlled. Renewal and retry intervals in the sandboxes are accelerated, and the exact timing is set by the stores, so verify the behavior live rather than assuming a specific schedule.

Recap

You built a complete involuntary-churn recovery flow:

  • Grace periods in both stores keep paying users in access while the card is retried.
  • RevenueCat surfaces the state as active entitlement + billingIssueDetectedAt, which you detect in-app.
  • Customer Center (or managementURL) gives users a one-tap way to update payment.
  • Webhooks drive out-of-app dunning, with timing that the grace period makes forgiving.

This is one of the highest-leverage changes you can make: you are recovering revenue from customers who never wanted to leave.

Keep going