Flutter In-App Purchases & Subscriptions Architecture
How to eliminate purchase fraud, handle asynchronous webhooks, manage subscription lifecycles, and prevent automatic Google Play refunds across iOS and Android.
Author: Syed Waleed Nawaz
Senior Flutter Developer & Mobile Backend Engineer
- 1. The Fallacy of Client-Side Receipt Trust: Why Apps Bleed Revenue
- 2. The Flutter Client Workflow: Stream Initialization and Deferred Completion
- 3. Server-Side Verification Pipeline: Apple StoreKit 2 JWS and Google Play Developer API v3
- 4. Subscription Lifecycle State Machine: Managing Webhooks, Grace Periods, and Account Holds
- 5. Account Binding and Cross-Platform Restore: Preventing Entitlement Hijacking
1. The Fallacy of Client-Side Receipt Trust: Why Apps Bleed Revenue
In mobile commerce, developer velocity often collides with security fundamentals. When integrating in-app purchases (IAP) or recurring subscriptions into a Flutter application, the easiest implementation path is also the most hazardous: listening to in_app_purchase events, detecting a PurchaseStatus.purchased event on the client, and immediately granting the user premium features in local storage or updating a Firestore document directly from the client SDK.
Treating the mobile client as a trusted execution environment creates three catastrophic vulnerabilities: First, client-side purchase flows are trivial to compromise on rooted Android or jailbroken iOS devices using memory-manipulation tools (such as Frida) or local proxy spoofers that synthesize fake success responses. Second, client-side receipt verification lacks an authoritative audit log, leaving apps vulnerable to replay attacks where a single valid receipt token is submitted across hundreds of illegitimate accounts. Third, client-only subscriptions fail when a user switches platforms—an iOS subscriber logging into an Android build is locked out because their subscription status lives exclusively in Apple's local receipt silo rather than a centralized user entitlement table.
Crucially, for Android apps, naive client-side flows trigger Google Play's 3-day auto-refund mechanism. Under Google Play Billing specifications, every subscription purchase must be formally acknowledged by the developer's backend (via the Android Publisher API purchases.subscriptions.acknowledge or purchases.subscriptionsv2). If your backend fails to acknowledge the purchase token within 3 calendar days, Google automatically cancels the order, revokes the transaction, and refunds the customer's money. An app that assumes the client's local completion is sufficient will bleed revenue and churn legitimate users unknowingly.
A battle-tested mobile subscription architecture separates concerns into a strict hierarchy: the Flutter client acts strictly as an unauthenticated presentation interface responsible for displaying localized store prices and initiating store payment sheets. Your secure cloud backend acts as the sole, authoritative gatekeeper that validates cryptographic signatures with Apple and Google, manages lifecycle state transitions, and issues entitlement tokens to authenticated users.
Key Takeaway: Never grant digital entitlements based on client-side status events. StoreKit and Google Play transactions must be verified, acknowledged, and persisted by your backend server before unlocking premium functionality.
2. The Flutter Client Workflow: Stream Initialization and Deferred Completion
Implementing package:in_app_purchase in production requires respecting the platform store lifecycle across application lifecycles. A common architectural defect is subscribing to InAppPurchase.instance.purchaseStream only inside the checkout or paywall widget's initState(). When a user backgrounds the app during biometric confirmation, or when a network hiccup delays transaction finalization, the paywall widget unmounts. When the user relaunches the app later, the pending purchase event is dropped or left in an uncompleted purgatory state.
In production systems—such as our work on Sub Tracker and the Seyyah Mobile eSIM platform—we instantiate a singleton BillingService during app boot (in main.dart or a top-level provider/BLoC) that binds to purchaseStream before the first frame renders. This ensures that any interrupted transactions or deferred purchases (such as Apple's Ask-to-Buy parental approvals) are captured immediately upon app launch.
The crucial invariant is deferred completion. When purchaseStream delivers a purchase with status == PurchaseStatus.purchased or PurchaseStatus.restored, the Flutter app must NOT call iap.completePurchase(purchase) immediately. Instead, it packages purchase.verificationData.serverVerificationData (the signed JWS transaction string on iOS, or the JSON payload containing the Google purchaseToken and orderId on Android) alongside the active user's authorization bearer token, and dispatches it to your backend API (/api/v1/billing/verify). Only after the server validates the signature with the store API, persists the entitlement to PostgreSQL, and returns an HTTP 200 OK does Flutter invoke completePurchase().
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
class ProductionBillingService {
final InAppPurchase _iap = InAppPurchase.instance;
final ApiClient _apiClient;
StreamSubscription<List<PurchaseDetails>>? _subscription;
ProductionBillingService({required ApiClient apiClient}) : _apiClient = apiClient;
void initialize() {
_subscription = _iap.purchaseStream.listen(
_handlePurchaseUpdates,
onDone: () => _subscription?.cancel(),
onError: (error) => debugPrint('[Billing] Stream error: $error'),
);
}
Future<void> purchaseSubscription(ProductDetails product) async {
final available = await _iap.isAvailable();
if (!available) throw StateError('Store billing unavailable on this device.');
final purchaseParam = PurchaseParam(productDetails: product);
await _iap.buyNonConsumable(purchaseParam: purchaseParam);
}
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
for (final purchase in purchases) {
switch (purchase.status) {
case PurchaseStatus.pending:
debugPrint('[Billing] Purchase pending confirmation: ${purchase.productID}');
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
await _verifyAndFinalize(purchase);
break;
case PurchaseStatus.error:
debugPrint('[Billing] Purchase error: ${purchase.error?.message}');
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
}
break;
case PurchaseStatus.canceled:
debugPrint('[Billing] Purchase canceled by user');
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
}
break;
}
}
}
Future<void> _verifyAndFinalize(PurchaseDetails purchase) async {
try {
// 1. Transmit store payload to server-authoritative backend
final response = await _apiClient.post(
'/api/v1/billing/verify',
data: {
'platform': defaultTargetPlatform == TargetPlatform.iOS ? 'apple' : 'google',
'product_id': purchase.productID,
'server_verification_data': purchase.verificationData.serverVerificationData,
'source': purchase.verificationData.source,
'transaction_date': purchase.transactionDate,
},
);
// 2. Only complete with the platform store AFTER backend validates & acknowledges
if (response.statusCode == 200 && purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
debugPrint('[Billing] Transaction finalized: ${purchase.purchaseID}');
}
} catch (e) {
// Network drop: do NOT completePurchase. The stream will redeliver on next launch.
debugPrint('[Billing] Backend verification failed: $e. Leaving transaction open.');
}
}
void dispose() {
_subscription?.cancel();
}
}Key Takeaway: If backend verification fails due to connectivity loss, leave the transaction uncompleted. Both Apple and Google will redeliver the unacknowledged transaction on subsequent app startups, guaranteeing zero lost revenue without prematurely dismissing store events.
3. Server-Side Verification Pipeline: Apple StoreKit 2 JWS and Google Play Developer API v3
On the backend, verifying in-app purchases has evolved significantly. Developers who rely on legacy tutorials often implement Apple's verifyReceipt endpoint, which Apple officially deprecated in favor of the App Store Server API and StoreKit 2. Similarly, on Google Play, legacy Android Publisher v1 and v2 subscription endpoints have been replaced with the unified subscriptionsv2 resource.
Apple StoreKit 2 verification uses JSON Web Signatures (JWS Compact Serialization) conforming to RFC 7515. The Flutter client provides serverVerificationData, which contains the signed transaction string in header.payload.signature format. Rather than trusting client claims, the backend decodes the JWS header to inspect the x5c (X.509 certificate chain), validates that the intermediate certificate chains up to the trusted Apple Inc. Root CA, and verifies the ECDSA P-256 signature against the leaf certificate's public key. The payload reveals immutable transaction properties: originalTransactionId, productId, purchaseDate, expiresDate, and environment ('Sandbox' vs 'Production').
On Google Play, the verification flow operates through the Google Play Developer API v3 using server-to-server OAuth2 service account credentials. When the Flutter Android client submits its purchaseToken, the backend queries GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token} (the purchases.subscriptionsv2.get method). The response returns the authoritative subscriptionState (such as SUBSCRIPTION_STATE_ACTIVE or SUBSCRIPTION_STATE_IN_GRACE_PERIOD), the lineItems array containing expiry timestamps, and payment state.
Crucially, for new Google Play subscriptions, the backend must immediately issue an acknowledgment request (purchases.subscriptions.acknowledge or within the subscription lifecycle) if acknowledgementState == ACKNOWLEDGEMENT_STATE_PENDING. Once verified and acknowledged, the backend writes the record to PostgreSQL, computes the user's active entitlements, and returns the updated entitlement profile to the Flutter client.
import { Request, Response } from 'express';
import { google } from 'googleapis';
import * as jose from 'jose';
// Google Android Publisher Client initialization
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_JSON_PATH,
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const androidPublisher = google.androidpublisher({ version: 'v3', auth });
export async function verifyPurchaseHandler(req: Request, res: Response) {
const userId = req.user.id;
const { platform, product_id, server_verification_data } = req.body;
try {
if (platform === 'apple') {
// 1. Verify Apple StoreKit 2 JWS compact signature
const jws = server_verification_data;
const header = jose.decodeProtectedHeader(jws);
// Extract x5c certificate chain and verify against Apple Root CA
const certificates = header.x5c;
if (!certificates || certificates.length === 0) {
return res.status(400).json({ error: 'Missing Apple x5c certificate chain' });
}
const leafCertDer = Buffer.from(certificates[0], 'base64');
const publicKey = await jose.importX509(leafCertDer.toString('utf-8'), header.alg!);
const { payload } = await jose.jwtVerify(jws, publicKey);
const appleTransaction = payload as {
transactionId: string;
originalTransactionId: string;
productId: string;
expiresDate: number; // Milliseconds Unix
environment: 'Sandbox' | 'Production';
};
// 2. Persist entitlement in PostgreSQL with atomic upsert
const entitlement = await db.entitlements.upsert({
userId,
platform: 'apple',
originalTransactionId: appleTransaction.originalTransactionId,
productId: appleTransaction.productId,
status: appleTransaction.expiresDate > Date.now() ? 'ACTIVE' : 'EXPIRED',
expiresAt: new Date(appleTransaction.expiresDate),
});
return res.status(200).json({ success: true, entitlement });
}
if (platform === 'google') {
const { purchaseToken, packageName } = JSON.parse(server_verification_data);
// 1. Query Google Play Developer API v3 subscriptionsv2 endpoint
const subResponse = await androidPublisher.purchases.subscriptionsv2.get({
packageName,
token: purchaseToken,
});
const subData = subResponse.data;
const state = subData.subscriptionState; // 1 = ACTIVE, 2 = IN_GRACE_PERIOD, etc.
const lineItem = subData.lineItems?.[0];
const expiryTime = lineItem?.expiryTime;
// 2. Acknowledge purchase if pending to stop 3-day refund auto-cancellation
if (subData.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_PENDING') {
await androidPublisher.purchases.subscriptions.acknowledge({
packageName,
subscriptionId: product_id,
token: purchaseToken,
requestBody: { developerPayload: userId },
});
}
// 3. Persist entitlement in database
const entitlement = await db.entitlements.upsert({
userId,
platform: 'google',
originalTransactionId: purchaseToken,
productId: product_id,
status: state === 'SUBSCRIPTION_STATE_ACTIVE' ? 'ACTIVE' : 'IN_GRACE_PERIOD',
expiresAt: expiryTime ? new Date(expiryTime) : new Date(Date.now() + 30 * 86400000),
});
return res.status(200).json({ success: true, entitlement });
}
return res.status(400).json({ error: 'Unsupported billing platform' });
} catch (err: any) {
console.error('[Billing Verification Error]', err);
return res.status(500).json({ error: 'Failed to verify store transaction' });
}
}Key Takeaway: Apple StoreKit 2 JWS transactions and Google Play subscriptionsv2 APIs eliminate opaque client receipts. By decoding trusted X.509 chains and querying Google Play's service endpoints with OAuth2 service accounts, backend verification becomes tamper-proof and mathematically verifiable.
4. Subscription Lifecycle State Machine: Managing Webhooks, Grace Periods, and Account Holds
A customer's initial purchase is merely the opening step of a multi-year subscription lifecycle. In real-world SaaS and mobile consumer applications, recurring payments encounter monthly friction: credit cards expire, bank fraud filters decline renewals, users cancel renewal flags, or customers request refunds via Apple Support.
Relying on the Flutter client to notify your backend when a renewal occurs is impossible—the user may not open the app for weeks. Instead, subscription lifecycles must be driven asynchronously by store webhooks: Apple App Store Server Notifications v2 and Google Cloud Pub/Sub Real-Time Developer Notifications (RTDN).
Apple App Store Server Notifications v2 push JWS-encoded event payloads containing signedTransactionInfo. Critical event types include SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, GRACE_PERIOD_EXPIRED, REFUND, and REVOKE. In parallel, Google Play publishes RTDN messages to your Google Cloud Pub/Sub topic containing numeric notificationType enums: 2 (SUBSCRIPTION_RENEWED), 5 (SUBSCRIPTION_ON_HOLD), 6 (SUBSCRIPTION_IN_GRACE_PERIOD), 10 (SUBSCRIPTION_PAUSED), 12 (SUBSCRIPTION_REVOKED), and 13 (SUBSCRIPTION_EXPIRED). When an RTDN message arrives, your backend uses the purchaseToken to query purchases.subscriptionsv2.get to obtain the canonical state.
To handle these states without revenue leakage or premature user lockouts, your system should model a 7-stage entitlement state machine: 1) ACTIVE (normal access), 2) IN_GRACE_PERIOD (payment failed, but Apple/Google gives 6-16 days to fix billing; keep access open while displaying an in-app banner), 3) ON_HOLD (grace period expired; revoke access but preserve customer data), 4) PAUSED (user elected to pause subscription), 5) CANCELED (user turned off auto-renew; access remains until expires_at), 6) EXPIRED (period elapsed without renewal; revoke access), and 7) REVOKED (refund granted or family sharing revoked; immediate revocation).
To prevent race conditions caused by network retries or out-of-order webhook delivery (e.g. an EXPIRED webhook arriving after a DID_RENEW webhook), every webhook ingestion worker must be idempotent and monotonic: record each notificationUUID or Pub/Sub message ID, and update expires_at only if the incoming event timestamp is newer than the existing database record.
-- 1. Subscriptions Table: Authoritative store link
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL CHECK (platform IN ('apple', 'google', 'stripe')),
original_transaction_id VARCHAR(255) NOT NULL UNIQUE, -- StoreKit originalTransactionId or Google purchaseToken
product_id VARCHAR(100) NOT NULL,
status VARCHAR(30) NOT NULL CHECK (
status IN ('ACTIVE', 'IN_GRACE_PERIOD', 'ON_HOLD', 'PAUSED', 'CANCELED', 'EXPIRED', 'REVOKED')
),
auto_renew_enabled BOOLEAN NOT NULL DEFAULT true,
starts_at TIMESTAMPTZ NOT NULL,
current_period_expires_at TIMESTAMPTZ NOT NULL,
grace_period_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fast lookup for auth middleware checking user entitlement
CREATE INDEX idx_subscriptions_user_status ON subscriptions(user_id, status);
-- 2. Entitlements Table: High-level feature flags mapped to subscriptions
CREATE TABLE user_entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
feature_key VARCHAR(50) NOT NULL, -- e.g., 'premium_tier', 'cloud_sync_unlimited'
is_active BOOLEAN NOT NULL DEFAULT false,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE(user_id, feature_key)
);
-- 3. Webhook Audit Logs: Guaranteeing strict idempotency
CREATE TABLE billing_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(20) NOT NULL,
event_id VARCHAR(255) NOT NULL UNIQUE, -- Apple notificationUUID or Google Pub/Sub message_id
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Key Takeaway: Treat store webhooks as asynchronous change signals, not instant entitlement switches. Maintain an idempotent audit log of incoming notification UUIDs and reconcile against the store's authoritative API to ensure out-of-order events never corrupt subscription state.
5. Account Binding and Cross-Platform Restore: Preventing Entitlement Hijacking
A notorious challenge in mobile store certification is Apple App Store Review Guideline 3.1.1, which mandates that any app offering auto-renewable subscriptions or non-consumables must present an explicit 'Restore Purchases' button. However, naive implementation of restore flows introduces a rampant vulnerability: entitlement hijacking across accounts.
Consider this scenario: User Alice purchases a subscription on her iPhone. Alice logs out of your app, and her coworker Bob logs in on the same physical device. Bob navigates to settings and taps 'Restore Purchases'. If your client blindly transmits the local StoreKit transaction to your backend without conflict checks, naive backend code re-binds Alice's originalTransactionId to Bob's user ID. Alice's subscription is stolen or Bob gets free access without paying.
To eliminate account hijacking while remaining 100% compliant with Apple and Google store guidelines, we implement an Immutable Identity Binding policy in production:
1. Permanent First-Touch Ownership: When an originalTransactionId (Apple) or purchaseToken (Google) is first verified, the backend permanently links it to the active authenticated user_id. Any subsequent verification attempt from a different user_id triggers a 409 Conflict: 'This store purchase is already linked to another account.'
2. Account Transfer Protocols: If a user legitimately switches from an old email to a new one, the transfer must be initiated via an explicit server-side authorization flow (e.g. email verification code to the original account) rather than an unauthenticated client restore button.
3. Cross-Platform Entitlement Portability: When a user logs in on an Android device after purchasing on an iPhone, the Flutter app does not query Google Play Billing. Instead, it calls GET /api/v1/users/me/entitlements. The backend checks the user_entitlements table in PostgreSQL, discovers an active subscription (even though its platform column is 'apple'), and unlocks premium features instantly. This delivers a seamless cross-platform experience without double billing.
Key Takeaway: Bind store transaction tokens immutably to user IDs upon initial verification. This fully complies with Apple Guideline 3.1.1 restore requirements while eliminating cross-account entitlement piracy across iOS and Android.
Ready to build your next production application?
Share your product scope, platform requirements, and target timeline. I'll provide a concrete architecture and deployment blueprint.