> ## Documentation Index
> Fetch the complete documentation index at: https://docs.appdna.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Core Concepts

> Architecture, offline-first design, and key abstractions

# Core Concepts

This page covers the foundational architecture and design decisions behind AppDNA. Understanding these concepts will help you integrate the SDK effectively and get the most out of the platform.

***

## SDK Architecture

The AppDNA SDK follows a **singleton pattern** with module namespaces. After calling `configure()`, the SDK is accessible through a single global instance. Each feature area is organized as a namespace on that instance:

| Namespace       | Purpose                                            |
| --------------- | -------------------------------------------------- |
| `push`          | Push notification registration and handling        |
| `billing`       | Subscription status, product fetching, purchases   |
| `onboarding`    | Server-driven onboarding flow presentation         |
| `paywall`       | Server-driven paywall rendering and events         |
| `remoteConfig`  | Key-value remote configuration                     |
| `features`      | Feature flags and entitlements                     |
| `experiments`   | A/B experiment assignment and exposure tracking    |
| `surveys`       | In-app survey presentation and response collection |
| `inAppMessages` | Triggered in-app messaging                         |
| `deepLinks`     | Deep link routing and deferred deep links          |

Each namespace operates independently but shares the core event pipeline, identity state, and configuration layer. For a quick reference on what's configured in the Console vs. what requires code, see [Dashboard vs. Code](/dashboard-vs-code).

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    // Access module namespaces
    AppDNA.pushModule.requestPermission()
    AppDNA.billing.getProducts(["premium_monthly"])
    AppDNA.experiments.getVariant("onboarding-v2")
    AppDNA.remoteConfig.get("welcome_message")
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    // Access module namespaces
    AppDNA.push.requestPermission(activity)
    AppDNA.billing.getProducts(listOf("premium_monthly"))
    AppDNA.experiments.getVariant("onboarding-v2")
    AppDNA.remoteConfig.get("welcome_message")
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    // Access module namespaces
    await AppDNA.push.requestPermission();
    await AppDNA.billing.getProducts(["premium_monthly"]);
    await AppDNA.experiments.getVariant("onboarding-v2");
    await AppDNA.remoteConfig.get("welcome_message");
    ```
  </Tab>

  <Tab title="React Native">
    ```javascript theme={null}
    // Access module namespaces
    await AppDNA.push.requestPermission();
    await AppDNA.billing.getProducts(["premium_monthly"]);
    await AppDNA.experiments.getVariant("onboarding-v2");
    await AppDNA.remoteConfig.get("welcome_message");
    ```
  </Tab>
</Tabs>

***

## Offline-First Design

AppDNA SDKs are built for unreliable networks. The SDK never assumes connectivity is available and degrades gracefully when it is not.

### Configuration Priority

The SDK resolves configuration using a three-tier fallback:

```
Remote (Firestore real-time sync)
        │
        ▼ unavailable?
Cached (persisted on device from last successful fetch)
        │
        ▼ unavailable?
