Skip to main content
Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · React Native SDK 1.0.4+
The AppDNA React Native SDK delivers full onboarding parity through native platform rendering. The onboarding module lets you present server-driven onboarding flows that are configured in the AppDNA Console, with native UI primitives on each platform — SwiftUI on iOS, Jetpack Compose on Android. Flows are delivered to the SDK via the remote config bundle, so you can update onboarding experiences without shipping app updates.

Present an Onboarding Flow

Present a specific onboarding flow by ID. The method returns a Promise that resolves once the native flow surface is shown:
import { AppDNA } from '@appdna/react-native-sdk';

try {
  await AppDNA.presentOnboarding('main_flow');
} catch (e) {
  console.log('Flow config not available — check Console or network:', e);
}
If the flow ID is unknown to the SDK or the remote config bundle has not finished loading, the call returns immediately as a no-op and a debug-level log entry is emitted.
The React Native API takes the flow ID as a positional argument. To present the active flow configured in remote config from the AppDNA Console, the canonical pattern is to pick an explicit flow ID at the call site — the Console controls which flow is active by toggling published flows.

Module Access

Access the onboarding module directly to use additional methods like setDelegate and to pass an OnboardingContext:
const onboarding = AppDNA.onboarding;

Module Methods

MethodSignatureDescription
presentpresent(flowId: string, context?: OnboardingContext): Promise<void>Present an onboarding flow
setDelegate`setDelegate(delegate: AppDNAOnboardingDelegatenull): void`Set a delegate for flow callbacks
The static AppDNA.presentOnboarding(flowId) is a convenience that calls AppDNA.onboarding.present(flowId) with no context.

OnboardingContext

Pass additional context when presenting a flow:
const context: OnboardingContext = {
  source: 'app_launch',
  campaign: 'winter_2025',
  referrer: 'social_ad',
  userProperties: { locale: 'en_US' },
  experimentOverrides: { onboarding_variant: 'b' },
};

await AppDNA.onboarding.present('main_flow', context);
PropertyTypeDescription
sourcestring?Where the flow was triggered from
campaignstring?Attribution campaign identifier
referrerstring?Referral source
userPropertiesRecord<string, unknown>?Additional user properties for personalization
experimentOverridesRecord<string, string>?Override experiment variant assignments for testing
The experimentOverrides parameter is intended for development and QA only. In production, experiment variants are assigned automatically by the AppDNA backend.

Paywalls inside onboarding flows

Steps with outcome: present_paywall open a paywall and route the user based on the result (purchase / fail / dismiss → next step). The host’s AppDNAPaywallDelegate (registered via AppDNA.paywall.setDelegate(...)) fires for these embedded paywalls identically to standalone presentations — purchase, restore, action, and post-purchase callbacks all reach the host. See the React Native paywall docs for the full delegate surface.

AppDNAOnboardingDelegate

Implement the AppDNAOnboardingDelegate interface to receive onboarding flow lifecycle events. The delegate fires for every step transition and at flow completion / dismissal. For server-driven async hooks that can block or redirect step advancement, see Async Step Hooks below.
interface AppDNAOnboardingDelegate {
  onOnboardingStarted(flowId: string): void;
  onOnboardingStepChanged(
    flowId: string,
    stepId: string,
    stepIndex: number,
    totalSteps: number,
  ): void;
  onOnboardingCompleted(flowId: string, responses: Record<string, unknown>): void;
  onOnboardingDismissed(flowId: string, atStep: number): void;

  // Async hooks — see "Async Step Hooks" below
  onBeforeStepAdvance?(
    flowId: string,
    fromStepId: string,
    stepIndex: number,
    stepType: string,
    responses: Record<string, unknown>,
    stepData: Record<string, unknown> | null,
  ): Promise<StepAdvanceResult>;

  onBeforeStepRender?(
    flowId: string,
    stepId: string,
    stepIndex: number,
    stepType: string,
    responses: Record<string, unknown>,
  ): Promise<StepConfigOverride | null>;
}

Set the Delegate

AppDNA.onboarding.setDelegate(myOnboardingDelegate);

Example Implementation

const onboardingHandler: AppDNAOnboardingDelegate = {
  onOnboardingStarted(flowId) {
    console.log(`Onboarding started: ${flowId}`);
  },

  onOnboardingStepChanged(flowId, stepId, stepIndex, totalSteps) {
    console.log(`Step ${stepIndex + 1}/${totalSteps}: ${stepId}`);
    // Update progress indicator
  },

  onOnboardingCompleted(flowId, responses) {
    console.log(`Onboarding completed: ${flowId}`);
    console.log('User responses:', responses);
    // Navigate to main app screen
    // Use responses to personalize the experience
  },

  onOnboardingDismissed(flowId, atStep) {
    console.log(`Onboarding dismissed at step ${atStep}`);
    // Handle early exit — maybe show again later
  },
};

Step Types

Onboarding flows support the following step types, configured in the AppDNA Console:
Step TypeDescription
welcomeWelcome screen with title, subtitle, and hero image
questionSingle-select or multi-select question for user input
value_propValue proposition screen highlighting a key feature or benefit
formStructured form with multiple native input fields
interactive_chatConversational step backed by your webhook
infoInformational screen rendered through the custom-step pipeline
permissionPush notification or tracking permission request
customCustom content rendered via a template
Step types and their content are defined entirely in the Console. The SDK renders them natively — you do not need to build UI for individual step types from React Native.

