◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-06-15Advanced

The Users You Cut Off as Churned Were Still Willing to Pay — Implementing Grace Periods That Keep Access Alive

A failed renewal is not a cancellation. If you revoke access during the grace period, you throw away recoverable revenue. Here is how to tell grace, billing retry, account hold, and real churn apart in a Rork (Expo) app, keep access during grace, and guide users back.

subscriptions10RevenueCat30billingExpo234monetization48

✦ Premium Article

While running subscription apps, I once noticed that one month's churn looked oddly high. When I dug in, more than half of those "cancellations" were not people tapping a cancel button. They were renewals that had simply failed at the payment step — an expired card, a hit credit limit. The real problem was on my side: my app treated that state exactly like a cancellation and locked the premium features immediately.

A user whose payment failed once is still willing to pay. Both Apple and Google avoid cutting off a subscription the moment a charge fails; they automatically retry for days or weeks. Whether you keep access alive during that window and prompt the user to update their card directly determines how much revenue you recover. In my case, fixing only this clearly increased the share of failed payments that recovered.

This article assumes a Rork-generated Expo app and walks through handling the four states of a failed payment without confusing them: keep access during the grace period, and revoke it only when the payment is truly unrecoverable. If you care about the state management itself, read Designing an entitlement state machine for subscriptions in Rork first; it makes the state machine here much easier to follow.

"Payment failed" is not a single state

The first trap is looking at isActive === false and concluding "churned." In reality, several intermediate states exist between a failed auto-renewal and a subscription disappearing for good.

  • Billing retry: The charge failed and the store is retrying automatically. In most cases you should keep access during this window.
  • Billing grace period: A grace window you explicitly enable. The user keeps using premium features during it. Apple grants up to about 16 days depending on the subscription period; Google grants up to 7 days for weekly and up to 30 days for monthly-and-longer plans.
  • Account hold: Both retry and grace are exhausted. Only here do you suspend access — but the subscription revives if the user fixes their card.
  • Cancelled / expired: The user actively cancelled, or the retry window ran out and the subscription ended completely.

The key point: during retry and grace, you should still treat the person as a customer. Cutting access here makes the user feel "I paid and you locked me out," which leads straight to a one-star review rather than recovery.

I normalize these four states into a single enum across the whole app before doing anything else. The biggest way to reduce bugs is to never let store-specific notification types leak up to the UI.

// subscription/types.ts
// Normalize every store-specific state down to these five values
export type EntitlementState =
  | 'active'        // charging normally
  | 'grace'         // payment failed but in grace/retry (= keep access)
  | 'hold'          // account hold (= suspend access, but recoverable)
  | 'expired'       // fully lapsed (cancelled or expired)
  | 'never';        // never subscribed
 
export interface EntitlementSnapshot {
  state: EntitlementState;
  // Held so the UI can show "you can use it until..." during grace
  graceUntil: string | null;   // ISO8601, or null
  willRenew: boolean;          // is the next auto-renewal scheduled?
  productId: string | null;
}

Reading the state from RevenueCat's customerInfo

If you use RevenueCat, customerInfo carries the clues about a failed payment. Many implementations only check whether entitlements.active is present, which misses the grace period. During grace, isActive stays true while willRenew becomes false, and billingIssueDetectedAt holds the time the failure was detected. You need to read that combination.

// subscription/fromRevenueCat.ts
import Purchases, { CustomerInfo } from 'react-native-purchases';
import { EntitlementSnapshot } from './types';
 
const ENTITLEMENT_ID = 'premium'; // the Entitlement identifier you set in RevenueCat
 
export function toSnapshot(info: CustomerInfo): EntitlementSnapshot {
  const ent = info.entitlements.active[ENTITLEMENT_ID]
    ?? info.entitlements.all[ENTITLEMENT_ID];
 
  if (!ent) {
    return { state: 'never', graceUntil: null, willRenew: false, productId: null };
  }
 
  const hasBillingIssue = ent.billingIssueDetectedAt != null;
 
  // If isActive is true, the user may still use it (normal or grace)
  if (ent.isActive) {
    if (hasBillingIssue && !ent.willRenew) {
      // Payment failed, but access is granted as grace until expiration
      return {
        state: 'grace',
        graceUntil: ent.expirationDate ?? null,
        willRenew: false,
        productId: ent.productIdentifier,
      };
    }
    return {
      state: 'active',
      graceUntil: null,
      willRenew: ent.willRenew,
      productId: ent.productIdentifier,
    };
  }
 
  // isActive is false. Separating hold from expired matters here.
  // RevenueCat alone is ambiguous about the two, so reinforce with server notifications (below).
  return {
    state: hasBillingIssue ? 'hold' : 'expired',
    graceUntil: null,
    willRenew: false,
    productId: ent.productIdentifier,
  };
}

Whether billingIssueDetectedAt is non-null is the first signal that separates a cancellation from a hold. If the user cancelled, there is no billing issue, so we lean expired; if the lapse was caused by a failed charge, a billing issue is present, so we lean hold.

But the client's customerInfo alone leaves gaps, because the state changes while the app isn't running. So we use server-side notifications as the source of truth alongside it.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦If you have been treating failed-payment users as churned and locking them out, you'll walk away with working code that keeps access alive through the grace period and recovers revenue you were losing
✦You'll learn to reconcile RevenueCat's customerInfo with App Store Server Notifications V2 and Google RTDN to cleanly separate grace, retry, hold, and cancellation
✦You'll ship a two-tier recovery flow — soft during grace, firm at account hold — and measure how much failed-payment revenue you win back
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-10-30
It runs in development but the iOS Release build dies on launch — checking whether a missing scheme is the cause
In an expo-router app, an app.json with no scheme can crash only the iOS Release build at launch. Why Debug and Android survive, the two ways to fix it, how to think about leaving the scheme out on purpose, and a pre-submission check.
⬡ Dev Tools2026-10-29
When DevTools Stays Silent in Expo Go on Android, Suspect the Runtime Before Your Code
In Expo Go on Android, React Native DevTools can open but never evaluate anything in the Console, and the Sources tab stays empty. Three quick checks to tell it apart from a bug in your own code, how to switch to a development build, and what to do if you can't.
⬡ Dev Tools2026-10-25
Sound Effects Stop at Play #40 — With expo-audio's createAudioPlayer, remove() Alone Isn't Cleanup
Creating a createAudioPlayer instance per play in expo-audio can leave Android silent after about 40 plays, with didJustFinish never arriving. What remove() and release() each do, three ways to restructure, and how to notice when it happens.