Skip to main content
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)
The React Native SDK requires the New Architecture. Versions 1.0.6 and earlier shipped no podspec and no Android Gradle module, so they could not be linked into an app at all — install 1.0.7 or later.

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 emits paying_user_reported with source: "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. Before configure() 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: onEntitlementsChanged now fires on a renewal, expiry, refund or revocation as well as on a purchase, restore or identify, only on a real change, with real isActive / expiresAt; iOS purchases are verified by the AppDNA server in the background under 'storeKit2'; transaction.environment reports production / sandbox / xcode on iOS.
  • billingProvider decides 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 calls onPaywallPurchaseFailed with errorType: 'providerNotAvailable' and the tapped productId. Before, on iOS a 'revenueCat' tap made a StoreKit purchase that AppDNA finished, an Adapty tap failed with unknown “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 with unknown. Now direct purchase / restorePurchases also reject with providerNotAvailable under these providers. See Billing.
  • Under 'revenueCat' the SDK no longer emits subscription_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 its expiresAt has 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 userCancelled and is tracked as purchase_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 onPushTapped on Android 7–11. diagnose() reports vetoTimeout as given (0.5, not 1 on Android or 0 on iOS).
  • Restore rejects with the billingErrorType code on both platforms (was RESTORE_ERROR): providerNotAvailable for 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 with providerNotAvailable (was unknown), and a paywall purchase fails visibly (on iOS it was silent).
  • An Android verification failure now rejects with verificationFailed instead of unknown; productNotFound now reaches you as the purchase error code. A purchase started while the Play connection is failing fails within 30 s with serverError.
  • purchase / restorePurchases called before configure completes fail with “AppDNA SDK not configured yet — call configure() first” (code unknown). A paywall purchase or restore tap before billing is ready, or after shutdown() has torn it down, fails the same way, without onPaywallRestoreStarted (on Android onPaywallRestoreStarted used 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 with providerNotAvailable, without onPaywallRestoreStarted (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_success action or one this SDK version does not know (the paywall stays up for your app to close), and on Android when shutdown() 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_failed on direct purchases; error_type on the paywall purchase_restore_failed; purchase_restore_failed{error_type: providerNotAvailable} for a paywall restore without a billing bridge; a failed direct restorePurchases() tracks purchase_restore_failed (no paywall_id).
  • The Android SDK now sends billing_owner on 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.
Lifecycle
  • 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’s Retry-After holds the next one back. A retry that succeeds fetches remote config and starts the Firestore listeners; onReady does 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. After shutdown() and a new configure(), 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() after shutdown() 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 through identify(userId, traits), a change of user and reset().
  • onInitDegraded and getLastInitError() report the same type (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 next configure().
  • shutdown() during start-up: a native bootstrap that answers after AppDNA.shutdown(), or after a newer configure(), is now ignored on both platforms. Before, it could make a shut-down SDK ready again (resolving onReady), and on Android leave it refusing every later configure(); after shutdown() and a new configure(), it could make the SDK ready before the new configure’s bootstrap answered.
Late purchases (no JS API change)
  • onPurchaseCompleted may 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, identify or restore, with an empty paywall_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 no purchase() call waiting for it, and a purchase reported after its purchase() call was cancelled (e.g. the paywall closed). Android reports a late purchase once as purchase_completed (+ subscription_started for a subscription). Delivery starts when a JS onPurchaseCompleted is registered, and later happens only while one is: whenever the SDK reports a purchase, after identify and at the end of configure; on Android also on each return to the foreground and after a restore. Grant idempotently by transactionId. 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 second purchase_completed. Restore emits no purchase_restored for a purchase it just reported. On iOS a re-buy fires no onPurchaseCompleted (purchase() still resolves the TransactionInfo); on Android an ITEM_ALREADY_OWNED returned synchronously by Play is handled like the listener result.
  • Android consumables are consumed immediately (re-buyable at once); mark them Consumable in Monetization → Products. TransactionInfo has no quantity.
  • On 'storeKit2', trial purchases report is_trial: true and price 0, and every purchase reports the charged price (RevenueCat / Adapty omit is_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() and identify with a different user clear the cached entitlements (the first identify after an anonymous session keeps them); Play on_hold and pending subscriptions 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 ..N suffix (new subscription_renewed events, 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). Android transactionId falls back to the purchase token (not the product id) when Play gives no order id. Android now derives the purchase appAccountToken as 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_id on lifecycle events, original_transaction_id on purchase_completed and subscription_started, cancel_semantics, is_consumable, delivery_id, purchase_failed.reason. AppDNA.track drops the reserved keys emitted_by / _appdna_origin if you pass them, also on calls made before configure.
  • 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; a timezone trait you pass to identify still takes precedence. See Push.
Experiments (Who Is in an Experiment)
  • 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 null from getVariant / getExperimentConfig, false from isInVariant, the live entity on a servable surface, and no experiment_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 (no traffic_allocation) still serves everyone.
Onboarding
  • Android: a countdown whose target_datetime is more than about 68 years away counts the real time, as on iOS (it was capped at 24855 days).
  • Sign-in actions in onBeforeStepAdvance wait at least 120 s (max(vetoTimeout, 120 s)) instead of vetoTimeout.
  • Interactive map (new). The iOS SDK now depends on the GoogleMaps pod. 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 the com.google.android.geo.API_KEY manifest 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.getLocationData no longer crashes on iOS after a typed answer, and returns { formatted_address, raw_query } with null coordinates for typed-but-unselected text; every field except formatted_address is 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 stores timezone_offset and raw_query).
  • An onBeforeStepAdvance reply { type: 'skipTo' } (or skipToWithData) without a stepId, 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 its data, if any).
  • iOS: a social_login tap on a step with an input field whose id is action or provider now still reports action: "social_login" and the button’s provider (as Android does).
  • AppDNA.session.set with a null, undefined or NaN value stores nothing and resolves on both platforms, as a null does on Flutter (it rejected with INVALID_VALUE). Values cross to native as JSON, so a NaN inside an object or list arrives as null.
  • 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.
Push
  • Only AppDNA-marked pushes (appdna: "1") are handled; onPushReceived no 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 bool appdna_messaging_service_enabled).
  • Tap routing follows one ladder on both platforms: the tapped button, then the push’s action, then flat action_type / action_value, then screen_id, then deep_link — a push with both an action and a screen_id now 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 to screen_id / deep_link.
  • New AppDNA.push.isAppDNAMessage / handleMessage / handleTap for apps that own Firebase Messaging. See If your app already uses Firebase Messaging.
  • Android: push taps are tracked, routed and passed to onPushTapped with 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 at configure and every new intent to the native SDK — a copy, so the activity’s intent keeps its appdna / push_id / delivery_id extras and your JS can still recognise the tap (handleTap returns true for 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 neither onNewIntent nor a configure that had already run, and was lost. Before configure, 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 called shutdown() is dropped, as on iOS; it used to be delivered to the next configure(), 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) returns false for 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 returns true, is routed and fires onPushTapped, and is tracked only when its push_id is not empty; once it has handled an intent it removes the appdna / push_id / delivery_id extras, so calling it again with the same intent returns false.
  • 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 AppDNADisableNotificationProxy and AppDNAForegroundPresentation. With @react-native-firebase/messaging as the outer notification delegate, a push arriving in the foreground keeps RNFirebase’s presentation and is not tracked as delivered unless AppDNAForegroundPresentation is set or the delegate AppDNA wrapped implements willPresent; 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 to onPushReceived — before, it was not shown. When the SDK ends up as the outer delegate (your delegate was set in didFinishLaunching) and your delegate implements willPresent or AppDNAForegroundPresentation is 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 the AppDNAForegroundPresentation trade-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.
The React Native SDK delegates rendering, storage, and network I/O to the native iOS and Android SDKs, so every paywall, onboarding flow, and in-app message looks and feels platform-native. Your project must meet the native platform requirements as well.

Installation

Install the AppDNA SDK package:
Or with npm:
Then install the iOS Pods with the New Architecture enabled:

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 needs use_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:
Everything else — every method, every event, every delegate — works without this. Only the inline <AppDNAScreenSlot> component needs it.

Expo

The package ships a config plugin. Add it to app.json and run a prebuild:
The plugin does five things, none of which Expo’s defaults give you: Options — all optional:
Expo SDK 53+ has a Swift AppDelegate, and the plugin cannot write the ObjC override (4) into it. With dynamic frameworks it skips the override and expo prebuild prints a warning naming the two fixes; the prebuild still succeeds, but <AppDNAScreenSlot> renders nothing. Build static frameworks — { "useFrameworks": "static" }, which makes the override unnecessary because React Native’s own registry is then compiled in — or, if you do not use <AppDNAScreenSlot>, acknowledge it with { "screenSlot": "skip" }, which skips the override without the warning. Every other SDK feature works in every case.
Expo Go cannot host a native module. Use a development build.
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

  1. Log into your AppDNA Console
  2. Go to Settings → SDK
  3. Click Download Firebase Config to download both:
    • GoogleService-Info-AppDNA.plist (for iOS)
    • google-services-appdna.json (for Android)

Step 2: Add iOS Configuration

  1. Open ios/<YourApp>.xcworkspace in Xcode
  2. Drag GoogleService-Info-AppDNA.plist into your app target’s folder in the project navigator
  3. Ensure “Copy items if needed” is checked
  4. Select your app target in the “Add to targets” section

Step 3: Add Android Configuration

Place the google-services-appdna.json file in the Android app module directory:
Ensure the Google Services plugin is applied in your android/app/build.gradle:
And the classpath is added to android/build.gradle:
Without the Firebase config files, the SDK cannot fetch remote configuration (paywalls, experiments, feature flags). Events will still be tracked, but remote features will not work.
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:
This entry point exposes the 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.
A pannable map needs one line in your Android host, and nothing at all on iOS. The Maps SDK for Android reads its key from the merged manifest at build time, so add this to android/app/src/main/AndroidManifest.xml:
Without it the SDK falls back to the static map rather than rendering a grey rectangle, so nothing breaks — the map simply does not pan. The key is always yours; AppDNA never substitutes one of its own, because Google bills the account that owns it.
Your Podfile needs use_frameworks! — the iOS SDK cannot be built from pods built as static libraries (iOS installation). With dynamic frameworks (plain use_frameworks! or USE_FRAMEWORKS=dynamic, not :linkage => :static), add this to it. Google ships the Maps SDK as a static xcframework, and CocoaPods refuses a static binary inside a dynamically-linked target:
That validation guards against the same static code being embedded in several dynamic frameworks. With one consumer — your app — that cannot happen, which is why this is the standard remedy across the GoogleMaps ecosystem.And name the pod in your app target too, inside the target '<YourApp>' do block:
The two lines do different jobs and you need both. The pre_install block lets pod install finish; it does not make CocoaPods link a static framework that reached your app transitively. Without this second line the app compiles and then fails at the linker with Undefined symbols ... _OBJC_CLASS_$_GMSCameraPosition. react-native-maps asks for the same line, for the same reason.
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, call AppDNA.diagnose() after configuration to get a full health report:
This prints a diagnostic report to the platform console (Xcode debug area on iOS, Logcat on Android):
Any items marked with ❌ indicate a configuration issue. Common fixes: For more verbose output, set logLevel: 'debug' in AppDNAOptions during development to surface SDK activity.

Next Steps

Once the SDK is installed, proceed to the Quickstart guide to configure the SDK and start tracking events.