> ## 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.

# React Native Paywalls

> Present server-driven paywalls with purchase handling

<Info>
  **Supported on:** iOS SDK `1.0.70+` · Android SDK `1.0.42+` · React Native SDK `1.0.7+` (New Architecture only)
</Info>

The AppDNA React Native SDK presents server-driven paywalls configured in the AppDNA Console. Paywalls are rendered natively (SwiftUI on iOS, Jetpack Compose on Android) and include built-in purchase handling through the billing module.

## Present a Paywall

Present a paywall by its identifier:

```typescript theme={null}
import { AppDNA } from '@appdna-ai/react-native-sdk';

await AppDNA.paywall.present('premium_paywall', { placement: 'settings' });
```

Or via the static convenience method:

```typescript theme={null}
await AppDNA.presentPaywall('premium_paywall');
```

<Info>
  The paywall ID must match the ID configured in the AppDNA Console under **Monetization > Paywalls**.
</Info>

## Present by Placement

Pass a placement string in `PaywallContext` to tag the presentation for analytics and audience targeting:

```typescript theme={null}
await AppDNA.paywall.present('premium_paywall', { placement: 'feature_gate' });
```

### Routing by placement in app code

To pick the paywall ID based on the placement at runtime, keep a small placement-to-ID map in your app and pass both values to `present()`:

```typescript theme={null}
const placementToPaywall: Record<string, string> = {
  feature_gate: 'premium_paywall_a',
  settings:     'premium_paywall_b',
  onboarding:   'trial_paywall',
};

export async function showForPlacement(placement: string): Promise<boolean> {
  const id = placementToPaywall[placement] ?? 'premium_paywall_a';
  // `present()` resolves FALSE when nothing was shown — an unknown paywall id, an SDK that is not
  // configured yet, or no screen to present from. Check it: a silent no-op looks exactly like a
  // paywall a user dismissed instantly.
  return AppDNA.paywall.present(id, { placement });
}
```

The placement value is included in every analytics event the paywall emits, so you can measure conversion by placement in the Console regardless of how you map placements to IDs in app code.

## Module Access

```typescript theme={null}
const paywall = AppDNA.paywall;
```

| Method               | Signature                                                                           | Description                                                                                                           |
| -------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `present`            | `present(id: string, context?: PaywallContext): Promise<boolean>`                   | Present a paywall by ID. Resolves `false` when nothing was shown                                                      |
| `presentByPlacement` | `presentByPlacement(placement: string, context?: PaywallContext): Promise<boolean>` | Present whichever paywall the Console assigned to a placement. Resolves `false` when nothing was shown                |
| `setDelegate`        | `setDelegate(delegate: Partial<AppDNAPaywallDelegate> \| null): void`               | Register a delegate for paywall callbacks. Pass `null` to clear it — registering again replaces the previous delegate |

## PaywallContext

```typescript theme={null}
const context: PaywallContext = {
  placement: 'settings',
  experiment: 'paywall_redesign',
  variant: 'variant_a',
  customData: { feature_name: 'advanced_analytics' },
};
```

| Property     | Type                       | Description                                                        |
| ------------ | -------------------------- | ------------------------------------------------------------------ |
| `placement`  | `string?`                  | Where the paywall is triggered (e.g., "settings", "feature\_gate") |
| `experiment` | `string?`                  | Experiment ID forwarded to paywall analytics for slicing           |
| `variant`    | `string?`                  | Variant ID forwarded to paywall analytics for slicing              |
| `customData` | `Record<string, unknown>?` | Optional key-value bag forwarded to paywall analytics events       |

<Info>
  The `placement` value is included in all paywall analytics events, allowing you to measure conversion by placement in the Console.
</Info>

## AppDNAPaywallDelegate

Implement the delegate to receive paywall lifecycle callbacks. **The same delegate fires for paywalls launched standalone via `AppDNA.paywall.present(...)` AND for paywalls launched from inside an onboarding flow** (via the `paywall_trigger` graph node) — register once with `AppDNA.paywall.setDelegate(...)`.

