Supported on: iOS SDK
1.0.70+ · Android SDK 1.0.42+ · React Native SDK 1.0.7+ (New Architecture only)Configuration
Set the billing provider when configuring the SDK:AppDNABillingProvider Options
Omitted =
'storeKit2' (AppDNA’s own billing); 'none' disables billing. With 'none', purchase and restore fail with providerNotAvailable and the message “No billing provider configured”. iOS: store purchases belong to the Apple ID, so signing out (AppDNA.reset()) does not hide them — while no user is identified, entitlements include every purchase on the Apple ID. Once a user is identified, entitlements and restore include only that user’s purchases (tagged with the user’s appAccountToken), plus untagged older purchases only for the first user ever identified on the device.Who owns a purchase
Exactly one component owns a store transaction. AppDNA finishes (iOS) or acknowledges / consumes (Android) a transaction only when you asked it to own billing ('storeKit2', or the option omitted). With any other provider the SDK reads store state and never writes it.
Choose
'revenueCat' when RevenueCat is your purchase SDK — AppDNA then never finishes or acknowledges a transaction, SDK paywall taps call onPaywallPurchaseFailed(errorType: 'providerNotAvailable'), and purchases reach AppDNA through the RevenueCat webhook (RevenueCat integration). Do not track purchase or subscription events yourself with AppDNA.track — use custom event names for UI analytics; the webhook is the purchase source.
Recipe — an AppDNA paywall with RevenueCat buying. Catch the paywall failure and start the purchase with RevenueCat yourself, then dismiss the paywall:
Purchases.logIn(userId) and AppDNA.identify(userId) with the same id, so webhook purchases join the user.
- Entitlements under RevenueCat / Adapty are read from the store, not from the provider, and can differ from the provider’s own mapping (e.g. promotional grants). Use the provider’s own entitlement API (RevenueCat
CustomerInfo, Adapty’s profile) as the source of truth. - Adapty: connect the Adapty webhook to add server-side renewal and refund data once its mapping is enabled; the SDK keeps sending device renewal / cancel events.
- If your app also calls StoreKit / Play Billing directly under a non-owning provider, AppDNA leaves those transactions unfinished / unacknowledged — finishing them is your provider’s job (Play refunds an unacknowledged purchase after 3 days).
- Older Android SDKs: the SDK now sends
billing_owneron verify / restore; an app with a connected RevenueCat or Adapty integration that still runs an older Android SDK on'storeKit2'gets its verifications refused (409provider_owned) until it updates.
Google Play credentials (Android, AppDNA-owned billing)
WithbillingProvider: 'storeKit2', Android purchases need Google Play credentials in AppDNA (otherwise verification fails and Play refunds the purchase after 3 days); apps whose billingProvider is 'revenueCat' or Adapty do not add them. The setup — service-account JSON, package name, Real-time developer notifications and “Push service account” — is on the Android billing page. Mark consumable products as Consumable in Monetization → Products: Android consumes them immediately so they can be bought again.
Billing Module
Access the billing module throughAppDNA.billing, which returns an AppDNABilling instance:
Get Products
Fetch product information for one or more product identifiers:ProductInfo[].
ProductInfo
Platform-specific keys are omitted, never faked: an absent key means the platform has no concept of it.There is no numeric
price field. iOS’s native price is a Decimal, which cannot cross the bridge without loss, so both platforms send integer priceMicros. Render displayPrice, or divide priceMicros by 1,000,000 when you need the number.Purchase a Product
Initiate a purchase for a product:code is identical on iOS and Android:
Android only:
purchase() called with no foreground Activity rejects with NO_ACTIVITY, which is not one of these codes.
Returns a TransactionInfo.
TransactionInfo
The entitlement the purchase granted arrives on
getEntitlements() / onEntitlementsChanged, which is where entitlement state has always lived.
Transaction Object Keys
Thetransaction payload delivered to onPurchaseCompleted (and AppDNAPaywallDelegate.onPaywallPurchaseCompleted) is the same four keys, as a Record<string, unknown>:
The
offerToken argument 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. On iOS, an introductory offer set up in App Store Connect is applied by StoreKit itself; Apple promotional offers (which need a server signature) are not supported by the SDK, and AppDNA does not sign them.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):string[] — the restored product IDs, not entitlements.
Restore can fail
restorePurchases() rejects on failure; the rejection code is a billingErrorType value, and entitlements are left unchanged (no onRestoreCompleted([])):
The codes are exact on Android; on iOS a linked provider’s errors surface as
unknown, so treat any rejection as a failure.
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:boolean. It counts active one-time products (a lifetime unlock) as well as subscriptions, on both platforms.
On Android,
AppDNA.reset() and identify with a different user clear the cached entitlements (the first identify after an anonymous session keeps them): hasActiveSubscription() returns false and getEntitlements() is empty until the next refresh or server snapshot fills them again.Get Entitlements
Retrieve all current entitlements for the user:Entitlement[].
Entitlement
Real-Time Entitlement Changes
Subscription callback
Listen for entitlement changes in real time. The first subscriber starts the native entitlement observer, so nothing is missed:onEntitlementsChanged returns an unsubscribe function. Call it on component unmount to avoid leaks.
AppDNABillingDelegate
Implement theAppDNABillingDelegate interface for fine-grained billing lifecycle callbacks:
onPurchaseCompleted(productId, transaction)— fires after the store completes a purchase, including purchases reported later (app start,identify, restore rescue, an Android lateITEM_ALREADY_OWNEDfor a purchase call that has already ended, iOS interrupted / Ask-to-Buy); delivery starts when a JSonPurchaseCompletedis registered; grant idempotently bytransaction.transactionId. Thetransactionmap shape is documented above.onPurchaseFailed(productId, error)— fires on store-side failure (user cancel, payment declined, network).erroris the platform’s error message as a string, delivered asunknown— it is not a JSError, and it has no.messageproperty. Narrow it (String(error)) before displaying. For a machine-readable reason code, useAppDNAPaywallDelegate.onPaywallPurchaseFailed, which also carrieserrorType.onEntitlementsChanged(entitlements)— fires whenever the active entitlement set changes: on Android on a purchase, restore, renewal, expiry or refund, and onAppDNA.reset()(an empty list, when it was not already empty); on iOS whenever a product appears or disappears or itsisActive/expiresAtchanges — a purchase, restore,identify, sign-out (AppDNA.reset(): the device’s StoreKit entitlements, which belong to the Apple ID, without the signed-out user’s server-only ones — not an empty list. This holds under every provider of the published package — StoreKit 2, and RevenueCat or Adapty, whose SDKs the published package does not link, so the SDK reads StoreKit itself. Only a source build of the iOS SDK with RevenueCat or Adapty linked in skips this check, see iOS billing), renewal, expiry, refund or revocation (checked onTransaction.updates, on each return to the foreground and at the earliest known expiry; an unchanged state, including the first check after a relaunch, does not fire). On iOS each map’sisActiveandexpiresAtare real values (StoreKit’s expiry, or the server’s for an entitlement bought on another platform). Delivers an array of entitlement maps in the Entitlement shape (identifier,productId,isActive,expiresAton both platforms, plusstore,status,isTrial,offerTypeon Android) — not product-ID strings. For typedEntitlementobjects, subscribe withAppDNA.billing.onEntitlementsChanged(...)instead, which parses them for you.onRestoreCompleted(restoredProductIds)— fires when a directAppDNA.billing.restorePurchases()call resolves. Empty array means no prior purchases on file.onBillingUnavailable()— Android only. Play Services is missing or broken and billing will never work on this device. Never fires on iOS. Hide your purchase UI.
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. Later-reported purchases reach only the billing delegate.
Late purchases and the delivery queue
A purchase can complete outside a livepurchase() call: on Android, a purchase whose verification failed earlier or that was made outside the app (a Play Store resubscribe, a promo code, a pending purchase completing while the app was closed) is reported at app start, at identify or during restore; on iOS, interrupted and Ask-to-Buy purchases complete later, a purchase made on another device of the same Apple ID may also be reported, and a late purchase made while another user was signed in is delivered when that user signs in. Such a report has no paywall (paywall_id is "").
- Every late
purchase_completedreachesonPurchaseCompleted— possibly later, at least once: the SDK queues it and delivers it only while a JSonPurchaseCompletedis registered withAppDNA.billing.setDelegate— at the moment you register it, and then whenever the SDK reports a purchase, afteridentifyand at the end ofconfigure; on Android also on each return to the foreground (app start included) and after a restore. A billing delegate withoutonPurchaseCompleteddoes not drain the queue (the purchase waits for one that has it); a queued purchase counts as delivered once the bridge hands it to your JSonPurchaseCompleted— a handler that throws, rejects or is slow is not retried, so persist the grant yourself. - Grant idempotently, keyed on
transactionId. JSonPurchaseCompletedcan fire at rescue time, and the same order can reach JS again later (e.g.purchase()resolving it on a re-tap of an owned item); a crash can re-deliver once. - Consumables are consumed immediately. A consumable whose verification failed during the purchase and that completes later is delivered through
onPurchaseCompleted— at once when a JSonPurchaseCompletedis registered, otherwise queued until one is (the later triggers above deliver it only while one is registered). The queue keeps 100 entries for 30 days; past either, grant manually from the dashboard / support. - Known:
TransactionInfocarries no quantity (a quantity > 1 under-grants); after a reinstall an earlier acknowledged purchase is not reported again.
Example Implementation
Full Example
Auto-Tracked Events
The SDK automatically tracks the following billing events. You don’t need to callAppDNA.track(...) for any of them.
Android-only events (fired by Play Billing’s subscription lifecycle):
Under
billingProvider: 'revenueCat' the SDK does not emit subscription_renewed / subscription_canceled / subscription_renewal_failed (the RevenueCat webhook is the single source); under 'none' there is no billing and none of these occur. A late purchase_completed (reported at app start, identify or restore) carries an empty paywall_id (""); re-tapping an owned item emits purchase_restored with reason: "item_already_owned" instead of a second conversion.
These events flow into the AppDNA Console exactly like any custom event you track yourself, and feed retention / monetization dashboards out of the box.
Revenue the SDK cannot see
Supported on: React Native SDK
1.0.20+
