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:
AppDNA.presentPaywall(
id: "premium_paywall",
from: viewController,
context: PaywallContext(placement: "settings"),
delegate: self
)
Present by Placement
Present a paywall by placement — the SDK uses audience rules configured in the Console to determine which paywall to show:
// Present paywall by placement (audience rules determine which paywall to show)
AppDNA.presentPaywall(placement: "premium_upgrade", from: viewController)
Module Access
Access the paywall module directly:
let paywall = AppDNA.paywall
Module Methods
| Method | Signature | Description |
|---|
present | present(_: String, from: UIViewController, context: PaywallContext?) | Present a paywall by ID |
setDelegate | setDelegate(_ delegate: AppDNAPaywallDelegate?) | Set a delegate for paywall callbacks |
PaywallContext
Provide context about where and why the paywall is being shown:
let context = PaywallContext(
placement: "settings",
experiment: "paywall_test",
variant: "b"
)
| Property | Type | Description |
|---|
placement | String | Where the paywall is triggered (e.g., “settings”, “onboarding”, “feature_gate”) |
experiment | String? | Experiment identifier, if showing as part of an A/B test |
variant | String? | Variant identifier within the experiment |
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.
protocol AppDNAPaywallDelegate {
func onPaywallPresented(paywallId: String)
func onPaywallAction(paywallId: String, action: PaywallAction)
func onPaywallPurchaseStarted(paywallId: String, productId: String)
func onPaywallPurchaseCompleted(paywallId: String, productId: String, transaction: TransactionInfo)
func onPaywallPurchaseFailed(paywallId: String, error: Error)
func onPaywallDismissed(paywallId: String)
func onPromoCodeSubmit(paywallId: String, code: String, completion: @escaping (Bool) -> Void)
func onPostPurchaseDeepLink(paywallId: String, url: String)
func onPostPurchaseNextStep(paywallId: String)
func onPaywallRestoreStarted(paywallId: String)
func onPaywallRestoreCompleted(paywallId: String, productIds: [String])
func onPaywallRestoreFailed(paywallId: String, error: Error)
}
Example Implementation
class PaywallHandler: AppDNAPaywallDelegate {
func onPaywallPresented(paywallId: String) {
print("Paywall shown: \(paywallId)")
}
func onPaywallAction(paywallId: String, action: PaywallAction) {
switch action {
case .ctaTapped:
print("CTA tapped")
case .featureSelected:
print("Feature selected")
case .planChanged:
print("Plan changed")
case .linkTapped:
print("Link tapped")
case .custom:
print("Custom action")
}
}
func onPaywallPurchaseStarted(paywallId: String, productId: String) {
print("Purchase started: \(productId)")
}
func onPaywallPurchaseCompleted(
paywallId: String,
productId: String,
transaction: TransactionInfo
) {
print("Purchased \(productId) — txn: \(transaction.transactionId)")
// Paywall auto-dismisses on successful purchase
}
func onPaywallPurchaseFailed(paywallId: String, error: Error) {
print("Purchase failed: \(error.localizedDescription)")
// Paywall stays visible so the user can retry
}
func onPaywallDismissed(paywallId: String) {
print("Paywall dismissed")
}
func onPaywallRestoreStarted(paywallId: String) {
// Show a "Restoring purchases…" toast or spinner
}
func onPaywallRestoreCompleted(paywallId: String, productIds: [String]) {
if productIds.isEmpty {
// Tell the user there were no purchases to restore
} else {
// Refresh entitlements / unlock premium features
}
}
func onPaywallRestoreFailed(paywallId: String, error: Error) {
// Surface an error toast — paywall stays visible so the user can retry
}
}
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:
- SDK fires
onPaywallRestoreStarted(paywallId:) immediately.
- SDK calls StoreKit 2’s restore API and queries your verification endpoint for previously-purchased products.
- On success:
onPaywallRestoreCompleted(paywallId:, productIds:) fires with the list of restored product identifiers, and the SDK emits a purchase_restored analytics event automatically.
- On failure (network error, no previous purchases, StoreKit error):
onPaywallRestoreFailed(paywallId:, error:) fires and the SDK emits a purchase_restore_failed event.
The paywall stays visible after both completion and failure so the user can decide whether to purchase fresh.
If the SDK was configured without a billing provider, onPaywallRestoreFailed fires immediately with BillingError.providerNotAvailable (and onPaywallRestoreStarted is not fired — the SDK never actually started). This only happens in misconfigured hosts; production apps that pass AppDNAOptions(billingProvider: .storeKit2) will never hit this branch.
For richer transaction details (purchase dates, environment, transaction IDs), call AppDNA.billing.restorePurchases() directly — it returns [ServerEntitlement] with the full receipt data verified server-side. The paywall delegate intentionally returns just product IDs to keep the callback lightweight.
PaywallAction
The PaywallAction enum represents user interactions within the paywall:
| Case | Description |
|---|
.ctaTapped | The main call-to-action button was tapped |
.featureSelected | A feature item was selected |
.planChanged | The user switched between plan options |
.linkTapped | A link (e.g., terms, privacy policy) was tapped |
.custom | A custom action defined in the paywall config |
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.
| Case | Wire value | Description |
|---|
.purchased | "purchased" | Dismissed after a successful purchase |
.dismissed | "dismissed" | Dismissed by the user via a close button |
.tappedOutside | "tappedOutside" | Dismissed by tapping outside the paywall |
.programmatic | "programmatic" | Dismissed programmatically by your code |
| (emitted directly) | "restore_success" | Auto-dismissed after a restore returned ≥1 product (routes to success path) |
The restore_success wire value is emitted directly by PaywallManager after a successful restore that auto-dismisses the paywall. Use it for analytics segmentation (MTPU 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:
| Section | Description |
|---|
| Header | Title, subtitle, and optional hero image |
| Features list | List of 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, and 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 (features, reviews) |
| Timeline | Step-by-step vertical timeline (e.g., trial-to-paid flow) |
| Icon grid | Grid layout of icons with labels (feature highlights) |
| Comparison table | Side-by-side plan comparison (free vs. premium columns) |
| Promo input | Text field for entering promotional or coupon codes |
| Toggle | On/off toggle for add-on options (e.g., annual billing) |
| Reviews carousel | Horizontally scrollable user reviews with ratings |
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:
| Style | Description |
|---|
horizontal_cards | Side-by-side cards, one per plan. Best for 2-3 plans. |
vertical_stack | Stacked rows, each showing plan name, price, and badge. |
toggle_switch | Two-option toggle (e.g., Monthly / Annual). |
segmented_control | Native segmented control for 2-4 plan options. |
radio_list | Radio button list with plan details per row. |
carousel | Horizontally scrollable plan cards with snap behavior. |
comparison_grid | Feature comparison grid with checkmarks per plan column. |
pill_selector | Rounded pill buttons arranged horizontally. |
tier_blocks | Large stacked blocks with feature lists per tier. |
minimal_text | Text-only plan names with prices, no cards or borders. |
featured_highlight | One plan visually promoted (larger, badge, glow). |
accordion | Expandable/collapsible plan sections with full details. |
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
| 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 |
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.
When a paywall includes a Promo input section, implement the promo code delegate method to validate codes against your backend:
func onPromoCodeSubmit(paywallId: String, code: String, completion: @escaping (Bool) -> Void) {
// Validate the code and call completion(true) for valid, completion(false) for invalid
validateCode(code) { isValid in
completion(isValid)
}
}
Configuration in Console
Paywalls are created and managed in the AppDNA Console:
- Navigate to Monetization > Paywalls.
- Create a new paywall or edit an existing one.
- Add sections (header, features, plans, CTA, social proof, guarantee).
- Link App Store products to the plan options.
- Optionally assign the paywall to an experiment for A/B testing.
- 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
import AppDNASDK
class PremiumGate: AppDNAPaywallDelegate {
private weak var presenter: UIViewController?
init(presenter: UIViewController) {
self.presenter = presenter
AppDNA.paywall.setDelegate(self)
}
func showPremiumPaywall(placement: String) {
guard let vc = presenter else { return }
AppDNA.presentPaywall(
id: "premium_paywall",
from: vc,
context: PaywallContext(placement: placement),
delegate: self
)
}
// MARK: - AppDNAPaywallDelegate
func onPaywallPresented(paywallId: String) {
// Track additional analytics if needed
}
func onPaywallAction(paywallId: String, action: PaywallAction) {
// Handle custom actions
}
func onPaywallPurchaseStarted(paywallId: String, productId: String) {
// Show loading state if desired
}
func onPaywallPurchaseCompleted(
paywallId: String,
productId: String,
transaction: TransactionInfo
) {
// Unlock premium features
unlockPremium()
}
func onPaywallPurchaseFailed(paywallId: String, error: Error) {
// Error is shown in the paywall UI automatically
}
func onPaywallDismissed(paywallId: String) {
// User closed without purchasing
}
}
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.