```typescript theme={null}
interface AppDNAPaywallDelegate {
  onPaywallPresented(paywallId: string): void;
  onPaywallAction(paywallId: string, action: string): void;
  onPaywallPurchaseStarted(paywallId: string, productId: string): void;
  onPaywallPurchaseCompleted(
    paywallId: string,
    productId: string,
    transaction: Record<string, unknown>,
  ): void;
  /**
   * `error` is the platform's error MESSAGE — a string, not an `Error` object, and it has no
   * `.message`. Branch on `errorType` instead: userCancelled | productNotFound |
   * verificationFailed | networkError | serverError | pending | providerNotAvailable | unknown.
   * `productId` is null only when no product had been selected.
   */
  onPaywallPurchaseFailed(
    paywallId: string,
    error: unknown,
    errorType: string,
    productId: string | null,
  ): void;
  onPaywallRestoreStarted(paywallId: string): void;
  onPaywallRestoreCompleted(paywallId: string, restoredProductIds: string[]): void;
  /** `error` is a message string, same as above. */
  onPaywallRestoreFailed(paywallId: string, error: unknown): void;
  onPaywallDismissed(paywallId: string): void;

  /**
   * Called after a successful purchase when the paywall section carries a
   * `post_purchase_deep_link` URL configured in the Console.
   */
  onPostPurchaseDeepLink(paywallId: string, url: string): void;

  /**
   * Called after a successful purchase when the paywall section carries a
   * `post_purchase_next_step` configured in the Console (e.g., navigate to
   * the success screen, continue an onboarding flow).
   */
  onPostPurchaseNextStep(paywallId: string): void;

  /**
   * VETO — the only one that defaults to REJECT. Validate a user-entered promo code:
   * return `true` to apply it, `false` to reject. Omit it (or time out) and every code is refused.
   */
  onPromoCodeSubmit?(paywallId: string, code: string): Promise<boolean>;
}
```

| Method                       | Description                                                                               |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `onPaywallPresented`         | Paywall is displayed to the user                                                          |
| `onPaywallAction`            | User interacts with a paywall element (CTA, plan switch, link)                            |
| `onPaywallPurchaseStarted`   | User initiates a purchase                                                                 |
| `onPaywallPurchaseCompleted` | Purchase succeeded; `transaction` carries store-verified IDs                              |
| `onPaywallPurchaseFailed`    | Purchase failed or was cancelled; `errorType` says which. Paywall stays visible for retry |
| `onPaywallRestoreStarted`    | User tapped Restore; the SDK began the restore flow                                       |
| `onPaywallRestoreCompleted`  | Restore succeeded; `restoredProductIds` is empty if nothing was on file                   |
| `onPaywallRestoreFailed`     | Restore failed (network, store error, no purchases); paywall stays visible                |
| `onPaywallDismissed`         | Paywall was closed for any reason                                                         |
| `onPromoCodeSubmit`          | User submitted a promo code; return `true` to apply, `false` to reject                    |
| `onPostPurchaseDeepLink`     | Fires after a purchase if the Console section carries a `post_purchase_deep_link`         |
| `onPostPurchaseNextStep`     | Fires after a purchase if the Console section carries a `post_purchase_next_step`         |

### Example Implementation