Selection Modes

Question steps support two selection modes:
ModeDescription
SINGLEUser selects exactly one option
MULTIUser can select multiple options

Content Blocks

Each onboarding step is composed of content blocks that control the visual layout. Blocks are configured in the Console and rendered natively on each platform. The block string the Console emits is the same string the native SDK switches on. The following block types are available:

Layout primitives

Block TypeDescription
headingPrimary heading text
textBody / subtitle text
rich_textMarkdown-style rich text with inline formatting
listBulleted or numbered list
badgePill-shaped label
iconSingle icon (Lucide / Material / SF / emoji)
imageStatic image with sizing and corner radius
lottieLottie animation (JSON or dotLottie)
riveRive state-machine animation
videoInline video (MP4, HLS) with autoplay and loop options
dividerHorizontal separator
spacerConfigurable vertical spacing
stackVertical container that groups child blocks
rowHorizontal container that arranges child blocks side by side
star_backgroundAnimated starfield or particle background effect
custom_viewHost-app-provided view (see Custom View Registration)

Interactive blocks

Block TypeDescription
buttonTappable button with configurable action
social_loginSocial sign-in buttons (Apple, Google, etc.)
wheel_pickerScrollable wheel-style picker for value selection
pulsing_avatarAnimated avatar with a pulsing ring effect
timelineVertical timeline with labeled milestones
animated_loadingSkeleton or spinner loading animation between steps
countdown_timerCountdown timer with configurable duration and expiry action
ratingStar or emoji rating selector
progress_barHorizontal progress bar with percentage or label
circular_gaugeCircular progress indicator with value label
date_wheel_pickerNative date wheel picker (day/month/year columns)
page_indicatorDot or bar indicator showing current step progress
pricing_cardProduct pricing card with plan details and CTA

Form input blocks

Form inputs are individual blocks (not a single form block). Each input type emits its current value through the step’s inputValues map and is included in the step responses on completion.
Block TypeRenders
input_textSingle-line text input
input_textareaMulti-line text input
input_numberNumeric input
input_emailEmail input with validation
input_phonePhone-number input
input_urlURL input
input_passwordSecure text with visibility toggle
input_dateDate picker
input_timeTime picker
input_datetimeCombined date + time picker
input_selectDropdown / stacked / grid picker
input_sliderNumeric slider
input_range_sliderDual-handle range slider
input_toggleOn/off switch
input_stepperIncrement/decrement counter
input_segmentedSegmented control
input_ratingStar rating input
input_chipsTag-style multi-select chips
input_colorColor picker or preset swatches
input_image_pickerPhoto picker (gallery + camera)
input_signatureFreehand signature drawing pad
input_locationAutocomplete location search
Blocks are configured entirely in the Console. The SDK renders them automatically — no TypeScript code is needed unless you want to register a host-provided custom view (see Custom View Registration below).

Button Actions

button and social_login blocks emit a structured payload when tapped. The action value on the button (configured in the Console) determines what shape the payload takes and lets you route to the right backend call (sign in, register, request OTP, etc.). If the user has not completed required form fields on the step, the native SDK shows an inline validation toast and does not emit the action — same gate as the standard next button.

Flow control

ActionWhat the SDK does
nextAdvances to the next step. Payload includes all collected input values.
skipSkips the step (subject to skip_enabled config).
linkOpens action_value (a URL) in the in-app browser. Step does not advance.
permissionTriggers a platform permission prompt and advances on grant or denial.

Authentication entry

When the user taps a button with one of these actions, the SDK calls your onBeforeStepAdvance handler with the payload below. Show a “Signing in…” spinner by returning { type: 'block', message: '...' } while async auth runs, then return { type: 'proceed' } / { type: 'proceedWithData', data: {...} } when the backend responds.
If you do not implement AppDNAOnboardingDelegate, the SDK stays on the step and logs a warning rather than advancing past credential collection. Auth actions require a host to perform the actual side effect (sign in, register, send OTP, …) — silently advancing would leak credentials into the response bundle without ever calling your backend.
ActionTypical button labelPayload to your handler
login”Sign In”{ action: 'login', email, password }
register”Sign Up” / “Create Account”{ action: 'register', email, password, ...optional fields }
social_login”Continue with Google” / Apple / etc.{ action: 'social_login', provider, ...inputs }
email_login”Continue with Email” (the email provider button inside a social_login block){ action: 'email_login', email, password, ...inputs } — added in v1.0.60. Email button still emits social_login in parallel one final release for back-compat; will be removed in v1.1.0. Migrate to email_login for clean handler routing.
reset_password”Forgot password?”{ action: 'reset_password', email }
magic_link”Email me a sign-in link”{ action: 'magic_link', email }
request_otp”Send code”{ action: 'request_otp', [channel], [recipient] } — see channel resolution below
verify_otp”Verify”{ action: 'verify_otp', otp_code, [channel], [recipient] }
verify_email”Confirm email”{ action: 'verify_email', verification_code }
resend_verification”Resend code”{ action: 'resend_verification' }
enable_biometric”Enable Face ID / Fingerprint”{ action: 'enable_biometric' } — your handler calls react-native-biometrics or expo-local-authentication

Account lifecycle

