Subscriptions Guide
Complete guide to implementing subscription-based purchases in your Flutter app.
Overview
Subscriptions are recurring purchases that provide access to content or services for a specific period. This guide covers subscription implementation, management, and best practices.
Basic Setup
1. Initialize IAP Connection
import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart';
class SubscriptionService {
final _iap = FlutterInappPurchase.instance;
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
try {
await _iap.initConnection();
_isInitialized = true;
_setupListeners();
} catch (e) {
print('Failed to initialize IAP: $e');
}
}
void _setupListeners() {
FlutterInappPurchase.purchaseUpdated.listen(_handlePurchase);
FlutterInappPurchase.purchaseError.listen(_handleError);
}
}
2. Fetch Subscription Products
class SubscriptionManager {
final _subscriptionIds = [
'com.example.monthly_premium',
'com.example.yearly_premium',
'com.example.basic_monthly',
];
List<IAPItem> _subscriptions = [];
Future<void> loadSubscriptions() async {
try {
_subscriptions = await FlutterInappPurchase.instance
.requestProducts(skus: _subscriptionIds, type: 'subs');
// Sort by price or preference
_subscriptions.sort((a, b) =>
_extractPrice(a).compareTo(_extractPrice(b)));
} catch (e) {
print('Error loading subscriptions: $e');
}
}
double _extractPrice(IAPItem item) {
// Extract numeric price from localizedPrice
final priceStr = item.price ?? '0';
return double.tryParse(priceStr) ?? 0.0;
}
}
Subscription Purchase Flow
Basic Purchase
Future<void> purchaseSubscription(String subscriptionId) async {
try {
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: subscriptionId),
android: RequestPurchaseAndroid(
skus: [subscriptionId],
obfuscatedAccountIdAndroid: await _getUserId(),
),
),
type: PurchaseType.subs,
);
// Result will be delivered via purchaseUpdated stream
} catch (e) {
print('Subscription purchase failed: $e');
_handlePurchaseError(e);
}
}
Advanced Purchase with Options
Future<void> purchaseSubscriptionAdvanced({
required String subscriptionId,
String? upgradeFromId,
int? prorationMode,
}) async {
try {
if (Platform.isAndroid && upgradeFromId != null) {
// Android subscription upgrade/downgrade
final currentToken = await _getCurrentSubscriptionToken(upgradeFromId);
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: subscriptionId),
android: RequestPurchaseAndroid(
skus: [subscriptionId],
purchaseTokenAndroid: currentToken,
replacementModeAndroid: prorationMode ??
AndroidProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE,
obfuscatedAccountIdAndroid: await _getUserId(),
),
),
type: PurchaseType.subs,
);
} else {
// New subscription or iOS
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: subscriptionId),
android: RequestPurchaseAndroid(
skus: [subscriptionId],
obfuscatedAccountIdAndroid: await _getUserId(),
),
),
type: PurchaseType.subs,
);
}
} catch (e) {
_handleSubscriptionError(e);
}
}
Subscription Management
Check Active Subscriptions
class SubscriptionChecker {
Future<SubscriptionStatus> checkSubscriptionStatus() async {
try {
final purchases = await FlutterInappPurchase.instance.getAvailablePurchases();
final activeSubscriptions = purchases.where((purchase) =>
_isSubscription(purchase.productId) &&
_isActive(purchase)
).toList();
if (activeSubscriptions.isEmpty) {
return SubscriptionStatus(isActive: false);
}
// Get highest tier subscription
final activeSub = _getHighestTierSubscription(activeSubscriptions);
return SubscriptionStatus(
isActive: true,
productId: activeSub.productId,
expirationDate: _calculateExpirationDate(activeSub),
isInGracePeriod: _isInGracePeriod(activeSub),
);
} catch (e) {
print('Error checking subscription status: $e');
return SubscriptionStatus(isActive: false);
}
}
bool _isSubscription(String? productId) {
return productId?.contains('subscription') ?? false;
}
bool _isActive(Purchase purchase) {
// Check platform-specific active status
if (Platform.isAndroid) {
return purchase.purchaseStateAndroid == 'purchased';
}
return true; // iOS purchases in the list are active
}
}
Handle Subscription Changes
class SubscriptionChangeHandler {
Future<void> upgradeSubscription({
required String fromProductId,
required String toProductId,
}) async {
try {
if (Platform.isAndroid) {
// Get current subscription token
final currentToken = await _getCurrentSubscriptionToken(fromProductId);
if (currentToken != null) {
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: toProductId),
android: RequestPurchaseAndroid(
skus: [toProductId],
purchaseTokenAndroid: currentToken,
replacementModeAndroid: AndroidProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE,
),
),
type: PurchaseType.subs,
);
} else {
throw Exception('Current subscription not found');
}
} else {
// iOS handles this automatically through subscription groups
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: toProductId),
android: RequestPurchaseAndroid(skus: [toProductId]),
),
type: PurchaseType.subs,
);
}
} catch (e) {
print('Subscription upgrade failed: $e');
}
}
Future<void> cancelSubscription(String productId) async {
if (Platform.isIOS) {
// Redirect to App Store subscription management
await FlutterInappPurchase.instance.showManageSubscriptionsIOS();
} else if (Platform.isAndroid) {
// Redirect to Google Play subscription management
await FlutterInappPurchase.instance.deepLinkToSubscriptionsAndroid(
sku: productId,
packageName: 'com.example.app',
);
}
}
}
Subscription UI Components
Subscription Card Widget
class SubscriptionCard extends StatelessWidget {
final IAPItem subscription;
final bool isCurrentPlan;
final VoidCallback onTap;
const SubscriptionCard({
Key? key,
required this.subscription,
required this.isCurrentPlan,
required this.onTap,
}) : super(key: key);
Widget build(BuildContext context) {
return Card(
elevation: isCurrentPlan ? 8 : 2,
child: InkWell(
onTap: isCurrentPlan ? null : onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
subscription.title ?? 'Subscription',
style: Theme.of(context).textTheme.headlineSmall,
),
if (isCurrentPlan)
Chip(
label: Text('Current'),
backgroundColor: Colors.green,
),
],
),
const SizedBox(height: 8),
Text(subscription.description ?? ''),
const SizedBox(height: 16),
_buildPriceInfo(context),
const SizedBox(height: 16),
if (!isCurrentPlan)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onTap,
child: Text('Subscribe for ${subscription.localizedPrice}'),
),
),
],
),
),
),
);
}
Widget _buildPriceInfo(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Price: ${subscription.localizedPrice}',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'Billing: ${_getBillingPeriod()}',
style: Theme.of(context).textTheme.bodyMedium,
),
if (_hasFreeTrial())
Text(
'Free trial: ${_getTrialPeriod()}',
style: TextStyle(color: Colors.green),
),
],
);
}
String _getBillingPeriod() {
if (Platform.isIOS) {
final unit = subscription.subscriptionPeriodUnitIOS?.toLowerCase() ?? '';
final number = subscription.subscriptionPeriodNumberIOS ?? '1';
return '$number $unit${number != '1' ? 's' : ''}';
} else {
final period = subscription.subscriptionPeriodAndroid ?? '';
return _formatAndroidPeriod(period);
}
}
String _formatAndroidPeriod(String period) {
switch (period) {
case 'P1M': return 'monthly';
case 'P1Y': return 'yearly';
case 'P1W': return 'weekly';
default: return period;
}
}
bool _hasFreeTrial() {
return subscription.introductoryPrice == '0' ||
subscription.introductoryPrice == '0.00';
}
String _getTrialPeriod() {
// Extract trial period from introductory price details
return '7 days'; // Simplified
}
}
Subscription Status Widget
class SubscriptionStatusWidget extends StatefulWidget {
_SubscriptionStatusWidgetState createState() => _SubscriptionStatusWidgetState();
}
class _SubscriptionStatusWidgetState extends State<SubscriptionStatusWidget> {
SubscriptionStatus? _status;
bool _loading = true;
void initState() {
super.initState();
_checkStatus();
}
Future<void> _checkStatus() async {
setState(() => _loading = true);
try {
final status = await SubscriptionChecker().checkSubscriptionStatus();
setState(() {
_status = status;
_loading = false;
});
} catch (e) {
setState(() => _loading = false);
print('Error checking subscription status: $e');
}
}
Widget build(BuildContext context) {
if (_loading) {
return CircularProgressIndicator();
}
if (_status?.isActive != true) {
return _buildInactiveStatus();
}
return _buildActiveStatus();
}
Widget _buildActiveStatus() {
return Card(
color: Colors.green.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Text(
'Active Subscription',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
const SizedBox(height: 8),
Text('Plan: ${_status!.productId}'),
if (_status!.expirationDate != null)
Text('Expires: ${_formatDate(_status!.expirationDate!)}'),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: _manageSubscription,
child: Text('Manage'),
),
const SizedBox(width: 8),
TextButton(
onPressed: _checkStatus,
child: Text('Refresh'),
),
],
),
],
),
),
);
}
Widget _buildInactiveStatus() {
return Card(
color: Colors.grey.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No Active Subscription',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('Subscribe to unlock premium features'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _showSubscriptionOptions,
child: Text('View Plans'),
),
],
),
),
);
}
void _manageSubscription() async {
if (Platform.isIOS) {
await FlutterInappPurchase.instance.showManageSubscriptionsIOS();
} else {
// Show Android management options
_showAndroidManagementOptions();
}
}
void _showSubscriptionOptions() {
// Navigate to subscription selection screen
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
}
Platform-Specific Considerations
iOS Subscriptions
class IOSSubscriptionHandler {
// Handle subscription groups
Future<void> handleSubscriptionGroup(String newSubscriptionId) async {
// iOS automatically manages subscription groups
// Users can only have one active subscription per group
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: newSubscriptionId),
android: RequestPurchaseAndroid(skus: [newSubscriptionId]),
),
type: PurchaseType.subs,
);
}
// Handle promotional offers
Future<void> purchaseWithPromoOffer({
required String subscriptionId,
required String offerId,
required String keyId,
required String nonce,
required String signature,
required int timestamp,
}) async {
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(
sku: subscriptionId,
withOffer: {
'identifier': offerId,
'keyIdentifier': keyId,
'nonce': nonce,
'signature': signature,
'timestamp': timestamp,
},
),
),
type: PurchaseType.subs,
);
}
}
Android Subscriptions
class AndroidSubscriptionHandler {
// Handle base plans and offers
Future<void> purchaseWithOffer({
required String subscriptionId,
required int offerIndex,
}) async {
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: subscriptionId),
android: RequestPurchaseAndroid(
skus: [subscriptionId],
subscriptionOffers: [{offerToken: offerIndex.toString()}],
),
),
type: PurchaseType.subs,
);
}
// Handle subscription upgrades/downgrades
Future<void> changeSubscription({
required String oldSubscriptionId,
required String newSubscriptionId,
required int prorationMode,
}) async {
final oldToken = await _getCurrentSubscriptionToken(oldSubscriptionId);
if (oldToken != null) {
await FlutterInappPurchase.instance.requestPurchase(
request: RequestPurchase(
ios: RequestPurchaseIOS(sku: newSubscriptionId),
android: RequestPurchaseAndroid(
skus: [newSubscriptionId],
purchaseTokenAndroid: oldToken,
replacementModeAndroid: prorationMode,
),
),
type: PurchaseType.subs,
);
}
}
}
Subscription Validation
Server-Side Validation
class SubscriptionValidator {
Future<bool> validateSubscription(Purchase purchase) async {
try {
// Always validate subscriptions server-side
final response = await _validateWithServer(purchase);
if (response.isValid) {
// Check expiration
if (response.expirationDate?.isAfter(DateTime.now()) == true) {
return true;
}
}
return false;
} catch (e) {
print('Subscription validation error: $e');
return false;
}
}
Future<ValidationResponse> _validateWithServer(Purchase purchase) async {
// Implement server validation
// Return validation result including expiration date
throw UnimplementedError();
}
}
Best Practices
- Always Validate Server-Side: Subscriptions should be validated on your server
- Handle Gracefully: Provide grace periods for failed renewals
- Clear Pricing: Display all pricing information clearly
- Easy Management: Provide easy access to subscription management
- Test Thoroughly: Test all subscription scenarios including upgrades
- Monitor Metrics: Track subscription metrics and churn
Testing Subscriptions
Sandbox Testing (iOS)
- Create sandbox test accounts in App Store Connect
- Sign out of your Apple ID in Settings
- When purchasing, sign in with sandbox account
- Use special subscription durations for testing
Test Purchases (Android)
- Create test accounts in Google Play Console
- Upload APK to internal testing track
- Add test accounts as testers
- Use test product IDs for development
class SubscriptionTesting {
static const testSubscriptions = [
'android.test.purchased',
'android.test.canceled',
'android.test.item_unavailable',
];
static bool get isTestMode {
return kDebugMode || _isTestFlavor;
}
static Future<void> simulateSubscriptionRenewal() async {
// Simulate renewal for testing
if (isTestMode) {
await Future.delayed(Duration(seconds: 5));
// Trigger renewal logic
}
}
}
Related Documentation
- Purchases Guide - General purchase handling
- Receipt Validation - Validating receipts
- Error Handling - Handling subscription errors
- API Reference - Subscription API methods