Monetizing Flutter Apps with RevenueCat: Subscriptions Done Right
In-app purchases are one of the most painful parts of mobile development. Apple and Google have completely different APIs, receipt validation is a nightmare, and subscription state management is full of edge cases. RevenueCat makes all of that disappear.
Why RevenueCat Instead of Raw IAP
If you've ever tried implementing in-app purchases directly, you know the pain:
- Different APIs for iOS and Android
- Server-side receipt validation
- Handling subscription renewals, cancellations, and expirations
- Grace periods and billing retry logic
- Sandbox testing that never works quite right
RevenueCat wraps all of this into a clean SDK. You call Purchases.purchasePackage(package) and it handles everything else. Entitlements are cached locally, so premium checks work offline too.
Architecture: Where Everything Lives
Following Clean Architecture, here's how monetization fits:
lib/src/feature/premium/
domain/
subscription_status.dart # Entities and enums
data/
premium_repository.dart # RevenueCat SDK wrapper
application/
premium_service.dart # Business logic
presentation/
premium_controller.dart # State management
paywall/
paywall_screen.dart # Purchase UI
The Domain: What Subscription States Exist
enum SubscriptionStatus { active, paused, expired, lifetime }
class SubscriptionState {
final bool isPremium;
final SubscriptionStatus status;
final DateTime? expirationDate;
final String? productId;
static const free = SubscriptionState(
isPremium: false,
status: SubscriptionStatus.expired,
);
}
The Repository: A Thin SDK Wrapper
class PremiumRepository {
Future<CustomerInfo> getCustomerInfo() async {
return Purchases.getCustomerInfo();
}
Future<Offerings> getOfferings() async {
return Purchases.getOfferings();
}
Future<CustomerInfo> purchase(Package package) async {
return Purchases.purchasePackage(package);
}
Future<CustomerInfo> restore() async {
return Purchases.restorePurchases();
}
}
This is intentionally thin. The repository just wraps RevenueCat SDK calls. No business logic, no validation.
The Service: Entitlement Logic
class PremiumService {
final PremiumRepository _repo;
Future<bool> isPremium() async {
final info = await _repo.getCustomerInfo();
return info.entitlements.active.containsKey('premium');
}
Future<SubscriptionState> getSubscriptionState() async {
final info = await _repo.getCustomerInfo();
final entitlement = info.entitlements.active['premium'];
if (entitlement == null) return SubscriptionState.free;
return SubscriptionState(
isPremium: true,
status: entitlement.willRenew
? SubscriptionStatus.active
: SubscriptionStatus.expired,
expirationDate: entitlement.expirationDate,
productId: entitlement.productIdentifier,
);
}
}
The Premium Gate Widget
Gate premium features with a simple widget:
class PremiumGate extends ConsumerWidget {
final Widget child;
final Widget fallback;
const PremiumGate({required this.child, required this.fallback});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPremium = ref.watch(premiumControllerProvider).valueOrNull ?? false;
return isPremium ? child : fallback;
}
}
// Usage:
PremiumGate(
child: JournalEditor(), // Premium users see the editor
fallback: UpgradePrompt(), // Free users see upgrade prompt
)
Backend Sync with Supabase Webhooks
For cross-device subscription tracking, set up RevenueCat webhooks that write to Supabase:
CREATE TYPE sub_status AS ENUM ('ACTIVE', 'PAUSED', 'EXPIRED', 'LIFETIME');
CREATE TABLE public.subscriptions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
status sub_status NOT NULL,
period_end_date timestamptz,
sku_id text NOT NULL,
last_update_date timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own subscription"
ON public.subscriptions FOR SELECT USING (auth.uid() = user_id);
The Edge Function
Deno.serve(async (req) => {
// Verify RevenueCat webhook token
const authToken = req.headers.get("Authorization")?.split(" ")[1];
if (authToken !== Deno.env.get("RC_TOKEN")) {
return new Response("Unauthorized", { status: 401 });
}
const body = await req.json();
const event = body.event;
// Map RevenueCat events to subscription status
const status = mapEventToStatus(event.type);
if (!status) return new Response("ignored", { status: 200 });
// Upsert subscription record
await supabase.from('subscriptions').upsert({
user_id: event.app_user_id,
status: status,
period_end_date: event.expiration_at_ms
? new Date(event.expiration_at_ms) : null,
sku_id: event.product_id,
});
return new Response("saved", { status: 200 });
});
RevenueCat Event Mapping
| Event | Status |
|---|---|
INITIAL_PURCHASE | ACTIVE |
RENEWAL | ACTIVE |
UNCANCELLATION | ACTIVE |
CANCELLATION | EXPIRED |
EXPIRATION | EXPIRED |
SUBSCRIPTION_PAUSED | PAUSED |
NON_RENEWING_PURCHASE | LIFETIME |
BILLING_ISSUE | Ignore |
TRANSFER | Ignore |
Two Strategies: Choose Your Path
Strategy A: Anonymous (No Backend)
Purchases are tied to the Apple ID or Google Play account. RevenueCat SDK caches entitlements locally. No user accounts needed. Perfect for simple apps.
Strategy B: User-Synced (With Supabase)
Purchases sync to your database via webhooks. Users have accounts, subscription data is centralized, and you get a dashboard for admin purposes. Better for apps with existing authentication.
The Rules
- Never store subscription state locally - RevenueCat is the source of truth
- Never hardcode prices - Fetch from
Offeringobjects (prices vary by country) - Always show "Restore Purchases" - App Store requires it
- Don't force login for anonymous purchases - Let users buy first, create accounts later
- Use
--no-verify-jwtwhen deploying webhook Edge Functions - Keep the paywall screen under 150 lines - Extract pricing cards into separate widgets
What To Build First
- Set up RevenueCat account and configure products in App Store Connect / Play Console
- Create the
premium/feature folder with all four layers - Implement the
isPremiumcheck andPremiumGatewidget - Build a simple paywall screen
- Add restore purchases functionality
- (Optional) Set up Supabase webhooks for backend sync
RevenueCat turns what used to be months of IAP plumbing into a weekend project. Your time is better spent making your premium features worth paying for than wrestling with receipt validation.