Bundled (appdna-config.json embedded in the app binary)
```

| Source      | Description                                                                                           | Latency |
| ----------- | ----------------------------------------------------------------------------------------------------- | ------- |
| **Remote**  | Real-time sync from Firestore. Always preferred when available.                                       | \~100ms |
| **Cached**  | Last known good config, persisted in local storage. Expires after the configured TTL.                 | \~1ms   |
| **Bundled** | Static JSON file shipped with the app binary. Used on first launch before any network call completes. | \~1ms   |

<Info>
  The default config TTL is **1 hour**. After the TTL expires, the SDK attempts a remote fetch. If the fetch fails, the cached config continues to be used until a successful refresh.
</Info>

### Event Queue

Events are never dropped. When the SDK records an event:

1. The event is written to a **persistent on-disk queue** (a JSON file in Application Support on iOS, a SQLite database on Android).
2. The queue is **auto-flushed** every **30 seconds** or when it reaches **20 events**, whichever comes first.
3. If the flush fails (no connectivity, server error), events remain in the queue and are retried on the next flush cycle.
4. Events are only removed from the queue after a successful server acknowledgment.

<Note>
  Because events are persisted to disk, they survive app restarts and even device reboots. No event is ever lost due to a crash or force-quit.
</Note>

***

## Config Bundle

A **config bundle** is a versioned JSON snapshot generated server-side. It contains all active configuration for your app: onboarding flows, paywalls, in-app messages, experiments, feature flags, and remote config values.

### How It Works

1. The dashboard generates a new bundle version whenever you publish a change.
2. The SDK polls `GET /api/v1/sdk/config-bundle/version` to check for updates.
3. If a newer version exists, the SDK downloads the full bundle and caches it locally.
4. The bundle can also be **embedded in your app binary** for zero-latency first launch.

### Embedding a Bundle

For the best first-launch experience, embed the latest config bundle in your app:

<Tabs>
  <Tab title="iOS">
    Add `appdna-config.json` to your Xcode project as a bundle resource.
  </Tab>

  <Tab title="Android">
    Place `appdna-config.json` in the `assets/` directory.
  </Tab>

  <Tab title="Flutter">
    Add `appdna-config.json` to your `assets/` folder and declare it in `pubspec.yaml`.
  </Tab>

  <Tab title="React Native">
    Place `appdna-config.json` in your project root or assets directory.
  </Tab>
</Tabs>

<Warning>
  The embedded bundle is a fallback only. The SDK will always prefer a fresher remote or cached config when available. Embedding a bundle ensures your app works correctly on first launch before any network request completes.
</Warning>

***

## Experiments

AppDNA experiments use **deterministic bucketing** to assign users to variants. No server call is required — the assignment is computed locally on the device.

### How Bucketing Works

The SDK computes a bucket from 0–9999 using a deterministic hash of the user and experiment identifiers:

| Input          | Source                                                     |
| -------------- | ---------------------------------------------------------- |
| `userId`       | The identified user ID (or anonymous ID if not identified) |
| `experimentId` | Unique identifier for the experiment                       |

The resulting bucket (0–9999) maps to a variant based on the traffic allocation configured in the dashboard.

### Key Properties

* **Deterministic**: The same user always gets the same variant for the same experiment. No randomness, no server dependency.
* **Cross-platform consistent**: A user who opens your app on iOS and Android will see the same variant because the hash function and inputs are identical.
* **Session-stable**: The variant does not change between sessions or app restarts.
* **Exposure tracking**: The SDK records an exposure event **once per session** when `getVariant()` is called. This prevents inflated exposure counts from multiple reads.

### Servable Surface Experiments

Experiments can also test an entire managed surface — a paywall, onboarding flow, in-app message, or survey — with no app code. The experiment's control variant references the live surface and the treatment variant carries an alternate configuration. When the app presents that surface, the SDK buckets the user (same deterministic hash) and serves the treatment configuration to the treatment cohort automatically, while everyone else sees the live surface. The treatment configuration ships only inside the experiment, so it never reaches a non-bucketed user. Servable-surface experiments render on iOS (`1.0.65+`) and Android (`1.0.37+`); on Flutter and React Native the control variant is shown. See the [Experiments guide](/guides/experiments#servable-surface-experiments).

The code-level pattern below remains available for feature flags, custom UI, or logic that isn't a managed surface:

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    let variant = AppDNA.experiments.getVariant("onboarding-v2")

    switch variant {
    case "control":
        showClassicOnboarding()
    case "streamlined":
        showStreamlinedOnboarding()
    default:
        showClassicOnboarding()
    }
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    val variant = AppDNA.experiments.getVariant("onboarding-v2")

    when (variant) {
        "control" -> showClassicOnboarding()
        "streamlined" -> showStreamlinedOnboarding()
        else -> showClassicOnboarding()
    }
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    final variant = await AppDNA.experiments.getVariant("onboarding-v2");

    if (variant == "streamlined") {
      showStreamlinedOnboarding();
    } else {
      showClassicOnboarding();
    }
    ```
  </Tab>

  <Tab title="React Native">
    ```javascript theme={null}
    const variant = await AppDNA.experiments.getVariant("onboarding-v2");

    if (variant === "streamlined") {
      showStreamlinedOnboarding();
    } else {
      showClassicOnboarding();
    }
    ```
  </Tab>
</Tabs>

***

## Delegates and Callbacks

All SDK modules use a **delegate/callback pattern** to communicate events back to your application. This keeps the SDK non-blocking and lets you respond to events in your own code.

<Tabs>
  <Tab title="iOS">
    Modules expose **protocols** with default empty implementations. Conform to only the methods you need:

    ```swift theme={null}
    class MyPaywallHandler: AppDNAPaywallDelegate {
        func onPaywallPresented(paywallId: String) {
            // Track in your own analytics
        }

        func onPaywallDismissed(paywallId: String) {
            // Handle purchase or dismissal
        }
    }

    AppDNA.paywall.setDelegate(MyPaywallHandler())
    ```
  </Tab>

  <Tab title="Android">
    Modules expose **interfaces** with default methods. Override only what you need:

    ```kotlin theme={null}
    AppDNA.paywall.setDelegate(object : AppDNAPaywallDelegate {
        override fun onPaywallPresented(paywallId: String) {
            // Track in your own analytics
        }

        override fun onPaywallDismissed(paywallId: String) {
            // Handle purchase or dismissal
        }
    })
    ```
  </Tab>

  <Tab title="Flutter">
    Modules use **abstract classes** that you extend:

    ```dart theme={null}
    class MyPaywallHandler extends AppDNAPaywallDelegate {
      @override
      void onPaywallPresented(String paywallId) {
        // Track in your own analytics
      }

      @override
      void onPaywallDismissed(String paywallId) {
        // Handle purchase or dismissal
      }
    }

    AppDNA.paywall.setDelegate(MyPaywallHandler());
    ```
  </Tab>

  <Tab title="React Native">
    Implement the **delegate interface** and register it once with `setDelegate`. Registering again replaces the previous delegate; pass `null` to clear it:

    ```typescript theme={null}
    const paywallDelegate: AppDNAPaywallDelegate = {
      onPaywallPresented: (paywallId) => {
        // Track in your own analytics
      },
      onPaywallDismissed: (paywallId) => {
        // Handle purchase or dismissal
      },
      // ...implement the other AppDNAPaywallDelegate methods (purchase, restore, promo)
    };

    AppDNA.paywall.setDelegate(paywallDelegate);

    // Detach when you no longer need callbacks:
    AppDNA.paywall.setDelegate(null);
    ```
  </Tab>
