Supported on: Android SDK
1.0.33+Configuration
The Android SDK defaults to Google Play Billing, but the billing backend is selectable via thebillingProvider configuration option (default BillingProvider.StoreKit2, which maps to Google Play Billing on Android). You enable billing by calling AppDNA.configure(...) from your Application.onCreate() and ensuring the Play Billing dependency is on your classpath (the SDK pulls it in transitively).
BillingProvider is a sealed class with these cases:
BillingProvider.StoreKit2— the default (omitted =StoreKit2): AppDNA’s own billing against Google Play.BillingProvider.RevenueCat— carries no key; RevenueCat is your purchase SDK and owns every purchase.BillingProvider.Adapty(apiKey)— Adapty is your purchase SDK and owns every purchase. When the Adapty SDK is on the classpath, AppDNA activates it with this key —Adapty.activate(context, AdaptyConfig)(Adapty 2.11 and later, the only form in 4.x), orAdapty.activate(context, apiKey)on an older 2.x; do not activate Adapty yourself as well, or it is activated twice. An empty key is refused: Adapty is not activated and an error is logged.BillingProvider.None— billing disabled; purchases and restores fail withproviderNotAvailable(“No billing provider configured”).
Who owns a purchase
Exactly one component finishes a store transaction. On Android the SDK acknowledges (or consumes) a Play purchase only whenbillingProvider is StoreKit2:
Choose
RevenueCat when RevenueCat is your purchase SDK. AppDNA then never finishes or acknowledges a purchase; a tap on a plan in an AppDNA paywall calls onPaywallPurchaseFailed(errorType = "providerNotAvailable", productId = …) (and emits purchase_failed with error_type: "providerNotAvailable") without opening the Play sheet; and purchases reach AppDNA through the RevenueCat webhook. Recipe: catch that callback, start the purchase with RevenueCat yourself (Purchases.sharedInstance.purchase(...)), then dismiss the paywall. Adapty behaves the same on Android: start the purchase with Adapty from the callback.
The SDK’s internal RevenueCat and Adapty bridge classes are not usable on Android — they are never used for purchases, and their forwarding methods are not public (the Adapty bridge only activates Adapty with your key). Under
RevenueCat / Adapty the SDK only reads your Play purchases locally (read-only entitlements) and never writes to the store. Use the provider’s own entitlement API (CustomerInfo for RevenueCat, Adapty’s profile access levels) as the source of truth; the SDK’s store-derived entitlements can differ (e.g. promotional grants). If the provider does not acknowledge a purchase within 3 days, Play refunds it — that is the provider’s contract.Module Access
Access the billing module through theAppDNA.billing property:
Get Products
Retrieve product details for one or more product IDs (suspend function — call from a coroutine, or use theFuture overload from Java):
AppDNA.billing.getProductsFuture(productIds): CompletableFuture<List<ProductInfo>>.
ProductInfo
Which offer a purchase uses. Pass an
offerToken and that offer is bought. Without one, a
subscription purchase uses the free-trial offer Play returned for this user, if any — the same
offer the paywall’s trial sentence is read from, so the trial the paywall states is the trial the
purchase sheet starts — and otherwise the base plan. Play applies no offer on its own: before
Android SDK 1.0.56 a purchase without a token always bought the base plan, so a paywall could show
“1 week free trial” while Play charged the full price from the first day. An introductory price
is never chosen automatically.
Purchase a Subscription
Initiate a purchase flow for a specific product.purchase is a suspend function that returns a TransactionInfo on success and requires the current Activity to host the Play Billing flow:
AppDNABillingDelegate (see below).
Promotional offers
Pass aPurchaseOptions to select a specific subscription offer token:
Server-side receipt verification is performed automatically by the SDK. You do not need to send purchase tokens to your own server for validation. With
billingProvider: StoreKit2, Android purchases need Google Play credentials in AppDNA (else Play refunds them after 3 days) — see Google Play credentials and notifications. Apps whose billingProvider is RevenueCat or Adapty do not add them.PurchaseOptions.appAccountToken is supported: the purchase is verified and granted to the signed-in caller unless another user already owns it.
Consumables
Mark consumable products as Consumable in the console (Monetization → Products). The SDK consumes a consumable immediately after it is verified and reported, so it can be bought again at once.purchase_completed carries is_consumable.
A consumable whose verification failed during the purchase and that is completed later (app start, identify, restore) is delivered through onPurchaseCompleted — at once when a delivering billing delegate is set (setDelegate(delegate), or setDelegate(delegate, deliversPurchases = true)), otherwise queued and delivered when one is set or after identify. Grant idempotently by transactionId (a crash can re-deliver once). The queue keeps 100 entries for 30 days; past either, a manual grant from the dashboard / support is needed. Known: TransactionInfo has no quantity (a quantity above 1 under-grants), and after a reinstall an earlier acknowledged purchase is not reported again.
Purchase errors
A failedAppDNA.billing.purchase throws one of the exceptions below (the Java purchaseFuture completes exceptionally with it). Call billingErrorType(e) to map any of them to one of userCancelled, productNotFound, verificationFailed, networkError, serverError, pending, providerNotAvailable, unknown:
providerNotAvailable—billingProviderisRevenueCat,AdaptyorNone(BillingError.ProviderNotAvailable; underNonethe message is “No billing provider configured”). Start the purchase with your provider instead.verificationFailed— AppDNA’s server could not verify the purchase;purchase_failed.reasoncarries the server’s code (for examplestore_credentials_missing) when present.- A purchase started while the Play connection is failing fails within 30 s with
serverError. purchase/restorePurchasescalled beforeconfigurehas set billing up, or afterAppDNA.shutdown(), fail withunknownand the message “AppDNA SDK not configured yet — call configure() first”. A paywall purchase tap in the same window — before billing is ready (including betweenconfigure()and the end of billing setup) or aftershutdown()— fails the same way: onepurchase_failedwithreason: "not_configured"anderror_type: "unknown", andonPaywallPurchaseFailedwitherrorType == "unknown"(notproviderNotAvailable). A paywall restore tap in that window fails with the sameunknownerror (onPaywallRestoreFailed, onepurchase_restore_failedwithreason: "not_configured"), withoutonPaywallRestoreStarted. See A tap before billing is ready.item_already_owned— a re-tap of an owned item that could not be completed; see Re-buying an owned item.
Restore Purchases
Restore previously purchased subscriptions and one-time products (e.g., after a device change or reinstall). Suspend function — returnsList<String> of restored product IDs:
Restoring purchases queries Google Play for all active subscriptions and updates entitlements accordingly. This is useful after a reinstall or device change.
Restore can fail
restorePurchases() throws on failure (the Java restorePurchasesAsync() completes exceptionally). Entitlements are left unchanged, onRestoreCompleted does not fire, and purchase_restore_failed is tracked once billing has initialised (a call before that throws the “AppDNA SDK not configured yet” unknown error untracked; a CancellationException from your own cancelled coroutine is rethrown untracked). The error is a billingErrorType value:
reset() and identify with a user different from the one already identified clear the cached entitlements (the first identify after an anonymous session keeps them); they come back from the next refresh or entitlement update.
Entitlements
Retrieve the current user’s entitlements (suspend function):Entitlement
Check Active Subscription
Quickly check if the user has any active entitlement.hasActiveSubscription() counts active one-time products (a lifetime unlock) as well as subscriptions, on both platforms:
Listen for Entitlement Changes
Register a listener to be notified when entitlements change (e.g., subscription renewal, expiration, or new purchase). When it fires. Once per real change, on the main thread — both the lambda below andAppDNABillingDelegate.onEntitlementsChanged. A change is a difference in the product set, a
product’s status, or its expiresAt: a purchase, a restore, a renewal (the expiry moves), a lapse
into grace_period or billing_retry and a recovery from one all fire. The SDK updates
entitlements from the real-time Firestore document of the identified user (started by identify,
and only when the SDK owns transactions — BillingProvider.StoreKit2), after each purchase, after a
restore or Play entitlement query, and on refreshEntitlementCache(). An update that finds the same
state as the last one does not fire, including the first document snapshot after a relaunch (the
last state is persisted alongside the cache, so a snapshot that merely confirms what is on disk is
silent).
reset(), and identify with a user different from the one already identified, clear the cache and
fire once with an empty list — when it was not already empty. (iOS differs: there the anonymous
state is the device’s StoreKit entitlements, which belong to the Apple ID, so a sign-out there is
often not a change at all.)
A listener registered before
configure() completes is queued and attached when billing comes
up, so the ordinary configure()-then-register sequence does not drop it.AppDNABillingDelegate
All 5 methods on this delegate fire from the active billing bridge (Google PlayNativeBillingManager). Register via AppDNA.billingDelegate = yourHandler.
onPurchaseCompletedandonPurchaseFailedfire for every purchase regardless of entry point — paywall-driven OR directAppDNA.billing.purchase(...)calls.onPurchaseCompletedmay also fire later, at least once — at app start, onsetDelegate, or afteridentify— for a purchase whose verification failed earlier or that was made outside the app (a Play Store resubscribe, a promo code, a pending purchase that completed while the app was closed). Such a report has no paywall (paywall_idis""). Grant idempotently bytransaction.transactionId.onRestoreCompletedfires after every successful restore.onEntitlementsChangedfires exactly when the lambdas registered withAppDNA.billing.onEntitlementsChanged { }above fire, with the same list, on the main thread. CallgetEntitlements()to re-read the set at any time.onBillingUnavailablefires once when the billing connection gives up after exhausting all reconnect attempts (Play Services missing or permanently broken). Hide paywalls / disable purchase UI when this fires.
AppDNAPaywallDelegate: when a purchase comes from a paywall, both AppDNAPaywallDelegate.onPaywallPurchaseCompleted AND AppDNABillingDelegate.onPurchaseCompleted fire. Each represents a different layer (paywall lifecycle vs platform billing). Pick one as your source of truth for your analytics — they will not double-count if you only listen on one.
For more granular control, implement the AppDNABillingDelegate interface:
Late purchases and the delivery queue
Purchase reports that did not resolve a livepurchase() call (rescued purchases, pending purchases approved later, a cancelled purchase() call) are kept in a durable queue and delivered through onPurchaseCompleted when a billing delegate is set, after identify, or once billing has initialised during configure’s background bootstrap. Any billing delegate drains the queue, so implement onPurchaseCompleted — or register a delegate that should not receive queued purchases with:
setDelegate(delegate) is kept and delivers queued purchases. A callback that returns (even after its timeout) counts as delivered.
Example Implementation
Data Types
TransactionInfo
PurchaseResult
ThePurchaseResult sealed class represents all possible outcomes of a purchase:
Auto-Tracked Events
The billing module automatically tracks the following events:These events include the product identifier and relevant metadata. They are tracked regardless of whether you implement
AppDNABillingDelegate.Which provider emits the lifecycle events.
subscription_renewed, subscription_canceled and
subscription_renewal_failed are emitted device-side by the SDK, and whether it does so depends
on the configured provider — identically on both platforms:🔴 Adapty is the one to watch. Unlike RevenueCat, the SDK does not step back under Adapty, so
if you also forward Adapty’s own subscription webhooks to AppDNA you will count each renewal twice.
Send one or the other, not both.
Under
billingProvider: StoreKit2 the SDK acknowledges purchases (and consumes consumables) after server verification, as Google Play requires within 3 days; an unacknowledged purchase is refunded by Play. Under RevenueCat / Adapty / None the SDK never acknowledges. Re-tapping an owned non-consumable or subscription emits one purchase_restored with reason: "item_already_owned" (no price), not a second conversion.Re-buying an owned item
When Play answers a purchase with “item already owned”, the SDK looks at the owned purchase for that product (it never runs a full restore):
Do not grant from either failure. A purchase whose verification failed is retried at the next return to the foreground (app start included),
identify, restore or refreshEntitlementCache() (see Consumables for how a late completion is delivered). A consumable that was consumed silently is bought again once while the purchase screen is still open; if the sheet cannot be relaunched, the purchase also ends Failed("item_already_owned") with the same purchase_failed.
Google Play credentials and notifications
Needed when AppDNA handles purchases (billingProvider: StoreKit2); apps on RevenueCat or Adapty do not add Play credentials.
- In the console open Settings → Store Credentials, and in the Google Play Console card paste your Google Cloud service account (with access to your app in the Play Console) into Service Account JSON and your app’s Package Name.
- Click Save Credentials; the page then shows the Google Play Webhook URL — copy it.
- In Google Cloud → Pub/Sub, create a topic for Play Real-time developer notifications, select it in the Play Console (Monetization setup), and add a push subscription on that topic whose endpoint is the AppDNA webhook URL, with authentication enabled.
is_trial: true, price 0) from the subscription’s current offer phase reported by Play (offerPhase). A fallback that reads the offer’s first phase through Play’s monetization API is not enabled; it would need the “View app information” permission for the service account and would degrade gracefully on a permission error. Known: a purchase reported after its trial ended is reported with is_trial: false at full price.
Full Example
Next Steps
- Configure Onboarding flows to guide new users
- Present Paywalls with integrated purchase handling
- Learn about Offline Support for billing resilience
Revenue the SDK cannot see
Supported on: Android SDK
1.0.55+
