Skip to main content
Supported on: iOS SDK 1.0.61+
You can A/B test this paywall with no extra code — create an experiment on it in the Console and the SDK serves the assigned variant automatically. See Servable Surface Experiments.
The AppDNA paywall module lets you present server-driven paywalls configured in the AppDNA Console. Paywalls are rendered natively and include built-in purchase handling through the billing module.

Present a Paywall

Present a paywall by its identifier:

Present by Placement

Present a paywall by placement — the SDK uses audience rules configured in the Console to determine which paywall to show:

Module Access

Access the paywall module directly:

Module Methods

PaywallContext

Provide context about where and why the paywall is being shown:
The placement value is included in all paywall analytics events, allowing you to measure conversion by placement in the Console.

AppDNAPaywallDelegate

Implement the delegate protocol 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 present_paywall step outcome) — register once with AppDNA.paywall.setDelegate(...) and handle every paywall presentation through the same handler.

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 restoreText and restorePosition config in the Console). When the user taps it, the SDK runs the restore flow and fires the lifecycle:
  1. When the SDK can restore (.storeKit2, or RevenueCat / Adapty linked into AppDNA itself), it fires onPaywallRestoreStarted(paywallId:) first. A refused restore (step 2), and a Restore tap while billing is not configured (before configure or during shutdown()), fire no Started callback.
  2. On .storeKit2 the SDK reads the previously-purchased products from StoreKit 2 (Transaction.currentEntitlements, verified on the device); it does not call AppStore.sync(). The restored product ids come from the device alone; each restored transaction is then sent to the AppDNA server (/billing/verify) in the background, which the restore does not wait for. Under billingProvider: .revenueCat, .adapty (provider not linked) or .none the SDK does not restore: onPaywallRestoreFailed fires at once with providerNotAvailable, without Started — restore through your provider. While billing is not configured it fires with an unknown “not configured” error instead.
  3. On success: onPaywallRestoreCompleted(paywallId:, productIds:) fires with the list of restored product identifiers, and the SDK emits a purchase_restored analytics event automatically.
  4. On failure: onPaywallRestoreFailed(paywallId:, error:) fires and the SDK emits a purchase_restore_failed event. On .storeKit2 the restore itself does not fail, so the failures are the refusals above, or an error thrown by a linked RevenueCat / Adapty SDK. A Swift CancellationError is not reported.
A restore that finds nothing is a success: onPaywallRestoreCompleted fires with an empty list and purchase_restored is tracked with restored_count: 0. 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, fails, or the host set skipNextAutoDismissOnRestore.
providerNotAvailable is not only a misconfiguration. It is also the intended answer when billingProvider is .revenueCat or .adapty and the provider owns purchases (the provider SDK is not linked into AppDNA — every published build), and with .none:
  • Purchase tap: no store sheet opens and onPaywallPurchaseStarted is not fired. The SDK emits one purchase_failed (error_type: "providerNotAvailable"), calls onPaywallPurchaseFailed(paywallId:error:errorType:productId:) with errorType == "providerNotAvailable" and the tapped plan’s productId, and runs the paywall’s failure route. Start the purchase with your provider from that callback — see Billing → Who owns the transaction.
  • Restore tap: onPaywallRestoreFailed fires immediately with BillingError.providerNotAvailable (read it with billingErrorType(error)), onPaywallRestoreStarted is not fired, and purchase_restore_failed carries error_type: "providerNotAvailable". Restore through your provider.
A plan or Restore tap that reaches the SDK while billing is not configured (before AppDNA.configure has set billing up, or while AppDNA.shutdown() is tearing it down) is not providerNotAvailable: onPaywallPurchaseFailed fires with errorType == "unknown" (and onPaywallRestoreFailed with an error that billingErrorType maps to unknown), with the message “AppDNA SDK not configured yet — call configure() first”. No store sheet opens, no Started callback fires, and one purchase_failed / purchase_restore_failed with error_type: "unknown" and reason: "not_configured" is tracked. Do not start a provider purchase for it.
To restore purchases outside of a paywall, call AppDNA.billing.restorePurchases() directly. It is async throws and returns [String] — the product IDs of the restored transactions. See Restore can fail for the errors per billing setup. The paywall delegate returns the same product-ID list.

PaywallAction

The PaywallAction enum represents user interactions within the paywall:

Auto-Tracked Events

DismissReason

The DismissReason enum indicates how the paywall was closed. The wire-format string (sent in paywall_close analytics) is shown in the right column — same value across iOS, Android, Flutter, and React Native.
The restore_success wire value is emitted directly by the SDK after a successful restore that auto-dismisses the paywall. Use it for analytics segmentation (purchase vs restore funnels); the DismissReason enum itself stays at the 4 user-initiated cases above.

Paywall Sections

Paywalls configured in the Console support the following content sections:

Plan Display Styles

The plan selection section supports 12 display styles, configured in the Console. Each style controls how subscription plans are laid out and visually differentiated:

Card & Badge Customization

Plan cards support per-plan styling configured in the Console:
  • Badge text and color — e.g., “Best Value”, “Most Popular” with custom background
  • Card border and shadow — highlight the selected or recommended plan
  • Save percentage — automatically calculated and displayed on annual plans
  • Trial label — shows “7-day free trial” or custom trial messaging
  • Card background — solid color, gradient, or image per plan card
  • Corner radius — configurable per card

Layout Types

Rich Media in Paywalls

Paywall sections support rich media content configured in the Console:
  • Lottie animations — animated hero images, feature illustrations, or backgrounds
  • Rive animations — interactive state-machine-driven animations
  • Video — inline video in header or feature sections
  • Haptic feedback — triggered on plan selection and CTA taps
  • Particle effects — confetti or celebration effects on purchase completion
  • Per-section styling — background colors, gradients, images, borders, shadows, and corner radius
See the Rich Media guide for full details on supported formats and configuration.

Promo Code Handling

When a paywall includes a Promo input section, implement the promo code delegate method to validate codes against your backend:

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

Paywalls are created and managed in the AppDNA Console:
  1. Navigate to Monetization > Paywalls.
  2. Create a new paywall or edit an existing one.
  3. Add sections (header, features, plans, CTA, social proof, guarantee).
  4. Link App Store products to the plan options.
  5. Optionally assign the paywall to an experiment for A/B testing.
  6. Publish the paywall to make it available via the config bundle.
Ensure the product identifiers used in your paywall match the products configured in App Store Connect. Mismatched identifiers will cause purchase failures.

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.