</Tabs>

***

## Environments

AppDNA supports two environments to separate development and production data:

| Environment    | API Base URL            | Key Prefix  |
| -------------- | ----------------------- | ----------- |
| **Production** | `https://api.appdna.ai` | `adn_live_` |
| **Sandbox**    | `https://api.appdna.ai` | `adn_test_` |

* Events, experiments, and configuration are **completely isolated** between environments.
* Use sandbox during development and testing. Switch to production for App Store / Play Store builds.
* The SDK determines the environment from the API key prefix — there is no need to set it separately if the key is correct.

<Warning>
  Never ship an app to production with a sandbox API key. Sandbox data is periodically purged and is not included in production analytics.
</Warning>

***

## Webhooks

AppDNA can send real-time HTTP callbacks to your server whenever key events occur. Webhooks let you sync data to your backend, trigger workflows, or feed events into third-party tools.

### Event Types

AppDNA supports **16 webhook event types** spanning the full lifecycle:

| Category            | Events                                                         |
| ------------------- | -------------------------------------------------------------- |
| **Onboarding**      | `onboarding.started`, `onboarding.completed`                   |
| **Surveys**         | `survey.completed`                                             |
| **Billing**         | `payment.completed`, `payment.failed`, `subscription.canceled` |
| **Push**            | `push.delivered`, `push.opened`                                |
| **Email**           | `email.opened`, `email.clicked`                                |
| **In-App Messages** | `message.clicked`                                              |
| **Journeys**        | `journey.completed`, `journey.exited`                          |
| **Experiments**     | `experiment.exposure`                                          |
| **SDK**             | `user.identified`                                              |
| **System**          | `webhook.test`                                                 |

### Security

Every webhook payload is signed with **HMAC-SHA256**. Your server should verify the signature before processing:

```
X-AppDNA-Signature: sha256=<hex-encoded HMAC of request body>
```

The signing secret is generated when you create a webhook endpoint in the dashboard and is only displayed once.

### Reliability

* **5 attempts total** (1 initial delivery + 4 retries) with backoff at 30s, 2min, 10min, 1hr.
* Your endpoint must respond with a `2xx` status within 30 seconds or the attempt is considered failed.
* After **50 consecutive failures**, the webhook endpoint is **automatically disabled** (`disabled_reason: consecutive_failures`). Re-enable it from the dashboard or via the API after fixing the issue.
* You can view delivery logs and manually retry failed deliveries in the dashboard.

<Info>
  To test webhooks locally during development, use a tunneling tool like ngrok and point your webhook endpoint to the tunnel URL.
</Info>

***

## Identity

AppDNA manages user identity through a combination of anonymous and authenticated identifiers.

### Anonymous ID

On first launch, the SDK generates a **random anonymous ID** and persists it securely:

| Platform     | Storage                                                           |
| ------------ | ----------------------------------------------------------------- |
| iOS          | Keychain                                                          |
| Android      | SharedPreferences (encrypted)                                     |
| Flutter      | Platform-specific (Keychain on iOS, SharedPreferences on Android) |
| React Native | Platform-specific (Keychain on iOS, SharedPreferences on Android) |

The anonymous ID survives app restarts and reinstalls (on iOS, as long as the Keychain entry is preserved).

### Linking Identity

When you call `identify(userId:)`, the SDK links the anonymous ID to the provided user ID. All past events tracked under the anonymous ID are retroactively associated with the user.

```
Anonymous ID (auto-generated)  ──identify()──▶  User ID (your system)
```

### Resetting Identity

Calling `reset()` clears the user ID but **keeps the anonymous ID**. This is appropriate for sign-out flows where another user may sign in on the same device.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    // User signs out
    AppDNA.reset()

    // New user signs in
    AppDNA.identify(userId: "user-456", traits: ["plan": "free"])
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    // User signs out
    AppDNA.reset()

    // New user signs in
    AppDNA.identify(userId = "user-456", traits = mapOf("plan" to "free"))
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    // User signs out
    await AppDNA.reset();

    // New user signs in
    await AppDNA.identify("user-456", traits: {"plan": "free"});
    ```
  </Tab>

  <Tab title="React Native">
    ```javascript theme={null}
    // User signs out
    await AppDNA.reset();

    // New user signs in
    await AppDNA.identify("user-456", { plan: "free" });
    ```
  </Tab>
</Tabs>

<Note>
  The anonymous ID is never deleted by `reset()`. This ensures continuity for device-level analytics even across multiple user sessions.
</Note>