ActionTypical button labelPayload to your handler
logout”Sign out”{ action: 'logout' }
change_password”Change password”{ action: 'change_password', current_password, new_password }
set_new_password”Set new password”{ action: 'set_new_password', new_password, [reset_token] } (post-reset / first-time setup)
delete_account”Delete account”{ action: 'delete_account', [confirmation_text] }
update_profile”Save”{ action: 'update_profile', ...all step inputs }

request_otp channel resolution

request_otp and verify_otp include a channel field so your handler knows which delivery method to call back on. The SDK resolves it in this order:
  1. Explicit — set the button’s action_value to "sms", "email", "whatsapp", or "voice" in the Console (case-insensitive).
  2. Auto-detect — if the step has exactly one phone-typed input (input_phone) → channel: "sms"; exactly one email-typed input (input_email) → channel: "email".
  3. Omitted — if the step has both an email AND a phone input (or neither), the channel key is absent from the payload. Check with stepData?.channel as string | undefined and fail explicitly if null / undefined rather than guessing. Set action_value on the button to disambiguate.
The matching input field’s value is included as recipient so you don’t have to look it up from the step responses.

Handling auth actions

import { AppDNA, StepAdvanceResult, AppDNAOnboardingDelegate } from '@appdna/react-native-sdk';
import ReactNativeBiometrics from 'react-native-biometrics';

const biometrics = new ReactNativeBiometrics();

const onboardingHandler: AppDNAOnboardingDelegate = {
  onOnboardingStarted: () => {},
  onOnboardingStepChanged: () => {},
  onOnboardingCompleted: () => {},
  onOnboardingDismissed: () => {},

  async onBeforeStepAdvance(flowId, fromStepId, stepIndex, stepType, responses, stepData) {
    const action = stepData?.action as string | undefined;
    if (!action) return { type: 'proceed' };

    switch (action) {
      case 'login': {
        const email = (stepData?.email as string) ?? '';
        const password = (stepData?.password as string) ?? '';
        try {
          const user = await authClient.signIn({ email, password });
          return { type: 'proceedWithData', data: { user_id: user.id } };
        } catch {
          return { type: 'block', message: 'Invalid email or password.' };
        }
      }

      case 'register': {
        const email = (stepData?.email as string) ?? '';
        const password = (stepData?.password as string) ?? '';
        try {
          const user = await authClient.register({ email, password });
          return { type: 'proceedWithData', data: { user_id: user.id } };
        } catch {
          return { type: 'block', message: "Couldn't create account — try a different email." };
        }
      }

      case 'reset_password': {
        const email = (stepData?.email as string) ?? '';
        try {
          await authClient.requestPasswordReset({ email });
        } catch {
          // Stay quiet; we always show the same confirmation banner
        }
        // Stay on step; show inline confirmation banner.
        return {
          type: 'stay',
          message: `If an account exists for ${email}, we've emailed a reset link.`,
        };
      }

      case 'request_otp': {
        // Fail explicitly when the SDK couldn't resolve the channel
        // (step has both phone and email inputs, or neither). Set the
        // button's action_value in the Console to disambiguate.
        const channel = stepData?.channel as string | undefined;
        const recipient = stepData?.recipient as string | undefined;
        if (!channel || !recipient) {
          return {
            type: 'block',
            message: "Couldn't determine OTP delivery channel — please contact support.",
          };
        }
        try {
          await otpClient.send({ channel, to: recipient });
          return { type: 'proceed' };
        } catch {
          return { type: 'block', message: "Couldn't send code — please retry." };
        }
      }

      case 'verify_otp': {
        const code = stepData?.otp_code as string | undefined;
        const recipient = stepData?.recipient as string | undefined;
        if (!code || !recipient) {
          return { type: 'block', message: 'Missing code or recipient.' };
        }
        try {
          await otpClient.verify({ code, recipient });
          return { type: 'proceed' };
        } catch {
          return { type: 'block', message: "Code didn't match — try again." };
        }
      }

      case 'enable_biometric': {
        const { available } = await biometrics.isSensorAvailable();
        if (!available) return { type: 'proceed' };
        const { success } = await biometrics.simplePrompt({
          promptMessage: 'Enable Face ID / Fingerprint for faster sign-in',
        });
        return { type: 'proceedWithData', data: { biometric_enabled: success } };
      }

      default:
        return { type: 'proceed' };
    }
  },
};

AppDNA.onboarding.setDelegate(onboardingHandler);
Existing next, skip, link, permission, and social_login button actions are unchanged. Host apps that already implement AppDNAOnboardingDelegate keep working without changes — the default onBeforeStepAdvance returns { type: 'proceed' }, so flows that don’t switch on stepData?.action advance normally. Host apps that have NO delegate set will see the no-delegate warning above when an auth action fires; for those flows, either implement the delegate or keep using next as the button action.

Custom View Registration

Register your own React Native components to be rendered inside onboarding steps wherever a custom_view block appears:
import { AppDNA } from '@appdna/react-native-sdk';
import { TermsAcceptanceView, MyCustomView } from './components';

AppDNA.registerCustomView('my_view', (config) => <MyCustomView config={config} />);

// Register before presenting the onboarding flow
AppDNA.registerCustomView(
  'terms_acceptance',
  (config) => (
    <TermsAcceptanceView
      config={config}
      onAccept={(accepted) => {
        // Handle acceptance
      }}
    />
  ),
);
The id must match the custom view identifier configured in the Console for the custom_view block. The returned React element is composited into the layout slot the Console authored.

