Designing a Unified Mobile Revenue Funnel with Antigravity: AdMob, IAP, and Subscriptions for Indie Developers
A practical implementation playbook for indie mobile developers to unify AdMob, in-app purchases, and subscriptions into a single revenue funnel. Build automated dashboards and A/B testing pipelines with Antigravity.
When app revenue stagnates, most indie developers respond in one of two ways: add more ads, or switch to subscriptions. I have done both, more than once. I have been shipping iOS and Android apps as a solo developer since 2014, and the catalog has accumulated past 50 million downloads — mostly wallpaper, healing, and law-of-attraction style apps in the wellness niche.
Around 2018, AdMob eCPMs dropped sharply for my wallpaper apps and monthly revenue fell to about 70 percent of the previous year. I almost flipped the whole thing to a subscription model. Lining the numbers up before pulling the trigger taught me something I keep coming back to: apps that design revenue as a continuous line rather than a set of disconnected points tend to see three to five times the lifetime value at the same DAU. AdMob, IAP, and subscriptions are not separate plays — they are sequential offers calibrated to how deeply a user already understands the product.
This guide is the implementation playbook for that approach, rebuilt around Antigravity as the development environment. It is not a "first impressions" tour. Everything below is code I run in production, including the parts that took me years to stop getting wrong — pricing by purchasing power, alert thresholds that survive seasonal noise, and prompt granularity that keeps AI agents from hallucinating into your monetization stack.
Rethinking Monetization as a User Lifecycle Problem
Most indie developers treat AdMob, IAP, and subscriptions as separate revenue streams. They aren't. They're stages of the same funnel, calibrated to where each user is in their relationship with your app.
User Lifecycle and Revenue Model Alignment
Day 0-3 (New Users): Light AdMob monetization → let users experience app value
Day 4-14 (Engaged Users): IAP for premium features → give committed users options
Day 15+ (Power Users): Subscriptions → lock in long-term retention
Apps that get this lifecycle alignment right typically see 3-5x higher LTV than apps that don't, even with identical DAU. On my own data, ARPDAU jumped roughly 2.4x and the D30 subscription retention rate climbed from 38 percent to 61 percent after switching from a flat monetization model to a stage-aware one.
The biggest implementation gain from using Antigravity comes from coordinating three SDKs (RevenueCat, Firebase, AdMob) at once — work that previously took me several days now takes a few hours.
Concrete Numbers I Have Learned at 50M Downloads
Abstractions only get you so far. Here are typical metrics I observe across my wallpaper, healing, and manifestation apps. Your category will move the numbers around, but the relative shape generally holds.
Day 0-3 — Onboarding: IAP conversion stays below 0.2 percent. Subscription CTAs at this stage are largely ignored, so this is when AdMob rewarded ads should carry the load and gently introduce app value. Typical eCPMs land at roughly ¥800-1,200 in Japan and $1.5-2.5 in the US.
Day 4-14 — Understanding: IAP conversion jumps to 0.8-1.5 percent for users who have surpassed five sessions. In my wallpaper app, an "ad-removal ¥320 one-time IAP" plus an "unlock-all-categories ¥480" combo ran at a 4.2 percent click-through. Resisting the urge to push subscriptions here is important — trial start rates for cohorts that crossed Day 14 first are about 2.7x higher than for cohorts pushed into trials earlier.
Day 15+ — Established: LTV reaches ¥600-1,200 over a single month. Subscription trial start rates run 6-9 percent, with trial-to-paid conversion around 32 percent. The difference between 7-day and 3-day trials turned out smaller than I expected — when I A/B tested with Antigravity-generated RevenueCat experiments, the timing of the CTA (which day of usage, which hour of day) mattered more than the trial length. For my wallpaper app, the sweet spot was a Day 21 prompt around 8am local time.
These numbers feed directly into the Firebase Remote Config rules I describe later.
✦
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
✦Working Firebase Functions + Firestore + Webhook code that stitches AdMob, IAP, and subscription revenue into one dashboard
✦MonetizationManager in Swift / Kotlin plus a Firebase Remote Config A/B testing layer that switches revenue models per user lifecycle stage
✦Python script for monthly price elasticity calculation and a Firebase Scheduled Function that flags revenue drops to Slack in near-real-time
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.
Everything in this approach depends on seeing all your revenue in one place. Before you can optimize anything, you need visibility.
Project Context Setup
The first thing I do when starting a cross-platform revenue integration with Antigravity is write a detailed agents.md:
# agents.md## Revenue Dashboard Integration### GoalUnified view of AdMob + RevenueCat + Firebase Analytics revenue data.Real-time aggregation with 15-minute maximum latency.### Architecture- iOS (Swift/SwiftUI) — client-side ad tracking, RevenueCat SDK- Android (Kotlin) — client-side ad tracking, RevenueCat SDK - Firebase Functions — webhook receiver, daily aggregation, alerting- Firestore — aggregated revenue documents, event log### Data Sources- RevenueCat: Webhook for real-time events + REST API for historical- AdMob: Client-side GADAdValue events → Firestore write- Firebase Analytics: ad_impression events for behavioral analysis### Key Constraints- No PII in revenue aggregation documents- Sandbox/test events must never reach production aggregates- Firebase Functions use Secret Manager for all API keys
This context prevents Antigravity from asking obvious questions mid-implementation, and it also prevents the common mistake of embedding API keys in client code.
Why Firestore transactions: The runTransaction call ensures that concurrent webhook deliveries (RevenueCat can send duplicate events during retries) don't corrupt your aggregates. This is especially important for subscription renewal events that arrive in bursts at the start of each billing cycle.
Step 2: Client-Side AdMob Revenue Tracking
AdMob revenue needs to come from the client, where you have access to the paid event callbacks. The goal is to write these events to Firestore in a way that's non-blocking — ad display should never wait on a Firestore write.
iOS Implementation
// AdRevenueTracker.swiftimport GoogleMobileAdsimport FirebaseAnalyticsimport FirebaseFirestoreimport FirebaseAuthfinal class AdRevenueTracker { static let shared = AdRevenueTracker() private let db = Firestore.firestore() private init() {} /// Call this from your ad delegate's adDidRecordImpression method func recordImpression(adValue: GADAdValue, adUnitId: String, adType: AdType) { let revenue = adValue.value.doubleValue let currency = adValue.currencyCode // Firebase Analytics — for aggregate reporting in GA4 Analytics.logEvent("ad_impression", parameters: [ AnalyticsParameterValue: revenue, AnalyticsParameterCurrency: currency, "ad_unit_id": adUnitId, "ad_type": adType.rawValue, "ad_platform": "admob", ]) // Firestore — for user-level LTV calculation let data: [String: Any] = [ "value": revenue, "currency": currency, "adUnitId": adUnitId, "adType": adType.rawValue, "timestamp": Timestamp(date: Date()), "userId": Auth.auth().currentUser?.uid ?? "anonymous", ] // Non-blocking write — failure here must never affect user experience db.collection("ad_impressions").addDocument(data: data) { error in if let error = error { // Log but don't surface to user print("[AdRevenueTracker] Write failed: \(error.localizedDescription)") } } // Also update the daily aggregate in Firestore for the dashboard updateDailyAdRevenue(revenue: revenue) } private func updateDailyAdRevenue(revenue: Double) { let dateKey = ISO8601DateFormatter().string(from: Date()).prefix(10).description let docRef = db.collection("daily_revenue").document(dateKey) docRef.setData([ "admobRevenue": FieldValue.increment(revenue), "totalRevenue": FieldValue.increment(revenue), "date": dateKey, ], merge: true) } enum AdType: String { case banner = "banner" case interstitial = "interstitial" case rewarded = "rewarded" case rewardedInterstitial = "rewarded_interstitial" case nativeAdvanced = "native_advanced" }}
// Usage in your ad delegateclass InterstitialAdDelegate: NSObject, GADFullScreenContentDelegate { func adDidRecordImpression(_ ad: GADFullScreenPresentingAd) { guard let adValue = (ad as? GADAdValue) else { return } AdRevenueTracker.shared.recordImpression( adValue: adValue, adUnitId: "ca-app-pub-YOUR_ID/YOUR_UNIT_ID", adType: .interstitial ) }}
Android Implementation
// AdRevenueTracker.ktimport android.os.Bundleimport com.google.android.gms.ads.AdValueimport com.google.firebase.analytics.FirebaseAnalyticsimport com.google.firebase.firestore.FieldValueimport com.google.firebase.firestore.FirebaseFirestoreimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.SupervisorJobimport kotlinx.coroutines.launchimport java.text.SimpleDateFormatimport java.util.Dateimport java.util.Localeclass AdRevenueTracker private constructor( private val analytics: FirebaseAnalytics, private val db: FirebaseFirestore) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) enum class AdType(val value: String) { BANNER("banner"), INTERSTITIAL("interstitial"), REWARDED("rewarded"), NATIVE("native_advanced") } fun recordImpression(adValue: AdValue, adUnitId: String, adType: AdType) { val revenueUsd = adValue.valueMicros / 1_000_000.0 // Firebase Analytics analytics.logEvent("ad_impression", Bundle().apply { putDouble(FirebaseAnalytics.Param.VALUE, revenueUsd) putString(FirebaseAnalytics.Param.CURRENCY, adValue.currencyCode) putString("ad_unit_id", adUnitId) putString("ad_type", adType.value) putString("ad_platform", "admob") }) // Non-blocking Firestore write scope.launch { try { val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) // User-level event log db.collection("ad_impressions").add(hashMapOf( "value" to revenueUsd, "currency" to adValue.currencyCode, "adUnitId" to adUnitId, "adType" to adType.value, "timestamp" to com.google.firebase.Timestamp.now(), )) // Daily aggregate update db.collection("daily_revenue").document(dateKey).set( hashMapOf( "admobRevenue" to FieldValue.increment(revenueUsd), "totalRevenue" to FieldValue.increment(revenueUsd), "date" to dateKey, ), com.google.firebase.firestore.SetOptions.merge() ) } catch (e: Exception) { // Swallow — never let tracking failures surface to users } } } companion object { @Volatile private var instance: AdRevenueTracker? = null fun getInstance(analytics: FirebaseAnalytics): AdRevenueTracker { return instance ?: synchronized(this) { instance ?: AdRevenueTracker(analytics, FirebaseFirestore.getInstance()) .also { instance = it } } } }}
Common Pitfalls in Ad Revenue Tracking
Pitfall 1: Client-side values differ from AdMob reporting
GADAdValue returns predicted eCPM-based estimates. Your Firestore aggregates will always differ from the actual AdMob revenue report. Use client-side data for trends and behavioral analysis; use the AdMob Reporting API for actual accounting.
Pitfall 2: Writes blocking the UI thread on Android
The scope.launch call in the Kotlin code uses Dispatchers.IO and a SupervisorJob. The SupervisorJob is important — it means failures in one child coroutine don't cancel the scope, which would cause subsequent tracking calls to silently fail.
Pitfall 3: Sandbox events appearing in production dashboards
AdMob test ads don't have the concept of a "sandbox" flag the way RevenueCat does. Use test ad unit IDs during development and ensure they're replaced before release. A CI check that fails builds containing test ad unit IDs is worth setting up once.
Step 3: Dynamic Monetization Model Switching
With data flowing in, the next layer is making the app serve different experiences based on where users are in their lifecycle.
// MonetizationManager.swiftimport FirebaseRemoteConfigimport RevenueCatenum MonetizationModel: Equatable { case adSupported // New users: light ads, no purchase prompts case hybridAdsIap // Retained users: ads + IAP options shown case subscriptionFocused // Power users: subscription front and center case premiumOnly // Subscribers: no ads, full access}@MainActorfinal class MonetizationManager: ObservableObject { @Published private(set) var model: MonetizationModel = .adSupported static let shared = MonetizationManager() private init() {} func refresh() async { // Paying subscribers always get the premium experience if await hasActiveSubscription() { model = .premiumOnly return } let days = daysInstalled() let engagement = engagementScore() // Remote Config drives the rules — change strategy without shipping builds let remoteConfig = RemoteConfig.remoteConfig() let rulesJson = remoteConfig.configValue(forKey: "monetization_rules").stringValue ?? "" model = resolveModel(json: rulesJson, days: days, engagement: engagement) } private func hasActiveSubscription() async -> Bool { guard let info = try? await Purchases.shared.customerInfo() else { return false } return info.entitlements.active["premium"] != nil } private func daysInstalled() -> Int { let defaults = UserDefaults.standard let key = "app_first_launch" if defaults.object(forKey: key) == nil { defaults.set(Date(), forKey: key) return 0 } let firstLaunch = defaults.object(forKey: key) as? Date ?? Date() return Calendar.current.dateComponents([.day], from: firstLaunch, to: Date()).day ?? 0 } private func engagementScore() -> Double { let defaults = UserDefaults.standard let sessions = Double(defaults.integer(forKey: "total_sessions")) let avgDuration = defaults.double(forKey: "avg_session_seconds") / 60.0 return min(sessions * 0.1 + avgDuration * 0.5, 10.0) } private func resolveModel(json: String, days: Int, engagement: Double) -> MonetizationModel { // Parse Remote Config rules, fall back to defaults if parsing fails struct Rule: Decodable { let maxDays: Int? let minDays: Int? let maxEngagement: Double? let model: String } if let data = json.data(using: .utf8), let rules = try? JSONDecoder().decode([Rule].self, from: data) { for rule in rules { let meetsMinDays = rule.minDays.map { days >= $0 } ?? true let meetsMaxDays = rule.maxDays.map { days < $0 } ?? true let meetsEngagement = rule.maxEngagement.map { engagement < $0 } ?? true if meetsMinDays && meetsMaxDays && meetsEngagement { switch rule.model { case "ad_supported": return .adSupported case "hybrid_ads_iap": return .hybridAdsIap case "subscription_focused": return .subscriptionFocused default: continue } } } } // Hardcoded fallback — always safe, never wrong if days < 3 { return .adSupported } if days < 14 && engagement < 5.0 { return .hybridAdsIap } return .subscriptionFocused }}
The beauty of this schema is that you can add country overrides in Remote Config conditions — different rules for Japan, India, and the US — without touching any app code.
Step 4: Price Elasticity Analysis
Once your revenue pipeline has a few weeks of data, you can start making evidence-based pricing decisions. Price elasticity tells you whether lowering your IAP price would increase total revenue (because the volume increase outweighs the price decrease) or decrease it.
# revenue_analytics.pyimport requestsfrom datetime import datetime, timedeltafrom typing import Optionaldef calculate_price_elasticity( api_key: str, product_id: str, price_change_date: str, old_price: float, new_price: float, comparison_days: int = 30) -> dict: """ Measure revenue impact of a price change by comparing purchase rates before and after. Elasticity < -1: Elastic — lowering price could increase total revenue Elasticity > -1: Inelastic — raising price could increase total revenue Elasticity = -1: Unit elastic — price has no net revenue impact """ headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } change_date = datetime.strptime(price_change_date, "%Y-%m-%d") before_start = change_date - timedelta(days=comparison_days) before_end = change_date after_start = change_date after_end = change_date + timedelta(days=comparison_days) def fetch_purchases(start: datetime, end: datetime) -> Optional[int]: params = { "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d"), "product_identifier": product_id, } try: response = requests.get( "https://api.revenuecat.com/v1/charts/overview", headers=headers, params=params, timeout=10 ) response.raise_for_status() return response.json().get("metrics", {}).get("initial_purchases") except requests.RequestException as e: print(f"API request failed: {e}") return None purchases_before = fetch_purchases(before_start, before_end) purchases_after = fetch_purchases(after_start, after_end) if purchases_before is None or purchases_after is None: return {"error": "Failed to fetch purchase data from RevenueCat"} if purchases_before == 0: return {"error": "No purchases in the before period — need more data"} purchase_change = (purchases_after - purchases_before) / purchases_before price_change = (new_price - old_price) / old_price if price_change == 0: return {"error": "No price change between old_price and new_price"} elasticity = purchase_change / price_change revenue_before = purchases_before * old_price revenue_after = purchases_after * new_price revenue_delta = revenue_after - revenue_before revenue_change_rate = revenue_delta / revenue_before if revenue_before > 0 else 0 if elasticity < -1.5: recommendation = f"Strong evidence to lower price further (to ~${old_price * 0.7:.2f})" elif -1.5 <= elasticity < -1: recommendation = f"Consider a modest price reduction (to ~${new_price * 0.9:.2f})" elif -1 <= elasticity < -0.5: recommendation = "Price appears near optimal — small changes likely neutral" else: recommendation = f"Inelastic demand — consider raising price to ~${new_price * 1.2:.2f}" return { "product_id": product_id, "price_change_date": price_change_date, "old_price": f"${old_price:.2f}", "new_price": f"${new_price:.2f}", "price_change": f"{price_change:.1%}", "purchases_before": purchases_before, "purchases_after": purchases_after, "purchase_change": f"{purchase_change:.1%}", "elasticity": round(elasticity, 2), "revenue_before": f"${revenue_before:.2f}", "revenue_after": f"${revenue_after:.2f}", "revenue_impact": f"{revenue_change_rate:+.1%} (${revenue_delta:+.2f})", "recommendation": recommendation }if __name__ == "__main__": result = calculate_price_elasticity( api_key="YOUR_REVENUECAT_API_KEY", product_id="com.yourapp.premium_monthly", price_change_date="2026-04-01", old_price=4.99, new_price=2.99, comparison_days=30 ) import json print(json.dumps(result, indent=2))
The most valuable application of this script isn't a one-time run — it's scheduling it as a monthly Firebase Function that writes results to Firestore and generates a Slack report. Over time you build a history of how your price sensitivity evolves as your app matures.
Step 5: Revenue Monitoring and Alerting
The last component is knowing when something breaks before your users do. Revenue drops can be silent — an SDK update disabling ads, a payment processing issue, an App Store algorithmic change — and you often won't notice until you check your monthly report.
The monitoring function compares against both yesterday and a 7-day rolling average. Yesterday comparison catches sudden drops; rolling average comparison catches gradual declines that day-over-day comparison might miss.
Working with Antigravity on This System
A few patterns that consistently speed up implementation when building cross-platform revenue systems in Antigravity:
Open all three projects simultaneously: Antigravity works best when it can see the full system. Keep iOS, Android, and Firebase Functions open in the same workspace. The agents.md file I showed earlier ensures Antigravity understands how they relate.
Lead with the error, not the description: When Firestore security rules reject a write (this will happen, usually on the first attempt), paste the exact error log from Firebase console before describing the problem. Antigravity resolves security rule issues significantly faster with the actual rejection reason than with "writes are failing."
Use the Firebase emulator suite: Ask Antigravity to configure a local emulator setup once. After that, you can test the entire webhook flow — RevenueCat events arriving, Firestore writes, daily monitor triggers — without touching production data. This is especially valuable when implementing the aggregation logic, where bugs in a transaction can corrupt days of data.
Test sandbox RevenueCat events in isolation: RevenueCat's sandbox environment sends event payloads with "environment": "SANDBOX". Set up a separate Firebase project just for testing, point your debug app builds at it, and verify that sandbox events are correctly filtered before deploying the webhook.
Step 6: Subscription Churn Analysis and Retention
The most asymmetric investment in this entire system is churn reduction. Every subscriber you retain is revenue you don't have to acquire again. Here's a Firebase Function that tracks cohort retention and surfaces the users most likely to churn before they actually do.
// functions/src/subscriptionRetention.tsimport { onSchedule } from "firebase-functions/v2/scheduler";import { getFirestore, Timestamp } from "firebase-admin/firestore";import * as logger from "firebase-functions/logger";interface SubscriberCohort { cohortMonth: string; // "2026-01" initialSubscribers: number; retainedAt30Days: number; retainedAt60Days: number; retainedAt90Days: number; retentionRate30: number; retentionRate60: number; retentionRate90: number; avgRevenuePerSubscriber: number;}// Runs daily to update cohort retention metricsexport const updateRetentionMetrics = onSchedule("0 10 * * *", async () => { const db = getFirestore(); // Pull all subscription events from last 120 days const cutoff = Timestamp.fromDate(new Date(Date.now() - 120 * 86_400_000)); const eventsSnapshot = await db .collection("revenue_events") .where("isSubscription", "==", true) .where("timestamp", ">=", cutoff) .orderBy("timestamp", "asc") .get(); // Group INITIAL_PURCHASE events by cohort month const cohortMap = new Map<string, Set<string>>(); const cancellationMap = new Map<string, { userId: string; date: Date }[]>(); for (const doc of eventsSnapshot.docs) { const data = doc.data(); const eventDate = (data.timestamp as Timestamp).toDate(); const cohortMonth = eventDate.toISOString().substring(0, 7); if (data.eventType === "INITIAL_PURCHASE") { if (!cohortMap.has(cohortMonth)) { cohortMap.set(cohortMonth, new Set()); } cohortMap.get(cohortMonth)!.add(data.userId); } else if (data.eventType === "CANCELLATION") { if (!cancellationMap.has(data.userId)) { cancellationMap.set(data.userId, []); } cancellationMap.get(data.userId)!.push({ userId: data.userId, date: eventDate }); } } // Calculate retention for each cohort const cohorts: SubscriberCohort[] = []; for (const [cohortMonth, subscribers] of cohortMap.entries()) { const cohortStart = new Date(cohortMonth + "-01"); const now = new Date(); const daysSinceCohortStart = Math.floor( (now.getTime() - cohortStart.getTime()) / 86_400_000 ); // Only compute metrics for cohorts old enough if (daysSinceCohortStart < 30) continue; let retained30 = 0; let retained60 = 0; let retained90 = 0; for (const userId of subscribers) { const cancellations = cancellationMap.get(userId) ?? []; const cancelledWithin30 = cancellations.some(c => { const daysSinceStart = (c.date.getTime() - cohortStart.getTime()) / 86_400_000; return daysSinceStart <= 30; }); const cancelledWithin60 = cancellations.some(c => { const daysSinceStart = (c.date.getTime() - cohortStart.getTime()) / 86_400_000; return daysSinceStart <= 60; }); const cancelledWithin90 = cancellations.some(c => { const daysSinceStart = (c.date.getTime() - cohortStart.getTime()) / 86_400_000; return daysSinceStart <= 90; }); if (!cancelledWithin30) retained30++; if (!cancelledWithin60 && daysSinceCohortStart >= 60) retained60++; if (!cancelledWithin90 && daysSinceCohortStart >= 90) retained90++; } const total = subscribers.size; cohorts.push({ cohortMonth, initialSubscribers: total, retainedAt30Days: retained30, retainedAt60Days: daysSinceCohortStart >= 60 ? retained60 : -1, retainedAt90Days: daysSinceCohortStart >= 90 ? retained90 : -1, retentionRate30: total > 0 ? retained30 / total : 0, retentionRate60: total > 0 && daysSinceCohortStart >= 60 ? retained60 / total : -1, retentionRate90: total > 0 && daysSinceCohortStart >= 90 ? retained90 / total : -1, avgRevenuePerSubscriber: 0, // Populated in a separate pass }); } // Write cohort data to Firestore const batch = db.batch(); for (const cohort of cohorts) { const docRef = db.collection("subscription_cohorts").doc(cohort.cohortMonth); batch.set(docRef, cohort, { merge: true }); } await batch.commit(); logger.info(`Retention metrics updated for ${cohorts.length} cohorts`);});
Using Retention Data to Reduce Churn
Raw retention rates tell you how bad the problem is. What actually reduces churn is acting on leading indicators — users who are drifting before they cancel.
The most reliable leading indicator I've found: a drop in session frequency among subscribers. An active subscriber who suddenly goes from daily to weekly usage is a churn risk. Here's how to surface that signal:
// functions/src/churnRiskDetector.tsimport { onSchedule } from "firebase-functions/v2/scheduler";import { getFirestore, Timestamp } from "firebase-admin/firestore";export const detectChurnRisk = onSchedule("0 11 * * 0", async () => { // Run weekly — churn signals develop over days, not hours const db = getFirestore(); const thirtyDaysAgo = Timestamp.fromDate(new Date(Date.now() - 30 * 86_400_000)); const sevenDaysAgo = Timestamp.fromDate(new Date(Date.now() - 7 * 86_400_000)); // Get active subscribers const activeSubsSnapshot = await db .collection("revenue_events") .where("eventType", "==", "INITIAL_PURCHASE") .where("isSubscription", "==", true) .where("timestamp", ">=", thirtyDaysAgo) .get(); const activeUserIds = new Set(activeSubsSnapshot.docs.map(d => d.data().userId)); // Check session activity for each subscriber const atRiskUsers: string[] = []; for (const userId of activeUserIds) { const recentSessionsSnapshot = await db .collection("user_sessions") .where("userId", "==", userId) .where("timestamp", ">=", sevenDaysAgo) .get(); const recentSessionCount = recentSessionsSnapshot.size; // Subscribers with less than 1 session in the last 7 days are at risk if (recentSessionCount < 1) { atRiskUsers.push(userId); } } // Write at-risk list to Firestore for use in push notification campaigns if (atRiskUsers.length > 0) { await db.collection("churn_risk").doc("latest").set({ userIds: atRiskUsers, detectedAt: Timestamp.now(), count: atRiskUsers.length, }); }});
Once you have the at-risk user list in Firestore, you can connect it to Firebase Cloud Messaging to send a targeted re-engagement push notification — ideally one that highlights a feature they haven't used recently rather than a generic "we miss you" message. Antigravity can implement the full FCM integration when you ask it to extend this system.
Understanding Revenue Model Trade-offs
Before implementing, it's worth being explicit about the trade-offs in each model combination:
AdMob-heavy strategy works best when: your app has a large free user base, session durations are short (under 3 minutes), and your target markets include regions where IAP conversion rates are low (India, Southeast Asia, parts of Latin America).
The risk: aggressive ad frequency damages retention more than it adds revenue. The rule I apply is that ad revenue per session should never exceed the estimated LTV cost of the retention it reduces. You won't know this number without the unified dashboard.
IAP-forward strategy works best when: your app has a clear "power user" use case where specific features matter, you can make the free tier genuinely useful (so users stick around long enough to hit the paywall naturally), and your markets skew toward higher-income regions.
The risk: poorly timed IAP prompts create hostility. Users who feel pressured to buy before they've experienced value churn faster than users who were never shown an IAP prompt.
Subscription strategy works best when: your app delivers ongoing, evolving value (not a one-time tool), you can clearly articulate what changes each month, and you have the operational bandwidth to keep the subscription tier meaningfully better than the free tier.
The risk: subscription fatigue is real. Users who subscribe impulsively and don't see immediate value cancel within the first billing cycle. Your 30-day retention rate is the leading indicator of whether your subscription value proposition is working.
The unified approach works because it lets you use each model where it fits rather than committing your entire user base to one strategy. A user who's been in your app for 4 days and has 8 sessions is a different customer than a first-day user — they deserve a different offer.
Where to Start
If you're building this from scratch, this is the order that minimizes wasted effort:
First, get the RevenueCat webhook writing to Firestore. This gives you ground-truth purchase data within 24 hours of deployment. Second, add the AdMob client-side tracking for trend visibility. Third, deploy the daily monitor — this forces you to think about what "normal" looks like before you have enough data to define baselines automatically.
The Remote Config model switching and price elasticity analysis come later, once you have enough users and data to run meaningful experiments. Running an A/B test with 50 users gives you noise, not signal.
The deeper value of this system isn't any single component — it's that it changes how you make decisions. Most indie developers run their monetization strategy on intuition and monthly revenue reports. With this pipeline running, you'll catch issues in hours rather than weeks, and you'll have the data to distinguish between "this IAP price is wrong" and "this user cohort doesn't convert on IAP at all." That distinction is worth more than any individual optimization.
Share
Thank You for Reading
Antigravity 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.