Skip to main content
Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · Flutter SDK 1.0.3+
The AppDNA Flutter 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:
await AppDNA.paywall.present(
  "premium_paywall",
  context: PaywallContext(placement: "settings"),
);
Or via the static convenience method:
await AppDNA.presentPaywall("premium_paywall");
The paywall ID must match the ID configured in the AppDNA Console under Monetization > Paywalls.

Present by Placement

Pass a placement string in PaywallContext to tag the presentation for analytics and audience targeting:
await AppDNA.paywall.present(
  "premium_paywall",
  context: PaywallContext(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():
const placementToPaywall = {
  "feature_gate": "premium_paywall_a",
  "settings":     "premium_paywall_b",
  "onboarding":   "trial_paywall",
};

Future<void> showForPlacement(String placement) {
  final id = placementToPaywall[placement] ?? "premium_paywall_a";
  return AppDNA.paywall.present(
    id,
    context: PaywallContext(placement: 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

final paywall = AppDNA.paywall;
MethodSignatureDescription
presentFuture<void> present(String id, {PaywallContext? context})Present a paywall by ID
setDelegatevoid setDelegate(AppDNAPaywallDelegate? delegate)Register a delegate for paywall callbacks (pass null to clear)

PaywallContext

final context = PaywallContext(
  placement: "settings",
  experiment: "paywall_redesign",
  variant: "variant_a",
  customData: {"feature_name": "advanced_analytics"},
);
PropertyTypeDescription
placementString?Where the paywall is triggered (e.g., “settings”, “feature_gate”)
experimentString?Experiment ID forwarded to paywall analytics for slicing
variantString?Variant ID forwarded to paywall analytics for slicing
customDataMap<String, dynamic>?Optional key-value bag forwarded to paywall analytics events
The placement value is included in all paywall analytics events, allowing you to measure conversion by placement in the Console.

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(...).
abstract class AppDNAPaywallDelegate {
  void onPaywallPresented(String paywallId);
  void onPaywallAction(String paywallId, String action);
  void onPaywallPurchaseStarted(String paywallId, String productId);
  void onPaywallPurchaseCompleted(
    String paywallId, String productId, Map<String, dynamic> transaction);
  void onPaywallPurchaseFailed(String paywallId, Object error);
  void onPaywallRestoreStarted(String paywallId);
  void onPaywallRestoreCompleted(
    String paywallId, List<String> restoredProductIds);
  void onPaywallRestoreFailed(String paywallId, Object error);
  void onPaywallDismissed(String paywallId);

  /// Called when the user submits a promo code. Return `true` to apply it.
  Future<bool> onPromoCodeSubmit(String paywallId, String code) async => false;

  /// Called after a successful purchase when the paywall section carries a
  /// `post_purchase_deep_link` URL configured in the Console.
  void onPostPurchaseDeepLink(String paywallId, String url) {}

  /// 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).
  void onPostPurchaseNextStep(String paywallId) {}
}
MethodDescription
onPaywallPresentedPaywall is displayed to the user
onPaywallActionUser interacts with a paywall element (CTA, plan switch, link)
onPaywallPurchaseStartedUser initiates a purchase
onPaywallPurchaseCompletedPurchase succeeded; transaction carries store-verified IDs
onPaywallPurchaseFailedPurchase failed or was cancelled; paywall stays visible for retry
onPaywallRestoreStartedUser tapped Restore; the SDK began the restore flow
onPaywallRestoreCompletedRestore succeeded; restoredProductIds is empty if nothing was on file
onPaywallRestoreFailedRestore failed (network, store error, no purchases); paywall stays visible
onPaywallDismissedPaywall was closed for any reason
onPromoCodeSubmitUser submitted a promo code; return true to apply, false to reject
onPostPurchaseDeepLinkFires after a purchase if the Console section carries a post_purchase_deep_link
onPostPurchaseNextStepFires after a purchase if the Console section carries a post_purchase_next_step

Example Implementation

class PaywallHandler implements AppDNAPaywallDelegate {
  @override
  void onPaywallPresented(String paywallId) {}

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

  @override
  void onPaywallPurchaseStarted(String paywallId, String productId) {}

  @override
  void onPaywallPurchaseCompleted(
    String paywallId, String productId, Map<String, dynamic> transaction) {
    debugPrint("Purchased $productId — txn: ${transaction['transactionId']}");
    // Paywall auto-dismisses on successful purchase
  }

  @override
  void onPaywallPurchaseFailed(String paywallId, Object error) {
    debugPrint("Purchase failed: $error");
  }

  @override
  void onPaywallRestoreStarted(String paywallId) {
    // Show a "Restoring purchases…" toast
  }

  @override
  void onPaywallRestoreCompleted(
    String paywallId, List<String> restoredProductIds) {
    if (restoredProductIds.isEmpty) {
      // Tell the user there were no purchases to restore
    } else {
      // Refresh entitlements / unlock premium features
    }
  }

  @override
  void onPaywallRestoreFailed(String paywallId, Object error) {
    // Surface an error toast — paywall stays visible so user can retry
  }

  @override
  void onPaywallDismissed(String paywallId) {}
}

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.
For richer transaction details (status, expiry, store), call AppDNA.billing.getEntitlements() after a successful restore — it returns List<Entitlement> with the full verified state. The paywall delegate intentionally returns just product IDs to keep the callback lightweight.

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 Dart with:
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 Flutter widgets rather than rendered by the SDK), drive the purchase yourself with the billing module:
final products = await AppDNA.billing.getProducts(["premium_monthly"]);
// ... render your custom Flutter widgets ...
final result = await AppDNA.billing.purchase("premium_monthly");
if (result.status == "purchased") {
  // Unlock premium features
}
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:
ValueDescription
cta_tappedThe main call-to-action button was tapped
feature_selectedA feature item was selected
plan_changedThe user switched between plan options
link_tappedA link (e.g., terms, privacy policy) was tapped
customA custom action defined in the paywall config

DismissReason

The native SDK reports a dismiss reason in analytics events; on Flutter you receive the lifecycle via onPaywallDismissed. Reasons include:
ValueDescription
purchasedDismissed after a successful purchase
restore_successDismissed after a restore returned at least one product (routes to success path)
dismissedDismissed by the user via a close button
tappedOutsideDismissed by tapping outside the paywall
programmaticDismissed programmatically by your code

Paywall Sections

Paywalls configured in the Console support the following content sections:
SectionDescription
HeaderTitle, subtitle, and optional hero image
Features listFeature highlights with icons and descriptions
Plan selectionSelectable plan options (e.g., monthly, annual)
CTA buttonPrimary purchase button with dynamic price text
Social proofTestimonials, ratings, or user counts
GuaranteeMoney-back guarantee or free trial messaging
ImageFull-width or sized image with optional corner radius
SpacerConfigurable vertical spacing between sections
TestimonialQuote with author name, role, and optional avatar
CountdownUrgency countdown timer with configurable expiry behavior
LegalTerms of service, privacy policy, subscription terms
DividerHorizontal separator line with optional label
Sticky footerFixed bottom bar with CTA and price summary
CardRounded card container for grouping related content
CarouselHorizontally scrollable content cards
TimelineStep-by-step vertical timeline (e.g., trial-to-paid)
Icon gridGrid layout of icons with labels
Comparison tableSide-by-side plan comparison (free vs. premium)
Promo inputText field for entering promotional codes
ToggleOn/off toggle for add-on options
Reviews carouselHorizontally scrollable user reviews with ratings

Plan Display Styles

Configured per paywall as a lowercase plan_display_style string.
StyleDescription
vertical_stackStacked rows with plan name, price, and badge. Default.
horizontal_cardsSide-by-side cards, one per plan. Best for 2-3 plans.
radio_listRadio-button list with plan details per row.
accordionExpandable/collapsible plan sections with full details.
carouselHorizontally scrollable plan cards with snap behavior.
pill_selectorRounded pill buttons arranged horizontally.
segmented_toggleTwo-/three-option segmented control.
toggle_cardsCard-style toggles, one selectable card per plan.
minimal_chipsCompact chip-style picker, no card surround.
feature_comparisonSide-by-side plan comparison with checkmarked feature rows.
featured_highlightOne plan visually promoted (larger, badge, glow).
tier_blocksLarge 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

LayoutDescription
"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 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:
@override
Future<bool> onPromoCodeSubmit(String paywallId, String code) async {
  // Validate the code against your backend and return true (apply) or false (reject)
  final isValid = await myPromoBackend.validate(code);
  return isValid;
}
The SDK awaits your Future<bool>, 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

EventTriggered When
paywall_viewPaywall is presented to the user
paywall_closePaywall is dismissed
purchase_startedUser initiates a purchase from the paywall
purchase_restoredA successful restore returns at least one product
purchase_restore_failedThe restore flow fails or returns no products
Purchase completion and failure events are tracked by the billing module. See Billing for the full list.

Error Handling

  • Purchase errors arrive via onPaywallPurchaseFailed(paywallId, error). The error is delivered as a generic Object — typically a Map<String, dynamic> with message and type keys, or a string when the underlying StoreKit / Play Billing error has no structured shape. Inspect or cast as needed. The paywall stays visible so the user can retry.
  • Restore errors arrive via onPaywallRestoreFailed(paywallId, error). 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 AppDNAOptions(billingProvider: ...) configured 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.
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.

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

import 'package:appdna_sdk/appdna_sdk.dart';
import 'package:flutter/material.dart';

class PremiumGate extends StatefulWidget {
  const PremiumGate({super.key});
  @override
  State<PremiumGate> createState() => _PremiumGateState();
}

class _PremiumGateState extends State<PremiumGate>
    implements AppDNAPaywallDelegate {
  bool _isPremium = false;

  @override
  void initState() {
    super.initState();
    AppDNA.paywall.setDelegate(this);
    _checkAccess();
  }

  Future<void> _checkAccess() async {
    final active = await AppDNA.billing.hasActiveSubscription();
    setState(() => _isPremium = active);
  }

  Future<void> _showPaywall() => AppDNA.paywall.present(
        "premium_paywall",
        context: PaywallContext(placement: "feature_gate"),
      );

  // MARK: - AppDNAPaywallDelegate

  @override
  void onPaywallPresented(String paywallId) {}
  @override
  void onPaywallAction(String paywallId, String action) {}
  @override
  void onPaywallPurchaseStarted(String paywallId, String productId) {}

  @override
  void onPaywallPurchaseCompleted(
      String paywallId, String productId, Map<String, dynamic> transaction) {
    setState(() => _isPremium = true);
  }

  @override
  void onPaywallPurchaseFailed(String paywallId, Object error) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text("Purchase failed: $error")),
    );
  }

  @override
  void onPaywallRestoreStarted(String paywallId) {}

  @override
  void onPaywallRestoreCompleted(
      String paywallId, List<String> restoredProductIds) {
    if (restoredProductIds.isNotEmpty) setState(() => _isPremium = true);
  }

  @override
  void onPaywallRestoreFailed(String paywallId, Object error) {}
  @override
  void onPaywallDismissed(String paywallId) {}

  @override
  Widget build(BuildContext context) {
    if (_isPremium) {
      return const Scaffold(body: Center(child: Text("Premium content")));
    }
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: _showPaywall,
          child: const Text("Unlock Premium"),
        ),
      ),
    );
  }
}
The paywall module integrates with the billing module for purchase handling. Ensure your billingProvider is configured correctly in AppDNAOptions. See the Billing guide for details.

Next Steps