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:
Or via the static convenience method:
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:

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():
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

PaywallContext

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(...).

Example Implementation

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 — only when the SDK can restore (storeKit2, billing configured). Under a provider that owns restoring, or before billing is ready, the tap goes straight to onPaywallRestoreFailed (see Error Handling).
  2. The SDK reads the previously-purchased products from the native store — StoreKit 2 on iOS (read on the device; each restored transaction is then sent to AppDNA’s /billing/verify in the background, which the restore does not wait for), BillingClient.queryPurchasesAsync(SUBS) + (INAPP) on Android, where it also sends the owned purchases to AppDNA’s /billing/restore.
  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.
A successful restore that returns at least one product auto-dismisses the paywall by default — the user is now entitled. The paywall stays visible when the restore returns no products or fails, or when you opted out with AppDNA.skipNextAutoDismissOnRestore(true). The flag is one-shot: the SDK clears it after the next restore, whatever its outcome. The native side decides whether to dismiss right after it forwards onPaywallRestoreCompleted, before your Dart callback runs, so set the flag before the user taps Restore (for example, when you present the paywall).

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:
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:
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:

DismissReason

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

Paywall Sections

Paywalls configured in the Console support the following content sections:

Plan Display Styles

Configured per paywall as a lowercase plan_display_style string.

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

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:
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

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, errorType, productId). The error is delivered as a generic Object that crosses the bridge opaquely — branch on errorType instead (userCancelled | productNotFound | verificationFailed | networkError | serverError | pending | providerNotAvailable | unknown). productId is null only when no product was ever selected. The paywall stays visible so the user can retry.
  • Restore errors arrive via onPaywallRestoreFailed(paywallId, error). Common causes: network failure, store outage, or a provider that cannot restore. A restore that finds nothing is not an error — it completes with an empty list. The paywall stays visible.
providerNotAvailable is not only a misconfiguration. It is also the intended answer when billingProvider is revenueCat or adapty (the provider owns purchases, so AppDNA never buys or restores) or when that provider is not linked, and the answer under none:
  • A plan tap calls onPaywallPurchaseFailed with errorType: 'providerNotAvailable' and the tapped productId (plus one purchase_failed event); no store sheet opens and onPaywallPurchaseStarted is not fired. Start the purchase with your provider from that callback — see Billing → Who owns the store transaction.
  • A restore tap calls onPaywallRestoreFailed immediately with a providerNotAvailable error, and onPaywallRestoreStarted is not fired (on both platforms). Restore through your provider.
A purchase or restore tap before billing is ready — before configure() has set billing up, or after AppDNA.shutdown() — is not providerNotAvailable: onPaywallPurchaseFailed fires with errorType unknown and onPaywallRestoreFailed with the message “AppDNA SDK not configured yet — call configure() first”, and no Started callback fires. Do not start a provider purchase for it; let the user retry once the SDK is configured.

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.

Template Variables

Any paywall text an author writes in the Console — a section title or subtitle, a feature row, a CTA label — can reference live values: {{var | fallback}} supplies text to use when the reference resolves to nothing.
Two differences from onboarding are worth knowing, because the syntax looks identical:
  • On a paywall, {{responses.…}} is addressed through the step (responses.step_id.field_id). The two-part form that works in an onboarding flow does not resolve here — use {{input.field_id}}, which searches every step for that field.
  • A paywall reference that cannot be resolved and has no fallback renders as an empty string, so a mistyped variable leaves a gap rather than showing raw {{...}} to a paying user. Onboarding renders the literal instead, which makes the typo visible while authoring.
Always preview a paywall with a variable in it before publishing: an empty gap and a correctly resolved short value look similar in the editor.

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

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