Skip to content
← STUDIO NOTES

Mobile app infrastructure

How to ship in-app subscriptions in React Native? Lessons from FitZen

How we wired FitZen’s iOS and Android subscriptions with RevenueCat and Firebase: products, a server-authoritative premium mirror and what audits caught.

FitZen interface 1
FitZen interface 2
FitZen — Explore the real interfaces and project scope ↗

SHORT ANSWER

In FitZen, store products map to a single "premium" entitlement in RevenueCat; RevenueCat is the source of truth and Firebase only holds a mirror. The server, not the client, writes that mirror through three paths: a secret-gated webhook, a callable that queries the RevenueCat REST API when the app opens, and a nightly sweep at 03:15. Firestore rules reject client writes to 18 entitlement fields.

Which products were set up in the stores, and with which IDs?

FitZen’s release checklist (IAP_PRODUCTION_CHECKLIST.md) starts with six products created under identical IDs in both stores:

  • Subscriptions: com.fitzen4.weekly, com.fitzen4.monthly, com.fitzen4.yearly
  • Dietitian packs: com.fitzen4.dietitian.single, .four, .eight

In App Store Connect the subscriptions share one subscription group ranked yearly > monthly > weekly. Dietitian packs are consumables on the App Store and one-time products on Google Play. RevenueCat has a default offering and one entitlement called "premium", granted by all three subscriptions.

The checklist assumes most failures are configuration, not code: product IDs that differ in case or carry a trailing space, products stuck in "Missing Metadata", an unsigned Paid Applications agreement, missing banking and tax details. It lists separately that keys must live in the EAS environment and not only in .env, that iOS and Android keys differ, and that production rather than sandbox keys are used.

The product decision changed later: the monthly product stays in the store and existing subscribers are untouched, but the main paywall now shows only the weekly and yearly plans.

Who writes the premium flag? A server-authoritative mirror

The design note at the top of functions/subscription.js states the rule: RevenueCat is the single source of truth, Firebase only holds a mirror, and only the server writes entitlement fields. The block on users/{uid} is isPremium, premiumSource (revenuecat, manual or none), rcPremium, rcProductId, rcStore, rcPeriodType, rcEnvironment, rcExpiresAt, rcWillRenew, rcLastEventId, rcLastEventAtMs, manualPremium and manualPremiumUntil.

The effective value is computed in one function: isPremium = rcActive || manActive. An expired record never counts as premium, even if no event ever arrives.

In the Firestore rules, entitlementFieldsUntouched() rejects client updates touching any of those fields, plus dietitian rights and the wheel-bonus fields. The create rule is subtler. The signup flow writes a default profile with values like isPremium: false, so banning the fields outright would break registration; only harmless values are allowed.

The rule’s comment explains why create must be as strict as update: users can delete and recreate their own document, and without that strictness someone could write a huge rcLastEventAtMs and permanently block every webhook event behind the ordering guard. The rules test users create: grace / entitlement fields denied, signup shape ok covers exactly this.

Which three paths keep the mirror up to date?

The code’s principle is "if one misses, another catches it". There are three paths:

  • Webhook (revenuecatWebhook): accepts only POST, compares the Authorization header in constant time with crypto.timingSafeEqual, and writes the event to transactions/{eventId}. Because the document ID is the event ID, a redelivered event is not double-counted.
  • Callable (syncMyEntitlement): invoked on sign-in, on app foreground and on restore purchases. The uid comes from context.auth, not from the payload, and the server asks the RevenueCat REST API for the subscriber’s real state.
  • Scheduled job (reconcileExpiredPremium): runs daily at 03:15 Europe/Istanbul and closes expired access.

Two details matter. The webhook returns 200 to RevenueCat even if the mirror write fails; otherwise RevenueCat keeps redelivering, and the other two paths repair the state anyway. The callable, if it cannot reach RevenueCat, leaves the mirror alone and returns verified: false.

The callable also receives the state the client SDK reports, but never uses it for entitlement. When webhooks lagged, the admin panel showed "0 premium" while users were premium in the app. The fix stores the client report in separate rcClient* fields labelled unverified. A modified app can lie, so isPremium is never derived from them.

How do RevenueCat events change premium state?

Events fall into three sets. Granting: INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, NON_RENEWING_PURCHASE, TEMPORARY_ENTITLEMENT_GRANT. Keeping access: CANCELLATION, BILLING_ISSUE, SUBSCRIPTION_PAUSED. Revoking: EXPIRATION.

SUBSCRIPTION_PAUSED originally cut access immediately. The code comment records why that changed: a Play Store pause is scheduled, and a paying user keeps access until the end of the paid period. rcExpiresAt already handles the end.

Refunds take a separate branch. RevenueCat does not send an event type called "REFUND"; refunds arrive as CANCELLATION with cancel_reason: CUSTOMER_SUPPORT. In the first version, which only checked the type, a user who bought a yearly plan and got refunded stayed premium for a year. Now isRefundEvent runs before the type check.

Writes happen in a transaction with two guards. The ordering guard ignores any event older than rcLastEventAtMs, so a late EXPIRATION cannot undo a newer RENEWAL. The ghost-user guard exists because the write uses merge: true and would create a missing document. Subscriptions outlive accounts, so an event for a deleted user would resurrect them as a document of entitlement fields only. If the document is missing, the event is skipped. On TRANSFER, access is removed from the old ID and granted to the new one; anonymous RevenueCat IDs ($RCAnonymousID) are never treated as Firebase users.

