RORK LABJP
MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committingMAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committing
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.

subscriptions9RevenueCat24billing2Expo84monetization46

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-06-16
I Initialized Ads Before Restoring Purchases, and Paying Users Saw a Banner Flash — Cold-Start Ordering for Rork (Expo) Apps
Consent, ATT, ad SDK init, purchase restore, and remote config all try to run in the same few hundred milliseconds at launch. Get the order wrong and a paying user sees a banner flash, or measurement fires before consent in the EEA. Here is how I fold a Rork-generated Expo app's startup into a single orchestrator and kill the races by design.
Dev Tools2026-06-15
The Day a Third Reason to Hide Ads Appeared — Folding Rork App Ad-Free Logic Into One Place
Ads show only on one screen for paying users, or ads never show for free users. The usual cause is that the condition for hiding ads is scattered across the code. Here is how I fold three reasons — subscription, lifetime purchase, and a timed reward unlock — into a single state and route every ad through one hook, written as an implementation note from running six apps as an indie developer.
Dev Tools2026-06-14
Building Rork Subscriptions Around RevenueCat Entitlements — Access Checks, Offering-Driven Paywalls, and Restore
Implementation notes for adding subscriptions to a Rork (Expo) app with RevenueCat. Make Entitlements the single source of truth for access, drive the paywall from Offerings so you can change prices remotely, wire up restore and the customer-info listener, and avoid the sandbox traps — all with working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →