Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · React Native SDK 1.0.4+
The AppDNA React Native 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 native AppDNA module’s billing bridge and the AppDNA_entitlements_changed event emitted on NativeEventEmitter.
Configuration
Set the billing provider when configuring the SDK:
import { AppDNA } from '@appdna/react-native-sdk';
await AppDNA.configure({
apiKey: 'adn_live_xxx',
env: 'production',
options: { billingProvider: 'storeKit2' },
});
AppDNABillingProvider Options
| Value | Description |
|---|
'storeKit2' | Native StoreKit 2 (iOS) / Play Billing (Android) — default |
'revenueCat' | RevenueCat integration |
'adapty' | Adapty integration (pass API key via adaptyApiKey option) |
'none' | Disable the billing module entirely |
You must set the billingProvider in AppDNAOptions before accessing AppDNA.billing. If set to 'none', billing module methods will throw errors.
Billing Module
Access the billing module through AppDNA.billing, which returns an AppDNABilling instance:
const billing = AppDNA.billing;
Get Products
Fetch product information for one or more product identifiers:
const products = await AppDNA.billing.getProducts(['premium_monthly']);
for (const product of products) {
console.log(`${product.name}: ${product.displayPrice}`);
}
Returns 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 | number | Numeric price value | |
currency | `string | null` | ISO-4217 currency code (e.g., USD) |
offerToken | `string | null` | Platform-specific offer token (Android) |
subscription | `SubscriptionInfo | null` | 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 | null` | ISO-8601 trial period, if a free trial is configured |
introductoryOffer | `IntroductoryOffer | null` | Introductory price, if configured (price, period, type) |
Purchase a Product
Initiate a purchase for a product:
const result = await AppDNA.billing.purchase('premium_monthly', {
offerToken: 'offer-xxx',
});
switch (result.status) {
case 'purchased':
console.log('Purchase successful!');
if (result.entitlement) {
console.log(`Entitlement: ${result.entitlement.productId}`);
}
break;
case 'cancelled':
console.log('User cancelled');
break;
case 'pending':
console.log('Purchase pending approval');
break;
default:
console.log(`Unknown status: ${result.status}`);
}
Returns a PurchaseResult.
PurchaseResult
| Property | Type | Description | |
|---|
status | string | One of: purchased, cancelled, pending, unknown | |
entitlement | `Entitlement | null` | The entitlement granted by the purchase, if any |
Transaction Object Keys
The transaction payload delivered to onPurchaseCompleted (and AppDNAPaywallDelegate.onPaywallPurchaseCompleted) is a Record<string, unknown> 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 | null` | Original transaction ID for renewals; equals transactionId for first purchase |
environment | string | Production or Sandbox | |
store | string | app_store or play_store | |
currency | `string | null` | ISO-4217 currency code (e.g., USD) when known |
price | `number | null` | Numeric price paid (in currency units) |
// Inside onPurchaseCompleted:
const txnId = transaction.transactionId as string;
const originalTxnId = transaction.originalTransactionId as string | null;
const isRenewal = originalTxnId != null && originalTxnId !== txnId;
The offerToken option 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):
const entitlements = await AppDNA.billing.restorePurchases();
for (const ent of entitlements) {
console.log(`Restored: ${ent.productId} (${ent.status})`);
}
Returns 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:
const active = await AppDNA.billing.hasActiveSubscription();
if (active) {
// Unlock premium features
}
Returns boolean.
Get Entitlements
Retrieve all current entitlements for the user:
const ents = await AppDNA.billing.getEntitlements();
for (const ent of ents) {
console.log(`${ent.productId}: ${ent.status}, trial: ${ent.isTrial}`);
}
Returns 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 | null` | ISO 8601 expiration date, if applicable |
isTrial | boolean | Whether the entitlement is from a free trial | |
offerType | `string | null` | The type of offer applied (e.g., “introductory”) |
Real-Time Entitlement Changes
Subscription callback
Listen for entitlement changes in real time. The SDK bridges the native AppDNA_entitlements_changed event on NativeEventEmitter into a typed callback subscription:
const unsubscribe = AppDNA.billing.onEntitlementsChanged((entitlements) => {
console.log(`Entitlements changed: ${entitlements.length} active`);
// Update UI to reflect new entitlement state
});
// Later, when you no longer need updates:
unsubscribe();
onEntitlementsChanged returns an unsubscribe function. Call it on component unmount to avoid leaks.
Raw event channel (advanced)
If you prefer to subscribe to the underlying NativeEventEmitter directly:
import { NativeEventEmitter, NativeModules } from 'react-native';
const emitter = new NativeEventEmitter(NativeModules.AppDNA);
const sub = emitter.addListener('AppDNA_entitlements_changed', (payload: { entitlements: Entitlement[] }) => {
console.log('Entitlements updated:', payload.entitlements);
});
// Cleanup:
sub.remove();
AppDNABillingDelegate
Implement the AppDNABillingDelegate interface for fine-grained billing lifecycle callbacks:
interface AppDNABillingDelegate {
onPurchaseCompleted(productId: string, transaction: Record<string, unknown>): void;
onPurchaseFailed(productId: string, error: Error): void;
onEntitlementsChanged(entitlements: string[]): void;
onRestoreCompleted(restoredProductIds: string[]): void;
}
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 standard JS Error; the error.message typically contains a JSON-encoded { "message": "...", "type": "..." } blob from native or a plain string.
onEntitlementsChanged(entitlements) — fires whenever the active entitlement set changes (purchase, restore, renewal, expiry, refund). Delivers an array 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(...) instead.
onRestoreCompleted(restoredProductIds) — fires when a direct AppDNA.billing.restorePurchases() call resolves. Empty array 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
const billingHandler: AppDNABillingDelegate = {
onPurchaseCompleted(productId, transaction) {
console.log(`Purchased: ${productId}`);
// Unlock premium features
},
onPurchaseFailed(productId, error) {
// `error.message` is usually a JSON blob `{"message":"...","type":"..."}` from
// native or a plain string. Parse / stringify when displaying.
console.log(`Purchase failed: ${productId} -- ${error.message}`);
// Show error message to user
},
onEntitlementsChanged(entitlements) {
console.log(`Entitlement product IDs: ${entitlements.join(', ')}`);
// For full entitlement objects (status, expiry, trial), call:
// const ents = await AppDNA.billing.getEntitlements();
},
onRestoreCompleted(restoredProductIds) {
console.log(`Restored ${restoredProductIds.length} products`);
},
};
AppDNA.billing.setDelegate(billingHandler);
Full Example
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, Pressable, Button, Alert } from 'react-native';
import { AppDNA, ProductInfo } from '@appdna/react-native-sdk';
export function SubscriptionPage() {
const [products, setProducts] = useState<ProductInfo[]>([]);
const [isSubscribed, setIsSubscribed] = useState(false);
useEffect(() => {
loadProducts();
checkSubscription();
const unsubscribe = AppDNA.billing.onEntitlementsChanged((entitlements) => {
setIsSubscribed(entitlements.some((e) => e.status === 'active'));
});
return unsubscribe;
}, []);
async function loadProducts() {
const result = await AppDNA.billing.getProducts(['premium_monthly', 'premium_yearly']);
setProducts(result);
}
async function checkSubscription() {
const active = await AppDNA.billing.hasActiveSubscription();
setIsSubscribed(active);
}
async function purchase(product: ProductInfo) {
const result = await AppDNA.billing.purchase(product.id, {
offerToken: product.offerToken ?? undefined,
});
if (result.status === 'purchased') {
Alert.alert('Purchase successful!');
}
}
async function restore() {
const restored = await AppDNA.billing.restorePurchases();
Alert.alert(`Restored ${restored.length} purchases`);
}
return (
<View style={{ flex: 1 }}>
<View style={{ padding: 16, flexDirection: 'row', justifyContent: 'flex-end' }}>
<Button title="Restore" onPress={restore} />
</View>
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable
onPress={() => purchase(item)}
style={{ padding: 16, borderBottomWidth: 1, borderColor: '#eee' }}
>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
<Text>{item.description}</Text>
<Text>{item.displayPrice}</Text>
</Pressable>
)}
/>
</View>
);
}
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.