Which subscription bugs did the audits catch?

The September 2026 security and performance review (SECURITY_PERFORMANCE_AUDIT_2026-09-24.md) and the fixes that followed closed these subscription findings:

  • Dietitian packs unlocked premium. One-time packs also emit NON_RENEWING_PURCHASE with no expiry. Originally that meant rcExpiresAt: null, which is open-ended premium; an interim fix added a 48-hour safe default, but buying the cheapest pack still gave 48 hours of premium. Now any product starting with com.fitzen4.dietitian. never touches the mirror. The prefix match also covers Android base-plan suffixes such as :base.
  • The wheel bonus could be reused. A wheelGrace/{uid} marker, closed to clients entirely, now enforces one use; the bonus only goes to users who subscribed before and are not premium now, and the server caps it at 15 days.
  • The admin secret fell back to the RevenueCat webhook secret; it now uses its own variable.

The most instructive one was an incident. On the night of 2026-08-06 the sweep revoked 82 users at once who had been premium before the mirror existed. Their records had no rc* fields, and the code read that as "no entitlement". The comment in the code puts the lesson in one line: absence of evidence is not evidence of expiry. Records with no trace at all are now only counted and logged; the callable settles their real state when the user opens the app.

Where should paywall prices and trial info come from?

The App Review audit of build 91 (1.2.3) found two blockers: the paywall showed "3 days free" without an eligibility check, and prices plus struck-through "discount" prices were hard-coded by UI language rather than storefront. There was also a permanent "limited time offer" bar.

The note atop the new usePaywall hook sets the rule: prices and trial info come straight from the RevenueCat offering, with no hard-coded price, fake discount or countdown in code. The screen uses store fields like priceString and pricePerMonthString. Trials are read differently per platform:

  • iOS: a zero-priced introPrice means a trial is offered; if checkTrialOrIntroductoryPriceEligibility reports ineligible, the screen says the trial was already used on this account.
  • Android: Play only returns offers the user qualifies for, so a trial shows when defaultOption.freePhase is present.

If eligibility is unknown, no trial is promised. Yearly savings are computed from real prices, against 12 times the monthly or 52 times the weekly price, and hidden below 5 percent. Because an offering can also contain non-subscription products like dietitian packs, packages are filtered by productCategory.

The "we will remind you before the trial ends" promise is now real too: with notification permission, scheduleTrialReminder schedules a local notification one day before the trial ends, at least one hour out.

What caused trouble on the client side?

The RevenueCat user ID is mapped to the Firebase uid: the client calls Purchases.logIn(firebaseUid), and the webhook finds the user via app_user_id. On refresh, if the RevenueCat ID differs from the Firebase ID, anonymous IDs included, the client logs in again; the comment explains that a stale ID left on the device can carry another account’s subscription.

The performance audit counted 4 to 8 redundant premium checks at launch and on every tab focus, one of them a silent restore when the paywall mounted. The fix merges concurrent calls into one request, and non-forced calls reuse the last result for 60 seconds (STATUS_CACHE_MS = 60_000). The paywall no longer restores silently.

Two bundle problems followed. react-native-purchases shipped roughly 880 KB of web fallback inside the native bundle; metro.config.js now swaps that module for an empty stub in native builds. RevenueCat debug logging was also on in production; outside __DEV__ it is now LOG_LEVEL.ERROR.

The sneakiest issue appeared with Android 2.0.2 in October 2026. A local EAS build left the $VARIABLE placeholders from eas.json unresolved, so the RevenueCat, Firebase and Google sign-in keys entered the bundle as literal placeholder text. The fix treats any value starting with $ as empty in app.config.js and defines defaults for the client keys. These are client keys that are public in any bundle anyway; the RevenueCat secret key lives only on the server.

A checklist for building a similar subscription setup

The order that worked for FitZen:

  • Use identical product IDs in both stores and RevenueCat, map subscriptions to one entitlement, and keep one-time products explicitly outside it.
  • Never let the client write the premium flag. Close both the update and the create path in rules, and lock that in with a rules test.
  • Update the mirror through at least three paths: a secret-gated webhook, a verification call that takes identity from the session, and a scheduled sweep for expired access.
  • Process events idempotently and order-independently: event ID as document ID, event time as an ordering guard, all inside a transaction.
  • Detect refunds from the cancellation reason, not the event type.
  • Never treat an entitlement without an expiry date as open-ended.
  • In the sweep, do not read missing data as "expired"; count it first, then ask the source.
  • Read prices, trials and discounts only from the store; if eligibility is unknown, do not promise a trial.
  • Prepare App Review notes and a premium demo account; on a paywalled app the reviewer otherwise cannot get in.
  • Verify from inside the bundle that environment variables actually resolved, for local and cloud builds alike.

At AMG Software Studio we build subscription mobile products like FitZen end to end, from store setup to the server side.

APPLY IT TO YOUR PRODUCT

Mobile App Development

Native iOS with Swift and SwiftUI, or cross-platform with React Native. Idea to store, end to end.