Block Styling

Every content block supports a block_style design token that controls appearance properties such as padding, margin, background color, corner radius, border, shadow, and opacity. Block styles are configured in the Console and applied automatically by the native renderers.

Visibility Conditions

Blocks can be shown or hidden based on user responses, bindings, or device attributes. Visibility conditions are configured per-block in the Console using rules like answer_equals, binding_not_empty, platform_is, and locale_matches. The native SDK evaluates conditions client-side before rendering each block.

Entrance Animations

Each block supports an entrance animation that plays when the block first appears. Animations are configured per-block in the Console. Supported animation types include fade, slide_up, slide_down, slide_left, slide_right, scale, flip, and bounce. You can configure duration, delay, and easing curve.

Form Steps

The form step type provides native input fields for collecting structured user data. Each form can contain multiple fields with validation, conditional visibility, and custom configuration. Fields are rendered using native iOS controls (UIKit / SwiftUI) and native Android controls (Jetpack Compose).

Supported Input Types

TypeDescriptionExample Use Case
textSingle-line text inputName, username
textareaMulti-line text inputBio, notes
numberNumeric input with stepperAge, quantity
emailEmail input with validationEmail address
phonePhone number inputContact number
dateDate pickerBirthday, start date
timeTime pickerPreferred time
datetimeCombined date and time pickerAppointment scheduling
selectDropdown or scrollable pickerCountry, category
sliderNumeric slider with min/maxBudget, intensity level
toggleOn/off switchOpt-in preferences
stepperIncrement/decrement counterNumber of items
segmentedSegmented control for few optionsGender, frequency
passwordSecure text input with visibility togglePassword, PIN
ratingStar rating inputSatisfaction, preference
range_sliderDual-handle range sliderPrice range, age range
image_pickerPhoto picker from library or cameraProfile photo, document
colorColor picker or preset swatchesTheme preference, branding
urlURL input with validationWebsite, portfolio link
chipsTag-style multi-select chipsInterests, skills
signatureFreehand signature drawing padAgreement, consent
locationAutocomplete location searchCity, address, country

Field Validation

Form fields support built-in and custom validation:
  • Required fields — marked in the Console, the SDK prevents advancing until filled.
  • Regex patterns — custom validation (e.g., ^[A-Z]{2}\d{4}$ for a code format).
  • Min/max values — for number, slider, and stepper fields.
  • Max length — for text and textarea fields.

Conditional Fields

Fields can depend on other fields using depends_on rules. For example, a “Company name” field can appear only when the user selects “Employed” in a previous field. Supported operators: equals, not_equals, contains, not_empty, empty, gt, lt, is_set.

Form Responses

Form field values are included in the responses map passed to onOnboardingCompleted, keyed by step ID. Each step’s value is a map of field ID to field value.
onOnboardingCompleted(flowId: string, responses: Record<string, unknown>) {
  const formData = responses['profile_step'] as Record<string, unknown> | undefined;
  if (formData) {
    const name = formData['full_name'] as string | undefined;
    const age = formData['age'] as number | undefined;
    const email = formData['email'] as string | undefined;
    // Use collected data to personalize the experience
  }
}

Location Fields

The location field type provides an autocomplete search input that returns structured location data including city, state, country, coordinates, and timezone. When the user starts typing (e.g., “New York”), the native SDK debounces the input (300ms), calls the AppDNA geocoding proxy, and displays a dropdown of suggestions. The user selects a result and the SDK stores the complete structured data.

Location Data Structure

Each location selection contains:
FieldTypeExample
formatted_addressstring"New York, New York, United States"
citystring"New York"
statestring"New York"
state_codestring"NY"
countrystring"United States"
country_codestring"US"
latitudenumber40.7128
longitudenumber-74.0060
timezonestring"America/New_York"
timezone_offsetnumber-300 (minutes from UTC)
postal_codestring?null

Accessing Location Data

Location data is returned as a nested object in the responses payload to onOnboardingCompleted:
onOnboardingCompleted(flowId: string, responses: Record<string, unknown>) {
  const formData = responses['location_step'] as Record<string, unknown> | undefined;
  const location = formData?.user_location as Record<string, unknown> | undefined;
  if (location) {
    console.log('City:', location.city);                  // "New York"
    console.log('Country:', location.country_code);       // "US"
    console.log('Timezone:', location.timezone);          // "America/New_York"
    console.log('Coords:', location.latitude, location.longitude);
  }
}

Template Engine

Location data is accessible in dynamic content templates rendered by the native SDK:
Welcome from {{onboarding.location_step.user_location.city}}!
Your timezone: {{onboarding.location_step.user_location.timezone}}

Configuration in Console

In the onboarding flow editor, add a location field to a form step and configure:
OptionDescriptionDefault
Location TypeFilter results: city, address, region, countrycity
Bias CountryISO country code to prioritize results (e.g., US)None
LanguageLanguage for results (e.g., en, fr)en
Min CharactersCharacters required before search triggers2
Location autocomplete uses a server-side proxy — no third-party SDK is added to your app binary. The geocoding provider (Mapbox by default) can be configured in Settings > Geocoding.

Interactive Chat Steps

The interactive_chat step type renders a conversational UI that forwards each user message to your webhook and renders the reply. This is how you integrate your own LLM, agent, or rule-based backend into an onboarding flow.

