Skip to main content
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

ValueDescription
AppDNABillingProvider.storeKit2Native StoreKit 2 (iOS) / Play Billing (Android) — default
AppDNABillingProvider.revenueCatRevenueCat integration
AppDNABillingProvider.adaptyAdapty integration (pass API key via adaptyApiKey: option)
AppDNABillingProvider.noneDisable 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

PropertyTypeDescription
idStringProduct identifier
nameStringLocalized product name
descriptionStringLocalized product description
displayPriceStringFormatted price string (e.g., “$9.99/mo”)
pricedoubleNumeric price value
currencyString?ISO-4217 currency code (e.g., USD)
offerTokenString?Platform-specific offer token (Android)
subscriptionSubscriptionInfo?Subscription details (period, free trial, intro offer), if the product is a subscription

SubscriptionInfo

PropertyTypeDescription
periodStringISO-8601 period (e.g., P1M, P1Y)
periodDescriptionStringLocalized period label (e.g., “Monthly”, “Annual”)
freeTrialPeriodString?ISO-8601 trial period, if a free trial is configured
introductoryOfferIntroductoryOffer?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

PropertyTypeDescription
statusStringOne of: purchased, cancelled, pending, unknown
entitlementEntitlement?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:
KeyTypeDescription
transactionIdStringApp Store / Play Store transaction identifier
productIdStringPurchased product identifier
purchaseDateStringISO-8601 date of the purchase
originalTransactionIdString?Original transaction ID for renewals; equals transactionId for first purchase
environmentStringProduction or Sandbox
storeStringapp_store or play_store
currencyString?ISO-4217 currency code (e.g., USD) when known
pricedouble?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

PropertyTypeDescription
productIdStringThe product identifier
storeStringThe store that granted the entitlement
statusStringEntitlement status (e.g., “active”, “expired”)
expiresAtString?ISO 8601 expiration date, if applicable
isTrialboolWhether the entitlement is from a free trial
offerTypeString?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.
EventTrigger
purchase_startedCustomer taps the purchase CTA. Properties include productId, paywallId (when triggered from a paywall).
purchase_completedPurchase succeeds. Properties include productId, transactionId, price, currency.
purchase_failedPurchase fails. Properties include productId, error.
purchase_canceledCustomer dismisses the platform purchase sheet without completing.
purchase_pendingA purchase is waiting for approval (e.g., Ask to Buy, deferred payment).
purchase_restoredA previously purchased product is restored.
Android-only events (fired by Play Billing’s subscription lifecycle):
EventTrigger
billing_purchase_requestedA purchase call has been queued and is about to launch the Play Billing flow.
subscription_renewedAn auto-renewing subscription successfully renews.
subscription_canceledA subscription is cancelled (access may continue until period end).
subscription_renewal_failedA 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.