```typescript theme={null}
const paywallHandler: AppDNAPaywallDelegate = {
  onPaywallPresented(paywallId) {},

  onPaywallAction(paywallId, action) {
    // action is one of: cta_tapped, feature_selected, plan_changed, link_tapped, custom
  },

  onPaywallPurchaseStarted(paywallId, productId) {},

  onPaywallPurchaseCompleted(paywallId, productId, transaction) {
    console.log(`Purchased ${productId} — txn:`, transaction.transactionId);
    // Paywall auto-dismisses on successful purchase
  },

  onPaywallPurchaseFailed(paywallId, error, errorType, productId) {
    if (errorType === 'userCancelled') return; // not an error worth reporting
    console.log(`Purchase failed (${errorType}) for ${productId ?? 'no product'}:`, String(error));
  },

  onPaywallRestoreStarted(paywallId) {
    // Show a "Restoring purchases…" toast
  },

  onPaywallRestoreCompleted(paywallId, restoredProductIds) {
    if (restoredProductIds.length === 0) {
      // Tell the user there were no purchases to restore
    } else {
      // Refresh entitlements / unlock premium features
    }
  },

  onPaywallRestoreFailed(paywallId, error) {
    // Surface an error toast — paywall stays visible so user can retry
    console.log('Restore failed:', String(error));
  },

  onPostPurchaseDeepLink(paywallId, url) {
    // The Console asked us to open a URL after the purchase
  },

  onPostPurchaseNextStep(paywallId) {
    // The Console asked us to continue to the next onboarding step
  },

  onPaywallDismissed(paywallId) {},
};

AppDNA.paywall.setDelegate(paywallHandler);
```

## Restore Purchases