How it works

  1. User types a message in the chat step.
  2. The native SDK POSTs the conversation payload to the webhook_url configured in the Console (with any custom headers you set).
  3. Your server responds with JSON in the schema below.
  4. The native SDK renders the AI reply, quick-reply buttons, media, etc.
Turn limits, typing indicators, ratings, and quick-reply routing are handled by the native SDK — your webhook only needs to return the next reply. The conversation never round-trips through JavaScript.

Request payload (SDK → your webhook)

{
  "event": "chat_message",
  "flow_id": "onboarding_v1",
  "step_id": "chat_intro",
  "app_id": "app_abc123",
  "user_id": "user_xyz",
  "conversation": {
    "turn": 2,
    "user_message": "I've been having vivid dreams lately",
    "max_turns": 5,
    "remaining_turns": 3,
    "messages": [
      { "role": "ai",   "content": "Hi, what brings you here?",       "id": "msg_a0", "timestamp": "2026-04-15T10:00:00Z" },
      { "role": "user", "content": "I've been having vivid dreams...", "id": "msg_u1", "timestamp": "2026-04-15T10:00:12Z" }
    ]
  },
  "context": { "threadId": "thread_UXJWTKpSvBRWpGEhHLXqnA6O" },
  "rating": null
}
Your headers are forwarded verbatim. For bearer-token APIs, set the header value to Bearer YOUR_TOKEN (the SDK does not add the Bearer prefix automatically). The context object is opaque session state you control — whatever you returned in data on a previous turn gets echoed back here on every subsequent call. This is how you integrate threaded AI backends (OpenAI Assistants, hosted LLMs with threadId, etc.) without replaying the full history on every request. context is absent on turn 1 (your server hasn’t written anything yet) and accumulates across turns; later values overwrite earlier ones per-key (last-write-wins). See Threaded backends below for the full pattern.

Response schema (your webhook → SDK)

{
  "action": "reply",
  "messages": [
    { "content": "Tell me more about the most recent one." }
  ]
}
Only messages[].content is required — every other field is optional. The SDK reads messages[] to render chat bubbles, so if you omit it or return a different shape, the reply won’t render.
Returning a response like {"reply": "..."} without wrapping it in the messages array will silently fail — the SDK decodes unknown fields as null and shows nothing. Always wrap your reply in messages[{ "content": "..." }].

Full response schema

FieldTypeDescription
action"reply" | "reply_and_complete" | "error"Default "reply". "reply_and_complete" renders the reply then ends the chat.
messagesArray<{ content, media?, delay_ms? }>Messages to render. Empty array = silent turn.
messages[].contentstringThe bubble text.
messages[].media{ type, url, alt_text? }Optional media — type is "image", "lottie", or "link".
messages[].delay_msnumberDelay before the bubble appears (typing simulation).
quick_repliesArray<{ id, text }>Buttons rendered under the latest AI reply. Tapping one sends it as the user’s next message.
force_completebooleanEnd the conversation after this reply.
completion_messagestringFinal message shown when the chat completes.
dataobjectFree-form JSON. Two uses: (1) stored in the onboarding response bundle as webhook_data for analytics / later steps; (2) echoed back to your webhook in context on every subsequent turn — use this for threadId, session_id, or any opaque server state you need to round-trip.
Unknown fields in your response are ignored, so you can also include your own server-side state at the top level (it just won’t round-trip — use data for anything you want back).

Minimal working example

Node.js (Express):
app.post('/chat', async (req, res) => {
  const { conversation } = req.body;
  const userMessage = conversation.user_message;

  const replyText = await myLLM.complete(userMessage);

  res.json({
    action: 'reply',
    messages: [{ content: replyText }],
  });
});

Threaded backends: round-tripping session state

If your AI service uses a thread/session handle (OpenAI Assistants API, a hosted LLM with conversation memory, or any third-party AI proxy that mints a session id), do not replay the full conversation history on every turn — latency and cost grow linearly with conversation length. Instead, return your session handle in data once; the SDK accumulates it and echoes it back in context on every subsequent turn. The wire contract in plain English:
  • You write to context by returning fields under data in any response.
  • You read from context off req.body.context on every request after the first.
  • Keys are merged across turns; later values overwrite earlier ones per-key.
  • The SDK never inspects or mutates context — it’s opaque to your server. You choose the keys and the shape.
Example: integrating a threaded AI service keyed by threadId. If your backend rotates the thread (expiry, failure, migration), just return the new id in data — the SDK overwrites the cached value and uses it on the next turn automatically.
app.post('/chat', async (req, res) => {
  const { conversation, context } = req.body;

  // Resume if we have a threadId; create a fresh one otherwise
  let threadId = context?.threadId;
  if (!threadId) {
    threadId = await aiService.createThread();
  }

  let replyText;
  try {
    replyText = await aiService.sendMessage(threadId, conversation.user_message);
  } catch (err) {
    // Thread expired or invalid — start fresh and retry once
    if (err.code === 'thread_expired') {
      threadId = await aiService.createThread();
      replyText = await aiService.sendMessage(threadId, conversation.user_message);
    } else {
      throw err;
    }
  }

  res.json({
    action: 'reply',
    messages: [{ content: replyText }],
    data: { threadId },   // round-tripped to every subsequent call
  });
});
No Redis, no per-user cache, no history replay. The context round-trip IS your session storage.
The same pattern works for anything opaque you need per-conversation: API rate-limit bucket IDs, A/B cohort labels, tool-call state, reasoning scratchpads — whatever your AI backend needs to resume, stash it in data and read it from context on the next turn.

