The React Native SDK requires the New Architecture.
Supported on: iOS SDK
1.0.70+ · Android SDK 1.0.42+ · React Native SDK 1.0.7+ (New Architecture only)What’s new in 1.0.20
React Native SDK 1.0.20 wraps iOS SDK 1.0.83 and Android SDK 1.0.55. Paying users the SDK cannot see (Who counts as a paying user)- New
AppDNA.reportPayingUser(productId?, priceCents?, currency?)— tell AppDNA that the current user is in a paid state for a subscription the SDK never sold: billing run by your own server, a web checkout, a seat sold by a salesperson. It emitspaying_user_reportedwithsource: "host". - Every argument is optional, and the price is analytics only: the paying-user meter counts distinct users, not money. Safe to call on every launch — repeating it within a month costs nothing and misses nothing.
- Call
identify()first. The SDK attaches the identity it already holds; there is deliberately no user-id argument, so an app can only ever report its own users. Beforeconfigure()the call resolves without doing anything. - Whether these reports count toward your bill is part of your agreement, and off until then — the events are ingested for analytics either way.
What’s new in 1.0.19
React Native SDK 1.0.19 wraps iOS SDK 1.0.82 and Android SDK 1.0.54. It needs the AppDNA server from the same release (push tracking and billing verification rely on it). Billing ownership- iOS:
onEntitlementsChangednow fires on a renewal, expiry, refund or revocation as well as on a purchase, restore oridentify, only on a real change, with realisActive/expiresAt; iOS purchases are verified by the AppDNA server in the background under'storeKit2';transaction.environmentreportsproduction/sandbox/xcodeon iOS. billingProviderdecides who owns a store transaction, on both platforms. Only'storeKit2'(the default) lets AppDNA buy, finish (iOS) or acknowledge (Android). Under'revenueCat', Adapty or'none'the SDK never finishes, acknowledges or consumes. RevenueCat and Adapty are not linked into AppDNA itself, so every published build refuses SDK purchases for them: a paywall plan tap callsonPaywallPurchaseFailedwitherrorType: 'providerNotAvailable'and the tappedproductId. Before, on iOS a'revenueCat'tap made a StoreKit purchase that AppDNA finished, an Adapty tap failed withunknown“Adapty SDK not linked”, and a'none'tap did nothing; on Android a'revenueCat'or Adapty tap bought through Play via AppDNA (then refused at verification, so Play refunded it after 3 days), and a'none'tap failed withunknown. Now directpurchase/restorePurchasesalso reject withproviderNotAvailableunder these providers. See Billing.- Under
'revenueCat'the SDK no longer emitssubscription_renewed/subscription_canceled/subscription_renewal_failed— the RevenueCat webhook is the single source. Adapty keeps them. - iOS: a subscription in its billing grace period, or a server entitlement in
grace_period/billing_retry, is reported active although itsexpiresAthas passed (it read inactive). A cross-platform entitlement stays listed after an offline relaunch. - iOS: on a slow network an entitlement server reply that arrives as the 2.5-second wait runs out is no longer reported twice, and while one reply is outstanding later checks for the same user wait on it instead of each sending another request.
- iOS, RevenueCat linked into AppDNA (source builds): a purchase the user cancels rejects with
userCancelledand is tracked aspurchase_canceled(it was RevenueCat’s raw error,unknown). - Push: a Dismiss button never opens the app (iOS). A text-reply button’s typed text reaches
onPushTappedon Android 7–11.diagnose()reportsvetoTimeoutas given (0.5, not1on Android or0on iOS). - Restore rejects with the
billingErrorTypecode on both platforms (wasRESTORE_ERROR):providerNotAvailablefor unlinked'revenueCat'(iOS: was a StoreKit restore), unlinked Adapty (iOS: was[]+onRestoreCompleted([])) and'none'; on Android'storeKit2'networkError/serverError(was[]). Entitlements are unchanged on failure. Under'none'a purchase now rejects withproviderNotAvailable(wasunknown), and a paywall purchase fails visibly (on iOS it was silent). - An Android verification failure now rejects with
verificationFailedinstead ofunknown;productNotFoundnow reaches you as the purchase error code. A purchase started while the Play connection is failing fails within 30 s withserverError. purchase/restorePurchasescalled beforeconfigurecompletes fail with “AppDNA SDK not configured yet — call configure() first” (codeunknown). A paywall purchase or restore tap before billing is ready, or aftershutdown()has torn it down, fails the same way, withoutonPaywallRestoreStarted(on AndroidonPaywallRestoreStartedused to fire first, then a “Billing bridge not configured” failure with no error type).- Android: a paywall restore tap under
'revenueCat', Adapty or'none'is now refused withproviderNotAvailable, withoutonPaywallRestoreStarted(iOS never fired it). Before, Started fired, then'revenueCat'/ Adapty ran a Play restore through AppDNA and'none'failed with “Billing bridge not configured” and no error type. Also,shutdown()during a paywall purchase or restore no longer reports it as failed. - A refused purchase or restore under
'revenueCat'/ Adapty carries one message on both platforms and on every path (direct call and paywall tap): “RevenueCat: purchases are made by RevenueCat in your app” (“Adapty: …” for Adapty). - The paywall’s purchase button no longer stays disabled with its spinner: it is enabled again after every failed, cancelled or pending purchase (both platforms), after a successful purchase when it has no
on_successaction or one this SDK version does not know (the paywall stays up for your app to close), and on Android whenshutdown()cancels the purchase. - Android
'storeKit2'purchases are now verified and acknowledged. The verify call was refused by the server, so Play refunded every purchase after 3 days; they are now verified and acknowledged (or consumed). - iOS event parity:
purchase_started/purchase_failedon direct purchases;error_typeon the paywallpurchase_restore_failed;purchase_restore_failed{error_type: providerNotAvailable}for a paywall restore without a billing bridge; a failed directrestorePurchases()trackspurchase_restore_failed(nopaywall_id). - The Android 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 until it updates.
- A failed native bootstrap (for example, the app starts offline) no longer lasts the whole session: the SDK becomes ready on cached config, reports the failure through
onInitDegraded(now on Android too) and retries the bootstrap when the network comes back, on foreground and after a backoff of up to 5 minutes (at most 10 retries). A 401 or 403 (the server refused the API key) ends the retries; a 429’sRetry-Afterholds the next one back. A retry that succeeds fetches remote config and starts the Firestore listeners;onReadydoes not fire again. shutdown()uploads the queued events once more on both platforms; on Android what it does not send goes to a background upload once that attempt has finished. Aftershutdown()and a newconfigure(), events the old session delivered are not sent again. After 5 failed upload cycles the pause also holds for the OS background upload on both platforms: backgrounding schedules none, and one that runs while paused uploads nothing.- A large queue of unsent events (up to 10,000) no longer delays
configure()aftershutdown()or slows every upload (both platforms): each upload used to read the whole queue; now it reads only the events it sends. - A 401 or 403 from the event upload (an invalid or revoked API key) pauses uploads at once on both platforms — the in-app queue and the OS background upload — until the app comes to the foreground again or you call
flush(); before, the queue dropped a batch on each of 5 failed cycles first. A background upload that gets any permanent rejection drops that batch and ends its run; the next run sends the rest (Android went on with up to 50 more batches). - iOS: the 10,000-event queue limit holds after every event queued (it was applied every 500), and the background upload sends up to 50 batches per run (it sent one), as on Android. The location traits from the IP address (
country,region,city,timezone) stay throughidentify(userId, traits), a change of user andreset(). onInitDegradedandgetLastInitError()report the sametype(BootstrapFailed,SubsystemFailed,FirebaseConfigMissing) in a minified Android release build as in a debug build (the class name could be obfuscated).- Android: do not roll back to 1.0.18 or earlier after shipping this version. Its native Android SDK writes event database schema version 4, which the Android SDK in 1.0.18 and earlier (1.0.53) cannot open: after such a rollback no event is recorded or sent until the app updates again to 1.0.19 or later (or is reinstalled, or its data is cleared). Going back to 1.0.19 or a later version keeps the queued events.
- Android: a push tap that arrives while JS
shutdown()runs is no longer handed to the shut-down SDK (which dropped it); it waits for the nextconfigure(). shutdown()during start-up: a native bootstrap that answers afterAppDNA.shutdown(), or after a newerconfigure(), is now ignored on both platforms. Before, it could make a shut-down SDK ready again (resolvingonReady), and on Android leave it refusing every laterconfigure(); aftershutdown()and a newconfigure(), it could make the SDK ready before the new configure’s bootstrap answered.
onPurchaseCompletedmay arrive later, through a delivery queue: on Android for a purchase whose verification failed or that was made outside the app — e.g. a resubscribe from the Play Store, a promo-code redemption, a PENDING purchase that completed while the app was killed, or a plan change (reported at app start,identifyor restore, with an emptypaywall_id); on iOS for interrupted and Ask-to-Buy purchases, and for a late purchase made while another user was signed in (delivered when that user signs in). The queue also holds an iOS Ask-to-Buy approval that arrives with nopurchase()call waiting for it, and a purchase reported after itspurchase()call was cancelled (e.g. the paywall closed). Android reports a late purchase once aspurchase_completed(+subscription_startedfor a subscription). Delivery starts when a JSonPurchaseCompletedis registered, and later happens only while one is: whenever the SDK reports a purchase, afteridentifyand at the end ofconfigure; on Android also on each return to the foreground and after a restore. Grant idempotently bytransactionId. The queue keeps 100 entries for 30 days. Known: after a reinstall an earlier acknowledged purchase is not reported again; a late-reported Android plan change looks like a new subscription.- Re-buying an owned item emits
purchase_restored(reason: "item_already_owned", no price) instead of a secondpurchase_completed. Restore emits nopurchase_restoredfor a purchase it just reported. On iOS a re-buy fires noonPurchaseCompleted(purchase()still resolves theTransactionInfo); on Android anITEM_ALREADY_OWNEDreturned synchronously by Play is handled like the listener result. - Android consumables are consumed immediately (re-buyable at once); mark them Consumable in Monetization → Products.
TransactionInfohas no quantity. - On
'storeKit2', trial purchases reportis_trial: trueand price 0, and every purchase reports the charged price (RevenueCat / Adapty omitis_trial). - Android: a lifetime purchase no longer disappears after an entitlement update;
hasActiveSubscription()counts it; server-side refunds and renewals now reach the device;reset()andidentifywith a different user clear the cached entitlements (the firstidentifyafter an anonymous session keeps them); Playon_holdandpendingsubscriptions are not active. Older cached Android entries without a type are read as lifetime when they are active Play entries without an expiry. Android also detects a renewal by the Play order id’s..Nsuffix (newsubscription_renewedevents, including on a trial conversion; known: a same-product base-plan change counts as a renewal, and a 7-day trial conversion found this way mostly lands in the d30 window). AndroidtransactionIdfalls back to the purchase token (not the product id) when Play gives no order id. Android now derives the purchaseappAccountTokenas iOS and the server do: a user id that is not a canonical 8-4-4-4-12 UUID (e.g.1-2-3-4-5) is hashed instead of used as-is. - New event properties:
emitted_by,transaction_id/original_transaction_idon lifecycle events,original_transaction_idonpurchase_completedandsubscription_started,cancel_semantics,is_consumable,delivery_id,purchase_failed.reason.AppDNA.trackdrops the reserved keysemitted_by/_appdna_originif you pass them, also on calls made beforeconfigure. - Every event carries the device’s time zone,
device.timezone(the IANA name the native SDK reads on each event). AppDNA keeps the latest one on the identified user’s profile, so pushes sent in the user’s time zone, push quiet hours and journey waits use it with no code; atimezonetrait you pass toidentifystill takes precedence. See Push.
- The experiment’s Traffic Allocation and targeting rules (countries, minimum app version, new users only, user trait conditions) are now applied on the device. A user outside them gets
nullfromgetVariant/getExperimentConfig,falsefromisInVariant, the live entity on a servable surface, and noexperiment_exposure. Before, every user on a targeted platform was assigned a variant. Allocation uses its own hash (<experimentId>.<salt>.<userId>.allocation), so the variant split is unchanged and raising the allocation keeps everyone who was in. An experiment written by an older server (notraffic_allocation) still serves everyone.
- Android: a countdown whose
target_datetimeis more than about 68 years away counts the real time, as on iOS (it was capped at 24855 days). - Sign-in actions in
onBeforeStepAdvancewait at least 120 s (max(vetoTimeout, 120 s)) instead ofvetoTimeout. - Interactive map (new). The iOS SDK now depends on the
GoogleMapspod. A map block with pan & zoom on and the Google provider draws a pannable Google map with the route polyline (otherwise straight segments between the stops). With Auto-fit on (the default) the camera fits the route and its stops, and a single point uses zoom 15; with Auto-fit off it centres on the authored centre at the authored zoom. On Android the app needs your Google key in thecom.google.android.geo.API_KEYmanifest meta-data; without it the map is static. On iOS the key comes from your AppDNA configuration; without it a Google-provider map block shows its fallback text (“Map unavailable” unless the author set one), because the still Google image needs the same key. AppDNA.getLocationDatano longer crashes on iOS after a typed answer, and returns{ formatted_address, raw_query }with null coordinates for typed-but-unselected text; every field exceptformatted_addressis optional. Both location writers on both platforms now store the same shape (the iOS form-step field used to store nothing for typed text; blank values are left out rather than stored as""; a selection storestimezone_offsetandraw_query).- An
onBeforeStepAdvancereply{ type: 'skipTo' }(orskipToWithData) without astepId, or with a blank one, is no longer a skip: on a sign-in step it is treated as no answer and blocked (it advanced the user past the sign-in step), elsewhere it proceeds (with itsdata, if any). - iOS: a
social_logintap on a step with an input field whose id isactionorprovidernow still reportsaction: "social_login"and the button’s provider (as Android does). AppDNA.session.setwith anull,undefinedorNaNvalue stores nothing and resolves on both platforms, as anulldoes on Flutter (it rejected withINVALID_VALUE). Values cross to native as JSON, so aNaNinside an object or list arrives asnull.- iOS: the SDK no longer references the Contacts, EventKit, App Tracking Transparency or Photos-library APIs unless your flow uses those permission steps. Every app still needs
NSLocationWhenInUseUsageDescription. If App Store Connect still asks for a key, add it and tell us. See Apple permission strings.
- Only AppDNA-marked pushes (
appdna: "1") are handled;onPushReceivedno longer fires for other messages, and on Android a data-only message is never shown as a blank notification (turn the SDK’s messaging service off with the resource boolappdna_messaging_service_enabled). - Tap routing follows one ladder on both platforms: the tapped button, then the push’s
action, then flataction_type/action_value, thenscreen_id, thendeep_link— a push with both an action and ascreen_idnow follows the action. iOS additionally falls back to the first button’s action when the push has none of these; Android does not. On Android an action (tapped button or body action) with a blank value routes nowhere instead of falling through toscreen_id/deep_link. - New
AppDNA.push.isAppDNAMessage/handleMessage/handleTapfor apps that own Firebase Messaging. See If your app already uses Firebase Messaging. - Android: push taps are tracked, routed and passed to
onPushTappedwith no forwarding code, including body, button and text-reply taps on notifications the SDK displayed (before, nothing handled those). The module hands the activity’s intent atconfigureand every new intent to the native SDK — a copy, so the activity’s intent keeps itsappdna/push_id/delivery_idextras and your JS can still recognise the tap (handleTapreturnstruefor it without tracking or routing it again). See Taps on Android. - Android: a tap that starts a new activity while React Native keeps running — for example after the user closed the app’s activity with Back and then tapped a notification — is now tracked, routed and passed to
onPushTapped. The module hands the current activity’s intent to the native SDK each time the app resumes, once per intent, so resuming the same activity again does not report the tap twice. Before, that tap reached neitheronNewIntentnor aconfigurethat had already run, and was lost. Beforeconfigure, only AppDNA taps are held for the SDK — once for the whole app, at most 64 — not every intent the activity receives. See Taps on Android. - Android: a push tap that was waiting for
configure()when you calledshutdown()is dropped, as on iOS; it used to be delivered to the nextconfigure(), possibly for another user. - Android 8.0 / 8.1: a push with a channel group no longer crashes the app (the native SDK called an API 28 method there). Dismissing a presentation with another queued behind it no longer crashes below Android 15.
- The Android SDK’s native
handlePushTap(intent)(called from a custom launch Activity) returnsfalsefor an intent without the AppDNA marker — except the tap intent of a notification posted by Android SDK 1.0.53 or earlier (no marker; recognised by exactly the extras that version set), which returnstrue, is routed and firesonPushTapped, and is tracked only when itspush_idis not empty; once it has handled an intent it removes theappdna/push_id/delivery_idextras, so calling it again with the same intent returnsfalse. - iOS: the SDK installs its notification handler automatically at launch (chaining any existing delegate), so AppDNA pushes are tracked and routed, including cold-start taps. Info.plist keys
AppDNADisableNotificationProxyandAppDNAForegroundPresentation. With@react-native-firebase/messagingas the outer notification delegate, a push arriving in the foreground keeps RNFirebase’s presentation and is not tracked as delivered unlessAppDNAForegroundPresentationis set or the delegate AppDNA wrapped implementswillPresent; taps are still tracked, because RNFirebase forwards them to the delegate it wrapped. With no notification delegate and no push library, an AppDNA push arriving in the foreground is now shown (AppDNA’s foreground options), tracked as delivered and passed toonPushReceived— before, it was not shown. When the SDK ends up as the outer delegate (your delegate was set indidFinishLaunching) and your delegate implementswillPresentorAppDNAForegroundPresentationis set, AppDNA’s foreground options apply to AppDNA pushes and your delegate is not called for them; if neither holds, iOS does not ask, and an AppDNA push arriving in the foreground is not shown and not tracked as delivered. Other pushes are forwarded to your delegate when it implements the method (otherwise completed with no presentation, the iOS default); the exception is theAppDNAForegroundPresentationtrade-off in Push.
Requirements
Before integrating the AppDNA React Native SDK, ensure your project meets the following minimum requirements:
Bare React Native: set
newArchEnabled=true in android/gradle.properties, and run the iOS
pod install with RCT_NEW_ARCH_ENABLED=1.
Expo: the config plugin does both for you — it writes newArchEnabled=true into
android/gradle.properties and into ios/Podfile.properties.json, which is where Expo’s Podfile
reads it from to set RCT_NEW_ARCH_ENABLED.
Installation
Install the AppDNA SDK package:Bare RN, iOS: register the screen slot when your Podfile uses dynamic frameworks
This section applies to bare React Native. Expo SDK 52 apps can skip it — the config plugin writes this override into the (Objective-C) AppDelegate on every prebuild. Expo SDK 53+ apps have a Swift AppDelegate, which the plugin does not patch: with dynamic frameworks it skips the override and prints a warning, see Expo below — use static frameworks there instead. The AppDNA iOS SDK needsuse_frameworks! in your Podfile, dynamic or :linkage => :static: its
Firebase dependency cannot be built from pods built as static libraries (iOS installation).
Skip this section if your Podfile builds static frameworks (use_frameworks! :linkage => :static,
or USE_FRAMEWORKS=static) — nothing to do there. With dynamic frameworks (use_frameworks!, or
USE_FRAMEWORKS=dynamic) you must hand the screen-slot view to React yourself.
React Native’s generated component registry wraps its whole third-party component map in
#ifndef RCT_DYNAMIC_FRAMEWORKS, and React Native defines RCT_DYNAMIC_FRAMEWORKS only for dynamic
frameworks, so with dynamic frameworks nothing registers AppdnaScreenSlotView.
There is no error and no warning — <AppDNAScreenSlot> simply renders React’s placeholder,
Unimplemented component: <AppdnaScreenSlotView>, and the slot never appears.
Add the override below to AppDelegate.mm. RCTAppDelegate already exposes the hook:
<AppDNAScreenSlot> component needs it.
Expo
The package ships a config plugin. Add it toapp.json and run a prebuild:
Options — all optional:
Autolinking wires the native iOS and Android modules automatically — no manual linking step is required.
Firebase Configuration
The AppDNA SDK uses Firebase Firestore for configuration delivery (paywalls, experiments, feature flags, onboarding flows). You must add Firebase configuration files for each platform your app targets.Step 1: Download Firebase Config Files
- Log into your AppDNA Console
- Go to Settings → SDK
- Click Download Firebase Config to download both:
GoogleService-Info-AppDNA.plist(for iOS)google-services-appdna.json(for Android)
Step 2: Add iOS Configuration
- Open
ios/<YourApp>.xcworkspacein Xcode - Drag
GoogleService-Info-AppDNA.plistinto your app target’s folder in the project navigator - Ensure “Copy items if needed” is checked
- Select your app target in the “Add to targets” section
Step 3: Add Android Configuration
Place thegoogle-services-appdna.json file in the Android app module directory:
android/app/build.gradle:
android/build.gradle:
If your app already uses Firebase for your own services (Realtime Database, Analytics, Crashlytics), AppDNA automatically initializes a separate named Firebase instance using
GoogleService-Info-AppDNA.plist (iOS) and google-services-appdna.json (Android). Your existing Firebase setup is not affected. Just add the AppDNA config files alongside your own.Architecture
The@appdna-ai/react-native-sdk package is a thin TypeScript facade over a TurboModule. Rendering,
storage, and network I/O all happen in the native iOS and Android SDKs; the facade marshals method
calls and receives native callbacks.
Method calls resolve through TurboModuleRegistry, and native events arrive on the generated event
emitters — not through NativeEventEmitter, which under the New Architecture would subscribe to a
channel nothing writes to.
Callbacks that native waits for
Most callbacks are one-way notifications. Eight are vetoes: native pauses and waits for your answer before it proceeds.
Each may return a value or a
Promise of one, so a handler can ask your own backend. If it takes
longer than the veto timeout (5 seconds by default, vetoTimeout in configure), native applies the
default in the last column and moves on. onPromoCodeSubmit is the one hook whose default is to
refuse — a timeout means the code was never validated.
You never interact with the bridge directly. The
AppDNA class and its module accessors provide a
high-level TypeScript API.Native Dependencies
The React Native SDK inherits the dependencies of the underlying native SDKs. These are resolved automatically by CocoaPods (iOS) and Gradle (Android):If your project already includes these dependencies, ensure your version constraints are compatible with the versions above. CocoaPods and Gradle will resolve conflicts automatically in most cases.
Lottie and Rive animation support is provided by the native SDKs. On iOS, add
lottie-ios and rive-ios to your Podfile if you use those formats. On Android, the equivalent dependencies are bundled with the native AppDNA SDK.Import
Import the SDK in any TypeScript / JavaScript file where you need to use it:AppDNA static class, every module accessor, the canonical delegate interfaces (AppDNAPushDelegate, AppDNAPaywallDelegate, AppDNAOnboardingDelegate, etc.), and all DTOs (Entitlement, ProductInfo, TransactionInfo, PaywallContext, …).
Interactive maps
An onboarding step can contain a map. The static map needs no setup — the provider key is delivered with your configuration.Enable the Maps SDK for this platform in Google Cloud, alongside the Maps Static API the map
image already uses: APIs & Services → Library → Maps SDK for iOS and Maps SDK for Android. Pan & zoom is drawn by that SDK, so
without it enabled the key is rejected at map start and the step falls back to the still image —
every bit of wiring below can be correct and the map still will not move. See
Maps for the full key setup.
android/app/src/main/AndroidManifest.xml:
None of this applies to Swift Package Manager. An iOS app that adds AppDNASDK through SPM gets the
map engine linked automatically, with no Podfile and nothing to add.
Verify Installation
After adding the dependency, verify the SDK is correctly installed by printing the version:You should see a version string. If the call throws instead, the error names the exact cause: Expo Go,
a missing
pod install, an unsupported runtime, or the New Architecture being switched off.Troubleshooting
If you encounter issues during integration, callAppDNA.diagnose() after configuration to get a full health report:
For more verbose output, set
logLevel: 'debug' in AppDNAOptions during development to surface SDK activity.