The Restore button is rendered alongside the **CTA section** (above or below the main subscribe button, controlled by the section's `restore_text` and `restore_position` config in the Console). When the user taps it, the SDK runs:

1. `onPaywallRestoreStarted(paywallId)` fires immediately.
2. The SDK calls the native store API — StoreKit 2 on iOS, `BillingClient.queryPurchasesAsync(SUBS)` + `(INAPP)` on Android — and queries your verification endpoint for previously-purchased products.
3. On success: `onPaywallRestoreCompleted(paywallId, restoredProductIds)` fires, and the SDK emits a `purchase_restored` event automatically.
4. On failure: `onPaywallRestoreFailed(paywallId, error)` fires and the SDK emits `purchase_restore_failed`.

The paywall stays visible after both completion and failure so the user can decide whether to purchase fresh.

### Auto-dismiss + onboarding routing

When the paywall is presented from inside an onboarding flow via a `paywall_trigger` graph node, a successful restore that returns at least one product automatically:

1. Dismisses the paywall.
2. Refreshes the in-memory entitlement cache.
3. Routes the onboarding flow via the trigger's `on_success_target` — same path as a real purchase.

A restore that returns zero products keeps the paywall visible; the flow proceeds via `on_dismiss_target` only if the user then closes without buying.

<Info>
  For richer transaction details (status, expiry, store), call `AppDNA.billing.getEntitlements()` after a successful restore — it returns `Entitlement[]` with the full verified state. The paywall delegate intentionally returns just product IDs to keep the callback lightweight.
</Info>

## Entitlement-aware Paywall Triggers

Onboarding flows can include `paywall_trigger` graph nodes that present a paywall mid-flow. Each trigger carries a `skip_if_subscribed` flag (default `true` for upsells). When the native SDK reaches the trigger, it checks `AppDNA.billing.hasActiveSubscription()` first:

* **Subscribed + `skip_if_subscribed: true`** → paywall is skipped. Flow routes via `on_subscribed_skip_target` (falls back to `on_success_target`, then to the downstream edge). No `paywall_view` event is emitted.
* **Subscribed + `skip_if_subscribed: false`** → paywall is shown anyway. Use this for cross-sell / lifetime upgrade paywalls where existing subscribers are the target audience.
* **Not subscribed** → paywall is presented normally.

The check is synchronous and reads the SDK's in-memory entitlement cache, so it adds no latency.

### Refreshing the entitlement cache

When you call `AppDNA.identify(...)` after sign-in, the native SDK silently refreshes the entitlement cache in the background. The next `paywall_trigger` gate then reflects the identified user's store subscriptions instead of the prior anonymous device's empty state.

If your app completes auth out-of-band (SSO callback, OAuth web flow) without calling `identify`, force a refresh from TypeScript with:

```typescript theme={null}
await AppDNA.billing.getEntitlements();
```

This round-trips the native store and updates the in-memory cache. The next `paywall_trigger` will see the fresh state.

## Custom Paywall Presentation

If you want a fully custom paywall UI (built in React Native components rather than rendered by the SDK), drive the purchase yourself with the billing module:

```typescript theme={null}
const products = await AppDNA.billing.getProducts(['premium_monthly']);
// ... render your custom React Native components ...
try {
  const transaction = await AppDNA.billing.purchase('premium_monthly');
  // Resolved means purchased. Unlock premium features.
  console.log(transaction.transactionId);
} catch (error) {
  // Cancellation, a pending (ask-to-buy) purchase, and store errors all REJECT.
}
```

You lose server-driven configuration with this approach — sections, A/B tests, audience rules, copy edits, and paywall analytics all flow through the Console only when you use `AppDNA.paywall.present(...)`. Reserve custom presentation for cases where the rendered paywall cannot meet a hard product requirement.

## PaywallAction Values

The `action` argument passed to `onPaywallAction` is one of:

| Value              | Description                                     |
| ------------------ | ----------------------------------------------- |
| `cta_tapped`       | The main call-to-action button was tapped       |
| `feature_selected` | A feature item was selected                     |
| `plan_changed`     | The user switched between plan options          |
| `link_tapped`      | A link (e.g., terms, privacy policy) was tapped |
| `custom`           | A custom action defined in the paywall config   |

## DismissReason

The native SDK reports a dismiss reason in analytics events; on React Native you receive the lifecycle via `onPaywallDismissed`. Reasons include:

| Value             | Description                                                                      |
| ----------------- | -------------------------------------------------------------------------------- |
| `purchased`       | Dismissed after a successful purchase                                            |
| `restore_success` | Dismissed after a restore returned at least one product (routes to success path) |
| `dismissed`       | Dismissed by the user via a close button                                         |
| `tappedOutside`   | Dismissed by tapping outside the paywall                                         |
| `programmatic`    | Dismissed programmatically by your code                                          |

## Paywall Sections

Paywalls configured in the Console support the following content sections:

| Section          | Description                                               |
| ---------------- | --------------------------------------------------------- |
| Header           | Title, subtitle, and optional hero image                  |
| Features list    | Feature highlights with icons and descriptions            |
| Plan selection   | Selectable plan options (e.g., monthly, annual)           |
| CTA button       | Primary purchase button with dynamic price text           |
| Social proof     | Testimonials, ratings, or user counts                     |
| Guarantee        | Money-back guarantee or free trial messaging              |
| Image            | Full-width or sized image with optional corner radius     |
| Spacer           | Configurable vertical spacing between sections            |
| Testimonial      | Quote with author name, role, and optional avatar         |
| Countdown        | Urgency countdown timer with configurable expiry behavior |
| Legal            | Terms of service, privacy policy, subscription terms      |
| Divider          | Horizontal separator line with optional label             |
| Sticky footer    | Fixed bottom bar with CTA and price summary               |
| Card             | Rounded card container for grouping related content       |
| Carousel         | Horizontally scrollable content cards                     |
| Timeline         | Step-by-step vertical timeline (e.g., trial-to-paid)      |
| Icon grid        | Grid layout of icons with labels                          |
| Comparison table | Side-by-side plan comparison (free vs. premium)           |
| Promo input      | Text field for entering promotional codes                 |
| Toggle           | On/off toggle for add-on options                          |
| Reviews carousel | Horizontally scrollable user reviews with ratings         |

### Plan Display Styles

Configured per paywall as a lowercase `plan_display_style` string.

| Style                | Description                                                 |
| -------------------- | ----------------------------------------------------------- |
| `vertical_stack`     | Stacked rows with plan name, price, and badge. Default.     |
| `horizontal_cards`   | Side-by-side cards, one per plan. Best for 2-3 plans.       |
| `radio_list`         | Radio-button list with plan details per row.                |
| `accordion`          | Expandable/collapsible plan sections with full details.     |
| `carousel`           | Horizontally scrollable plan cards with snap behavior.      |
| `pill_selector`      | Rounded pill buttons arranged horizontally.                 |
| `segmented_toggle`   | Two-/three-option segmented control.                        |
| `toggle_cards`       | Card-style toggles, one selectable card per plan.           |
| `minimal_chips`      | Compact chip-style picker, no card surround.                |
| `feature_comparison` | Side-by-side plan comparison with checkmarked feature rows. |
| `featured_highlight` | One plan visually promoted (larger, badge, glow).           |
| `tier_blocks`        | Large stacked blocks with feature lists per tier.           |

#### Card & Badge Customization

Plan cards support per-plan styling configured in the Console: badge text and color (e.g., "Best Value"), card border and shadow, auto-calculated save percentage on annual plans, trial label, card background (solid / gradient / image), and per-card corner radius.

### Layout Types

| Layout       | Description                                              |
| ------------ | -------------------------------------------------------- |
| `"stack"`    | Vertical stack layout (sections arranged top to bottom)  |
| `"grid"`     | Grid layout (for feature comparison or multi-plan views) |
| `"carousel"` | Horizontally scrollable section layout                   |

### Rich Media in Paywalls

Paywall sections support: Lottie animations, Rive animations, inline video, haptic feedback on plan selection / CTA, particle effects (confetti) on purchase, and per-section styling (background colors, gradients, images, borders, shadows, corner radius). See the [Rich Media](/sdks/react-native/rich-media) guide for full details.

## Promo Code Handling

Paywalls that include a `Promo input` section validate codes server-side against the rules configured in the Console (under **Monetization > Promo Codes**). When the user submits a code:

1. The SDK posts it to the Console validation endpoint along with the paywall ID, the active product set, and the user's identity.
2. On a valid code, the matching discount is applied to the displayed plans and an updated price is rendered.
3. On an invalid or expired code, the SDK surfaces the rejection inline inside the section.

If you want to validate codes against your own backend instead of the Console (e.g., partner-driven referral codes, region-locked promos), implement `onPromoCodeSubmit` on the paywall delegate:

```typescript theme={null}
declare const myPromoBackend: { validate(code: string): Promise<boolean> };

const delegate: Pick<AppDNAPaywallDelegate, 'onPromoCodeSubmit'> = {
  async onPromoCodeSubmit(paywallId, code) {
    // Validate the code against your backend and return true (apply) or false (reject)
    return myPromoBackend.validate(code);
  },
};
```

<Warning>
  `onPromoCodeSubmit` is the one veto whose default is to **refuse**. If you don't implement it, or your
  Promise takes longer than `vetoTimeout`, the code is rejected — never accepted.
</Warning>

The SDK awaits your `Promise<boolean>`, displays a "Validating…" spinner inside the promo section, and on `true` re-renders the paywall with the discounted plan prices the Console computed.

## Auto-Tracked Events

| Event                     | Triggered When                                    |
| ------------------------- | ------------------------------------------------- |
| `paywall_view`            | Paywall is presented to the user                  |
| `paywall_close`           | Paywall is dismissed                              |
| `purchase_started`        | User initiates a purchase from the paywall        |
| `purchase_restored`       | A successful restore returns at least one product |
| `purchase_restore_failed` | The restore flow fails or returns no products     |

<Warning>
  Purchase completion and failure events are tracked by the billing module. See [Billing](/sdks/react-native/billing) for the full list.
</Warning>

## Error Handling

* **Purchase errors** arrive via `onPaywallPurchaseFailed(paywallId, error, errorType, productId)`. `error` is the platform's error **message string** (`localizedDescription` on iOS, `message` on Android) — it is not a JS `Error` and has no `.message` property; `String(error)` is the whole of it. Branch on `errorType`, which is the stable reason code: `userCancelled`, `productNotFound`, `verificationFailed`, `networkError`, `serverError`, `pending`, `providerNotAvailable`, `unknown`. `productId` names the plan that failed, or is `null` if none was selected. The paywall stays visible so the user can retry.
* **Restore errors** arrive via `onPaywallRestoreFailed(paywallId, error)` — again a message string. Common causes: no previous purchases, network failure, store outage. The paywall stays visible.

If the SDK was configured without a billing provider, `onPaywallRestoreFailed` fires immediately with a `providerNotAvailable` error and `onPaywallRestoreStarted` is **not** fired. This only affects misconfigured hosts; production apps with `billingProvider` configured in `AppDNAOptions` never hit this branch.

## Testing in Sandbox

* **iOS** — Sign in to a sandbox Apple ID in **Settings > App Store > Sandbox Account**. Purchases route through the StoreKit sandbox; receipts validate against Apple's sandbox endpoint. A StoreKit configuration file in Xcode also works for simulator runs.
* **Android** — Add the tester's Google account as a license tester in **Google Play Console > Setup > License testing** and upload to an internal testing track. Sandbox purchases never charge the card and renewals run on accelerated timelines.

In sandbox, the full lifecycle fires identically — purchase, restore, and analytics events all flow — so you can wire the delegate and walk the flow end-to-end before shipping.

<Warning>
  Ensure product identifiers in your paywall match the products configured in App Store Connect and Google Play Console. Mismatched identifiers cause purchase failures with an obscure store error at runtime.
</Warning>

## Configuration in Console

1. Navigate to **Monetization > Paywalls**.
2. Create or edit a paywall.
3. Add sections (header, features, plans, CTA, social proof, guarantee).
4. Link App Store and Google Play products to the plan options.
5. Optionally assign the paywall to an experiment for A/B testing.
6. Publish to make it available via the config bundle.

## Full Example

```tsx theme={null}
import React, { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';
import { AppDNA, AppDNAPaywallDelegate } from '@appdna-ai/react-native-sdk';

export function PremiumGate() {
  const [isPremium, setIsPremium] = useState(false);

  useEffect(() => {
    const delegate: AppDNAPaywallDelegate = {
      onPaywallPresented: () => {},
      onPaywallAction: () => {},
      onPaywallPurchaseStarted: () => {},

      onPaywallPurchaseCompleted(paywallId, productId, transaction) {
        setIsPremium(true);
      },

      onPaywallPurchaseFailed(paywallId, error, errorType, productId) {
        console.log('Purchase failed:', errorType, String(error), productId);
      },

      onPaywallRestoreStarted: () => {},

      onPaywallRestoreCompleted(paywallId, restoredProductIds) {
        if (restoredProductIds.length > 0) {
          setIsPremium(true);
        }
      },

      onPaywallRestoreFailed: () => {},
      onPostPurchaseDeepLink: () => {},
      onPostPurchaseNextStep: () => {},
      onPaywallDismissed: () => {},
    };

    // A delegate is process-global. Registering a new one REPLACES the previous one, and passing
    // `null` clears it. Prefer registering once at app startup over doing it from a component.
    AppDNA.paywall.setDelegate(delegate);

    (async () => {
      const active = await AppDNA.billing.hasActiveSubscription();
      setIsPremium(active);
    })();
  }, []);

  async function showPaywall() {
    await AppDNA.paywall.present('premium_paywall', { placement: 'feature_gate' });
  }

  if (isPremium) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text>Premium content</Text>
      </View>
    );
  }

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Button title="Unlock Premium" onPress={showPaywall} />
    </View>
  );
}
```

<Note>
  The paywall module integrates with the billing module for purchase handling. Ensure your `billingProvider` is configured correctly in `AppDNAOptions`. See the [Billing](/sdks/react-native/billing) guide for details.
</Note>

## Next Steps

* Set up [Billing](/sdks/react-native/billing) for detailed purchase handling
* Configure [Onboarding](/sdks/react-native/onboarding) flows that transition into paywalls
* Learn about [Offline Support](/sdks/react-native/offline) for paywall config caching