Console configuration

In the onboarding flow editor, add a step of type Interactive Chat, then set:
FieldPurpose
Webhook URLWhere the SDK POSTs each turn
HeadersCustom headers — for bearer auth, the full value must be Bearer <token>
Timeout (ms)How long to wait before showing error_text
Retry countNumber of retries on transient network errors
Error textShown in-chat when the webhook fails or times out
PersonaName, role, avatar — rendered in the header
Max turnsCaps the conversation length
Auto-messagesPre-scripted AI messages keyed by turn number
Quick repliesStatic buttons always shown
Turn actionsTrigger rating prompts / inject messages at specific turns
StyleColors, fonts, bubble styling

Auto-tracked events

EventWhen
chat_message_sentUser sends a message
chat_message_receivedWebhook reply is received (even if empty)
chat_webhook_errorWebhook throws, times out, returns a non-2xx status, or returns invalid JSON. Includes http_status and a truncated response_body when the error is a non-2xx response, so integration bugs surface immediately instead of silently showing an empty reply.
chat_rating_submittedUser submits a rating in a turn action
The standard onboarding step events (onboarding_step_viewed, onboarding_step_completed) also fire for chat steps.

Async Step Hooks

The onboarding delegate supports two async hooks that let you intercept step transitions for server-side validation, dynamic content loading, or custom routing logic.

onBeforeStepAdvance

Called before the SDK advances to the next step. Return a StepAdvanceResult to control what happens next:
async onBeforeStepAdvance(
  flowId: string,
  fromStepId: string,
  stepIndex: number,
  stepType: string,
  responses: Record<string, unknown>,
  stepData: Record<string, unknown> | null,
): Promise<StepAdvanceResult> {
  // Example: validate a referral code with your backend
  if (stepType === 'form') {
    const stepResponses = responses[fromStepId] as Record<string, unknown> | undefined;
    const code = stepResponses?.referral_code as string | undefined;
    if (code) {
      const isValid = await validateReferralCode(code);
      if (!isValid) {
        return { type: 'block', message: 'Invalid referral code. Please try again.' };
      }
      return { type: 'proceedWithData', data: { referral_validated: true } };
    }
  }
  return { type: 'proceed' };
}

StepAdvanceResult

CaseDescription
{ type: 'proceed' }Continue to the next step normally
{ type: 'proceedWithData', data }Continue and merge additional data into the session
{ type: 'block', message }Block advancement and show an error message (red banner) to the user
{ type: 'stay', message? }Stay on the current step without advancing and without showing an error. Pass a non-null message to render a green success banner; omit message to stay silent and let your code handle UI
{ type: 'skipTo', stepId }Skip to a specific step by ID
{ type: 'skipToWithData', stepId, data }Skip to a specific step and merge additional data
Use stay for “I handled it; keep the user here”
Use { type: 'stay', ... } when your hook handled the user’s action — for example, sent a password-reset email — and you want the user to remain on the same step. This is distinct from block (error styling) and from proceed (advances).
case 'reset_password': {
  const email = ((responses[fromStepId] as Record<string, unknown>)?.email as string) ?? '';
  try {
    await myAuthBackend.sendPasswordReset({ to: email });
    return { type: 'stay', message: `Reset email sent to ${email}` };
  } catch {
    return { type: 'block', message: "Couldn't send reset email. Please try again." };
  }
}

onBeforeStepRender

Called before a step is rendered. Return a StepConfigOverride to dynamically modify the step’s content:
async onBeforeStepRender(
  flowId: string,
  stepId: string,
  stepIndex: number,
  stepType: string,
  responses: Record<string, unknown>,
): Promise<StepConfigOverride | null> {
  // Pre-fill form fields based on user data
  if (stepId === 'profile_step') {
    return {
      fieldDefaults: {
        email: currentUser.email,
        name: currentUser.displayName,
      },
      title: `Welcome back, ${currentUser.firstName}!`,
    };
  }
  return null;
}
Available fields on StepConfigOverride: fieldDefaults, title, subtitle, ctaText, layoutOverrides. The SDK merges these into the step config before rendering.
Both hooks are async — the SDK shows a loading indicator while waiting for your response. If either hook throws an error or times out, the SDK proceeds normally.

Row Direction and Distribution

The row content block supports configurable direction and distribution, set in the Console:
PropertyOptionsDescription
directionhorizontal, verticalAxis along which child blocks are arranged
distributionequal, fill, start, center, end, space_between, space_aroundHow child blocks are distributed within the row
// Example: Two buttons side by side, equally spaced
Row (direction: horizontal, distribution: equal)
  ├── Button "Skip"
  └── Button "Continue"

Button Gradients

Buttons support gradient backgrounds configured in the Console:
PropertyTypeDescription
gradient_colorsstring[]Array of hex color stops (e.g., ["#FF6B6B", "#4ECDC4"])
gradient_directionstringhorizontal, vertical, diagonal_tl_br, diagonal_tr_bl
Gradients override the solid background_color when set.

Select Display Styles

