Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · Flutter SDK 1.0.3+
The AppDNA Flutter SDK provides a billing module for managing in-app purchases, subscriptions, entitlement verification, and purchase restoration. All billing logic is handled by the native iOS and Android SDKs through the com.appdna.sdk/billing MethodChannel and the com.appdna.sdk/entitlements EventChannel.
Configuration
Set the billing provider when configuring the SDK:
await AppDNA.configure(
apiKey: 'adn_live_xxx',
env: AppDNAEnvironment.production,
options: AppDNAOptions(billingProvider: AppDNABillingProvider.storeKit2),
);
AppDNABillingProvider Options
| Value | Description |
|---|
AppDNABillingProvider.storeKit2 | Native StoreKit 2 (iOS) / Play Billing (Android) — default |
AppDNABillingProvider.revenueCat | RevenueCat integration |
AppDNABillingProvider.adapty | Adapty integration (pass API key via adaptyApiKey: option) |
AppDNABillingProvider.none | Disable the billing module entirely |
You must set the billingProvider in AppDNAOptions before accessing AppDNA.billing. If set to AppDNABillingProvider.none, billing module methods will throw errors.
Billing Module
Access the billing module through AppDNA.billing, which returns an AppDNABilling instance:
final billing = AppDNA.billing;
Get Products
Fetch product information for one or more product identifiers:
final products = await AppDNA.billing.getProducts(["premium_monthly"]);
for (final product in products) {
print("${product.name}: ${product.displayPrice}");
}
Returns List<ProductInfo>.
ProductInfo
| Property | Type | Description |
|---|
id | String | Product identifier |
name | String | Localized product name |
description | String | Localized product description |
displayPrice | String | Formatted price string (e.g., “$9.99/mo”) |
price | double | Numeric price value |
currency | String? | ISO-4217 currency code (e.g., USD) |
offerToken | String? | Platform-specific offer token (Android) |
subscription | SubscriptionInfo? | Subscription details (period, free trial, intro offer), if the product is a subscription |
SubscriptionInfo
| Property | Type | Description |
|---|
period | String | ISO-8601 period (e.g., P1M, P1Y) |
periodDescription | String | Localized period label (e.g., “Monthly”, “Annual”) |
freeTrialPeriod | String? | ISO-8601 trial period, if a free trial is configured |
introductoryOffer | IntroductoryOffer? | Introductory price, if configured (price, period, type) |
Purchase a Product
Initiate a purchase for a product:
final result = await AppDNA.billing.purchase(
"premium_monthly",
offerToken: "offer-xxx",
);
switch (result.status) {
case 'purchased':
print("Purchase successful!");
if (result.entitlement != null) {
print("Entitlement: ${result.entitlement!.productId}");
}
break;
case 'cancelled':
print("User cancelled");
break;
case 'pending':
print("Purchase pending approval");
break;
default:
print("Unknown status: ${result.status}");
}
Returns a PurchaseResult.
PurchaseResult
| Property | Type | Description |
|---|
status | String | One of: purchased, cancelled, pending, unknown |
entitlement | Entitlement? | The entitlement granted by the purchase, if any |
Transaction Map Keys
The transaction payload delivered to onPurchaseCompleted (and AppDNAPaywallDelegate.onPaywallPurchaseCompleted) is a Map<String, dynamic> with the following store-verified keys:
| Key | Type | Description |
|---|
transactionId | String | App Store / Play Store transaction identifier |
productId | String | Purchased product identifier |
purchaseDate | String | ISO-8601 date of the purchase |
originalTransactionId | String? | Original transaction ID for renewals; equals transactionId for first purchase |
environment | String | Production or Sandbox |
store | String | app_store or play_store |
currency | String? | ISO-4217 currency code (e.g., USD) when known |
price | double? | Numeric price paid (in currency units) |
// Inside onPurchaseCompleted:
final txnId = transaction['transactionId'] as String;
final originalTxnId = transaction['originalTransactionId'] as String?;
final isRenewal = originalTxnId != null && originalTxnId != txnId;
The offerToken parameter on purchase(...) is optional. On Android, pass the offer token from ProductInfo.offerToken to select a specific subscription offer. On iOS, this parameter is ignored. For Apple promotional offers (iOS), the SDK auto-applies any offer associated with the product in App Store Connect.
Server-side receipt verification is performed automatically by the SDK. You do not need to send receipts to your own server for validation.
Restore Purchases
Restore previously purchased products (e.g., after reinstalling the app or switching devices):
final entitlements = await AppDNA.billing.restorePurchases();
for (final ent in entitlements) {
print("Restored: ${ent.productId} (${ent.status})");
}
Returns List<Entitlement>.
Apple requires that all apps with in-app purchases include a restore mechanism. Call restorePurchases() when the user taps a “Restore Purchases” button.
Check Active Subscription
Check whether the user has an active subscription:
final active = await AppDNA.billing.hasActiveSubscription();
if (active) {
// Unlock premium features
}
Returns bool.
Get Entitlements
Retrieve all current entitlements for the user:
final ents = await AppDNA.billing.getEntitlements();
for (final ent in ents) {
print("${ent.productId}: ${ent.status}, trial: ${ent.isTrial}");
}
Returns List<Entitlement>.
Entitlement
| Property | Type | Description |
|---|
productId | String | The product identifier |
store | String | The store that granted the entitlement |
status | String | Entitlement status (e.g., “active”, “expired”) |
expiresAt | String? | ISO 8601 expiration date, if applicable |
isTrial | bool | Whether the entitlement is from a free trial |
offerType | String? | The type of offer applied (e.g., “introductory”) |
Real-Time Entitlement Changes
Stream
Listen for entitlement changes in real time via the com.appdna.sdk/entitlements EventChannel:
AppDNA.billing.onEntitlementsChanged.listen((List<Entitlement> entitlements) {
print("Entitlements changed: ${entitlements.length} active");
// Update UI to reflect new entitlement state
});
Returns Stream<List<Entitlement>>.
Callback
Alternatively, register a callback for entitlement changes:
AppDNA.billing.onEntitlementsChangedCallback((List<Entitlement> entitlements) {
print("Entitlements updated");
});
AppDNABillingDelegate
Implement the AppDNABillingDelegate abstract class for fine-grained billing lifecycle callbacks:
abstract class AppDNABillingDelegate {
void onPurchaseCompleted(String productId, Map<String, dynamic> transaction);
void onPurchaseFailed(String productId, Object error);
void onEntitlementsChanged(List<String> entitlements);
void onRestoreCompleted(List<String> restoredProductIds);
}
The 4 methods semantics:
onPurchaseCompleted(productId, transaction) — fires after the store completes a purchase (paywall, direct AppDNA.billing.purchase(...), or transaction queue retry). The transaction map shape is documented above.
onPurchaseFailed(productId, error) — fires on store-side failure (user cancel, payment declined, network). The error is a typed Object (usually Map<String, dynamic> from native with message + type keys, or a raw String).
onEntitlementsChanged(entitlements) — fires whenever the active entitlement set changes (purchase, restore, renewal, expiry, refund). Delivers a list of product IDs (the SKUs your store is configured with). For full Entitlement objects with status/expiry/trial, call AppDNA.billing.getEntitlements() from inside the callback, or subscribe to AppDNA.billing.onEntitlementsChanged stream instead.
onRestoreCompleted(restoredProductIds) — fires when a direct AppDNA.billing.restorePurchases() call resolves. Empty list means no prior purchases on file.
Overlap with AppDNAPaywallDelegate. When a purchase originates from a paywall, the paywall delegate fires onPaywallPurchaseStarted / onPaywallPurchaseCompleted / onPaywallPurchaseFailed. The billing delegate ALSO fires onPurchaseCompleted / onPurchaseFailed for that same purchase — they’re independent observers. Register both if you want paywall-specific UI (banners, confetti) AND a global purchase log; register only the billing delegate if you only care about the eventual state.
Example Implementation
class MyBillingHandler implements AppDNABillingDelegate {
@override
void onPurchaseCompleted(String productId, Map<String, dynamic> transaction) {
print("Purchased: $productId");
// Unlock premium features
}
@override
void onPurchaseFailed(String productId, Object error) {
// `error` is a typed Object -- usually a Map<String, dynamic> from native
// ({ "message": "...", "type": "..." }) or a raw String. Stringify when displaying.
print("Purchase failed: $productId -- $error");
// Show error message to user
}
@override
void onEntitlementsChanged(List<String> entitlements) {
print("Entitlement product IDs: ${entitlements.join(', ')}");
// For full entitlement objects (status, expiry, trial), call:
// final ents = await AppDNA.billing.getEntitlements();
}
@override
void onRestoreCompleted(List<String> restoredProductIds) {
print("Restored ${restoredProductIds.length} products");
}
}
Full Example
import 'package:appdna_sdk/appdna_sdk.dart';
import 'package:flutter/material.dart';
class SubscriptionPage extends StatefulWidget {
const SubscriptionPage({super.key});
@override
State<SubscriptionPage> createState() => _SubscriptionPageState();
}
class _SubscriptionPageState extends State<SubscriptionPage> {
List<ProductInfo> _products = [];
bool _isSubscribed = false;
@override
void initState() {
super.initState();
_loadProducts();
_checkSubscription();
// Listen for entitlement changes
AppDNA.billing.onEntitlementsChanged.listen((entitlements) {
setState(() {
_isSubscribed = entitlements.any((e) => e.status == "active");
});
});
}
Future<void> _loadProducts() async {
final products = await AppDNA.billing.getProducts([
"premium_monthly",
"premium_yearly",
]);
setState(() => _products = products);
}
Future<void> _checkSubscription() async {
final active = await AppDNA.billing.hasActiveSubscription();
setState(() => _isSubscribed = active);
}
Future<void> _purchase(ProductInfo product) async {
final result = await AppDNA.billing.purchase(
product.id,
offerToken: product.offerToken,
);
if (result.status == 'purchased') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Purchase successful!")),
);
}
}
Future<void> _restore() async {
final restored = await AppDNA.billing.restorePurchases();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Restored ${restored.length} purchases")),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Subscription"),
actions: [
TextButton(
onPressed: _restore,
child: const Text("Restore"),
),
],
),
body: ListView(
children: _products.map((product) {
return ListTile(
title: Text(product.name),
subtitle: Text(product.description),
trailing: Text(product.displayPrice),
onTap: () => _purchase(product),
);
}).toList(),
),
);
}
}
Always test purchases using sandbox/test accounts. On iOS, use StoreKit Testing in Xcode or a Sandbox Apple ID. On Android, use license testing accounts configured in the Google Play Console.
Auto-Tracked Events
The SDK automatically tracks the following billing events. You don’t need to call AppDNA.track(...) for any of them.
| Event | Trigger |
|---|
purchase_started | Customer taps the purchase CTA. Properties include productId, paywallId (when triggered from a paywall). |
purchase_completed | Purchase succeeds. Properties include productId, transactionId, price, currency. |
purchase_failed | Purchase fails. Properties include productId, error. |
purchase_canceled | Customer dismisses the platform purchase sheet without completing. |
purchase_pending | A purchase is waiting for approval (e.g., Ask to Buy, deferred payment). |
purchase_restored | A previously purchased product is restored. |
Android-only events (fired by Play Billing’s subscription lifecycle):
| Event | Trigger |
|---|
billing_purchase_requested | A purchase call has been queued and is about to launch the Play Billing flow. |
subscription_renewed | An auto-renewing subscription successfully renews. |
subscription_canceled | A subscription is cancelled (access may continue until period end). |
subscription_renewal_failed | A renewal attempt fails (payment issue). |
These events flow into the AppDNA Console exactly like any custom event you track yourself, and feed retention / monetization dashboards out of the box.