input_select form-input blocks (and select form fields) support three display styles:
StyleDescription
dropdownNative dropdown picker. Best for long option lists (5+ items).
stackedVertically stacked option buttons. Default style.
gridGrid layout with 2 or 3 columns. Good for visual options with icons.
The display style is configured per select field in the Console under field_config.display_style. Question-step option lists always render in a fixed grid layout; the styles above apply specifically to input_select / select.

Progress Bar Custom Colors

The progress_bar content block supports custom color configuration:
PropertyTypeDescription
fill_colorstringHex color for the filled portion
track_colorstringHex color for the unfilled track
fill_gradientstring[]Gradient color stops for the fill (overrides fill_color)
corner_radiusnumberCorner radius of the progress bar
heightnumberHeight of the progress bar in logical pixels
Colors are configured per block in the Console. When not set, the SDK uses the app’s primary theme color.

Per-Step Progress Visibility

The progress indicator can be hidden on specific steps while still counting them in the total progress. This is useful for splash screens, permission prompts, or transition steps where the progress bar would be distracting. Configure hide_progress per step in the Console under Step Design > Logic. When enabled, the progress bar is hidden on that step but the step still contributes to the overall progress calculation (e.g., step 3 of 5 still advances progress to 60%).
PropertyTypeDefaultDescription
hide_progressbooleanfalseHides the progress indicator on this step only
This is a per-step override of the flow-level show_progress setting. If show_progress is false at the flow level, the progress bar is hidden on all steps regardless of hide_progress.

Conditional Branching

Onboarding flows support conditional branching — the next step can change based on the user’s answer. Branching is configured entirely in the Console. Example:
Step 1: "What's your fitness goal?"
  → "Lose weight"  → Step 2a: Weight loss program
  → "Build muscle" → Step 2b: Muscle building program
  → "Stay active"  → Step 2c: General fitness
To configure branching:
  1. In the Console, open your onboarding flow (Onboarding > Flows).
  2. On a question step, click Add branching rule.
  3. Map each answer option to a target step.
  4. The SDK handles routing automatically — no TypeScript code needed.
All answers (including branched paths) are returned in the responses map in onOnboardingCompleted.

Auto-Tracked Events

The native SDK automatically tracks the following onboarding-related events:
EventTriggered When
onboarding_flow_startedAn onboarding flow begins
onboarding_step_viewedA step is displayed to the user
onboarding_step_completedA user completes a step (e.g., answers a question)
onboarding_step_skippedA user skips a step
onboarding_flow_completedThe user completes the entire flow
onboarding_flow_dismissedThe user dismisses the flow before completing
Each event includes the flowId, stepId, and stepIndex where applicable.

Configuration in Console

Onboarding flows are managed in the AppDNA Console:
  1. Navigate to Onboarding > Flows.
  2. Create a new flow or edit an existing one.
  3. Add steps (welcome, question, value_prop, form, interactive_chat, custom) and configure their content.
  4. Set targeting rules to control which users see the flow.
  5. Publish the flow to make it available to the SDK via the config bundle.
Flows must be published in the Console before they appear in the SDK. Draft flows are not delivered to client devices.

Full Example

import React, { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { AppDNA, AppDNAOnboardingDelegate } from '@appdna/react-native-sdk';

export function OnboardingScreen() {
  const navigation = useNavigation();

  useEffect(() => {
    const delegate: AppDNAOnboardingDelegate = {
      onOnboardingStarted(flowId) {
        console.log(`Starting flow: ${flowId}`);
      },

      onOnboardingStepChanged(flowId, stepId, stepIndex, totalSteps) {
        // Track progress
      },

      onOnboardingCompleted(flowId, responses) {
        // Personalize based on responses
        const goal = responses['fitness_goal'] as string | undefined;
        if (goal) {
          AppDNA.identify('current_user_id', { fitness_goal: goal });
        }
        navigation.replace('Home');
      },

      onOnboardingDismissed(flowId, atStep) {
        navigation.replace('Home');
      },
    };

    AppDNA.onboarding.setDelegate(delegate);
    showOnboardingIfNeeded();

    return () => {
      AppDNA.onboarding.setDelegate(null);
    };
  }, [navigation]);

  async function showOnboardingIfNeeded() {
    try {
      await AppDNA.onboarding.present('main_flow', { source: 'app_launch' });
    } catch (e) {
      // No active flow or config not loaded yet
      navigation.replace('Home');
    }
  }

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <ActivityIndicator />
    </View>
  );
}

Paywall trigger nodes — entitlement-aware skip + restore behavior

Onboarding flows can include paywall_trigger graph nodes that present a paywall in the middle of a flow and route the user via per-outcome targets (on_success_target, on_fail_target, on_dismiss_target). Triggers are configured in the AppDNA Console and the SDK presents them automatically when the flow reaches the node.

Entitlement-aware skip (default ON)

Each paywall_trigger node carries a skip_if_subscribed flag (default true). When the native SDK reaches the trigger, it checks the user’s entitlement state first:
  • User has an active subscription → SDK skips presentation, emits an onboarding_paywall_skip event with reason=user_already_subscribed, and routes the flow via the configured skip target (see below).
  • User has no active subscription → SDK presents the paywall as before; outcome routing is unchanged.
This stops returning users from seeing a paywall they already paid for. Already-subscribed users move past the trigger silently. If you sell upsell paywalls (a higher tier to a user who already owns the base plan), open the trigger in the onboarding flow editor and uncheck Skip if user already has an active subscription. The trigger then always presents.
☑ Skip if user already has an active subscription   ← default for new + legacy nodes
Existing flows authored before this version require no migration: nodes without the field default to true at read time. From React Native you can check the same entitlement state used by the gate via:
const hasSub = await AppDNA.billing.hasActiveSubscription();

Subscribed-user skip routing — picking the target

The console editor exposes a dedicated dropdown directly under the Skip if user already has an active subscription toggle: When skipped (user is already subscribed), go to. Four options:
OptionStored valueWhen to pick
Complete flow (recommended)complete_flowSubscribed user bypasses the paywall and everything downstream — flow ends, host’s onOnboardingCompleted fires. Default for new paywall_trigger nodes.
Use post-purchase target'' (empty)Falls back to whatever on_success_target is set to — useful when the post-purchase target and the skip target should be identical.
Follow downstream edgecontinueSubscribed user continues through the rest of the graph (rare — used for chain-of-paywalls flows that intentionally show more upsells after the gate).
Go to: <node>the node IDRoute directly to a specific step or end node.
The native SDKs resolve the target with this chain at runtime: on_subscribed_skip_target → falls back to on_success_target → falls back to “follow downstream edge”. The fallback means flows authored without an explicit skip target that worked around this by setting on_success_target = complete_flow continue to behave identically.

Warning callout in the editor

If you open a paywall_trigger that has Skip if user already has an active subscription on but the resolved skip target is “Follow downstream edge” and the next node in the graph is another paywall_trigger, the editor displays an inline warning:
Heads up: this paywall is set to skip for subscribed users, but the next step is another paywall trigger — subscribed users will still see it. Set “When skipped” to “Complete flow” to bypass the rest of the paywall chain.
One click on Complete flow in the dropdown clears the warning and fixes the chain. This is the most common authoring trap with multi-paywall flows (main paywall → winback paywall → end); the warning surfaces it before publish.

Restore now auto-dismisses + routes to success

Tapping Restore Subscription on a paywall now:
  1. Fires onPaywallRestoreCompleted(paywallId, restoredProductIds) on your AppDNAPaywallDelegate.
  2. Auto-dismisses the native paywall surface when restoredProductIds is non-empty (i.e., the restore actually found entitlements).
  3. Routes the onboarding flow via the trigger’s on_success_target — same path as a real purchase.
When the restore call succeeds but finds NO entitlements (restoredProductIds: []) the paywall stays up so the user can attempt a fresh purchase or close manually.
const paywallDelegate: AppDNAPaywallDelegate = {
  // ... other 8 AppDNAPaywallDelegate methods

  onPaywallRestoreCompleted(paywallId, restoredProductIds) {
    if (restoredProductIds.length > 0) {
      console.log(`Restored ${restoredProductIds.length} product(s) — SDK is dismissing the paywall.`);
    } else {
      console.log('Restore returned no entitlements — paywall stays open.');
    }
  },
};

AppDNA.paywall.setDelegate(paywallDelegate);

Hosts that handle their own dismiss

If your onPaywallRestoreCompleted handler already dismisses the paywall itself (e.g., to show a “Restored — tap continue when ready” overlay), the SDK’s internal dispatchedDismiss guard ensures the auto-dismiss never fires twice — the first caller wins. onPaywallDismissed fires exactly once regardless of who triggered the dismissal.
const paywallDelegate: AppDNAPaywallDelegate = {
  // ... other 8 methods

  onPaywallRestoreCompleted(paywallId, productIds) {
    // Your custom UI handling — show overlay, etc.
    // No need to call dismiss() yourself; the SDK auto-dismiss path
    // handles dismissal once your synchronous body returns. If you
    // want to suppress the SDK auto-dismiss (e.g., to show your own
    // overlay), call AppDNA.paywall.dismiss() inside this method first
    // — the dispatchedDismiss guard prevents a second auto-dismiss.
  },
};
Synchronous host-side dismisses inside the delegate body always win the race.

AppDNA.identify(userId) refreshes the entitlement cache

When you call AppDNA.identify(...) after the user signs in, the native SDK silently refreshes its entitlement cache in the background. The next paywall_trigger gate then reflects the identified user’s StoreKit / Play Billing subscriptions instead of the prior anonymous user’s empty entitlement state.
await AppDNA.identify('user_abc', { plan: 'free' });
// Native entitlement cache is refreshed in the background.
// The next paywall_trigger node will see the updated state.
If your app completes auth out-of-band (SSO callback, OAuth web flow) and you need the entitlement cache refreshed manually, call AppDNA.identify(...) again with the same user ID — the cache refresh is idempotent.

Migration risk callout

Behavioral change: paywall_trigger nodes now skip presentation for subscribed users by default. If your app intentionally relies on the legacy “always present” behavior (e.g., a confirmation / legal-acceptance screen rendered as a paywall), open the trigger node in the console editor and uncheck Skip if user already has an active subscription.
Web-side entitlements (Stripe subscriptions, manually-granted entitlements via web tooling) are NOT consulted by this gate today — only StoreKit (iOS) and Play Billing (Android) entitlements are. If your premium users have only web-side subscriptions, leave Skip if user already has an active subscription unchecked, or implement a host-side gate via AppDNAOnboardingDelegate.onBeforeStepRender that consults your own backend.