Skip to main content
Supported on: Android SDK 1.0.33+
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 with Jetpack Compose 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:
presentPaywallByPlacement is a top-level method on AppDNA (not on AppDNA.paywall).

Module Access

Access the paywall module directly:

Module Methods

The placement-routed flavor lives on the top-level AppDNA object as AppDNA.presentPaywallByPlacement(activity, placement, context).

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

A tap before billing is ready

A purchase tap before billing is ready — before configure(), in the moment between configure() and the end of billing setup, or after AppDNA.shutdown() — does not open the Play sheet. It calls onPaywallPurchaseFailed with errorType == "unknown" and the message “AppDNA SDK not configured yet — call configure() first”, and tracks one purchase_failed with reason: "not_configured" and error_type: "unknown". No purchase_started is tracked. It is not providerNotAvailable: do not start a provider purchase for it. Show the paywall only after configure(), and let the user retry. A restore tap in the same window fails the same way: onPaywallRestoreFailed with that unknown error (billingErrorType(error) == "unknown") and one purchase_restore_failed with reason: "not_configured" and error_type: "unknown". onPaywallRestoreStarted is not fired.

When your app’s purchase SDK owns purchases

Override the 4-argument onPaywallPurchaseFailed(paywallId, error, errorType, productId) to read the failure type. errorType == "providerNotAvailable" is not only a misconfiguration: it is also the intended answer when billingProvider is RevenueCat or Adapty (the provider owns purchases, so the SDK never opens the Play sheet) or None. Start the purchase with your provider:
A restore tapped on the paywall under those setups calls onPaywallRestoreFailed(paywallId, error) immediately, with a providerNotAvailable error, and onPaywallRestoreStarted is not fired (as on iOS). Read the type with billingErrorType(error) and restore through your provider.

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 the restore flow and fires the lifecycle:
  1. SDK fires onPaywallRestoreStarted(paywallId) immediately — only when the SDK can restore (billingProvider StoreKit2, billing configured). Otherwise the tap goes straight to onPaywallRestoreFailed (see above).
  2. SDK calls BillingClient.queryPurchasesAsync(SUBS) + queryPurchasesAsync(INAPP) and sends the owned purchases to AppDNA’s /api/v1/billing/restore for previously-purchased products (both subscriptions and one-time products are restorable).
  3. On success: onPaywallRestoreCompleted(paywallId, productIds) fires with the list of restored product identifiers, and the SDK emits a purchase_restored analytics event automatically. A restore that finds nothing is a success too (an empty list, restored_count: 0).
  4. On failure (network error, Play Billing error, or the provider cannot restore): onPaywallRestoreFailed(paywallId, error) fires and the SDK emits a purchase_restore_failed event.
A successful restore that returns at least one product auto-dismisses the paywall by default (shouldDismiss = productIds.isNotEmpty() && !hostRequestedSkip) — the user is now entitled, so there is nothing left to buy. The paywall stays visible only when the restore returns no products, fails, or the host opted out of auto-dismiss via skipNextAutoDismissOnRestore.
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.

PaywallAction

The PaywallAction enum represents user interactions within the paywall:
All five cases are emitted from iOS SDK 1.0.84 and Android SDK 1.0.56. Before that, cta_tapped was sent by Android only — the iOS SDK never called onPaywallAction at all, so the same published paywall reported a tap on one platform and nothing on the other — and the remaining four had no emitter anywhere.🔴 plan_changed is not sent for the default selection. A paywall selects its default plan as it appears; that is not a choice the user made, and reporting it would put one event at the top of every paywall session that looks like engagement and is not.🔴 The action fires BEFORE the SDK decides whether the tap can do anything. A CTA tap that cannot resolve a plan is reported and then does nothing else — no purchase_started, no purchase callback. That ordering is what lets you tell “the SDK never received the tap” from “the purchase was attempted and failed”, which is otherwise the same silence. custom behaves the same way: the renderer still falls back to its default handling, and you are told it was an action you defined.Feature rows became tappable in iOS 1.0.84 / Android 1.0.56 so that feature_selected could exist. Nothing about them looks or reads differently, and the paywall still scrolls — the row takes a tap, not a drag.

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.

Paywall Sections

Paywalls configured in the Console support the following content sections:

Section config reference

Each section’s config object in the Console carries the fields below. Everything is optional; the Unset column is what the SDK draws when the field is absent, which is also what it draws for a value it cannot parse.

social_proof

The section has three sub-types, selected by sub_type, and they read different fields. sub_type: countdown draws the same timer as the countdown section and honours its six expiry and format fields too — on_expire_action, expired_text, and show_days / show_hours / show_minutes / show_seconds. What it does not take is that section’s duration_seconds, layout, label or digit styling: the duration comes from countdown_seconds and the digits draw in the page’s readable ink. rating is drawn as five stars with half-star precision. review_count is formatted compactly and identically on both platforms — 12450 → 12K, 1000000 → 1M, 1500 → 1.5K. text means two different things by sub-type: on app_rating it is a caption above the stars, on trial_badge it is the badge’s own label.

features

items is the rich form of the list: use it when a row needs a subtitle, its own icon, an emoji, an image, or an excluded (struck-through) state. Each entry takes text (or title), and optionally subtitle, icon, emoji, image_url and included — included: false dims the row rather than removing it, for a “not in the free plan” row. A section may carry both features and items. Both are drawn, plain strings first — a document written before items existed keeps rendering, and nothing an author set disappears.

cta

Each entry in extra_buttons takes text and action (the same four verbs), plus url when the action is link, and optionally bg_color, text_color, font_size, corner_radius and style — filled (the default) draws a button, text draws a tappable label like Restore. dismiss closes the paywall and returns the user to the screen beneath it; there is deliberately no separate back action.

divider

A divider can also carry a centred label, which splits the line in two and puts the text between them — the “OR” between a plan list and a secondary action. 🔴 label_bg_color makes the label a chip, drawn behind the text with a 4pt corner radius. It is the field to set when the line would otherwise appear to run through the text.
Requires iOS SDK 1.0.84. Up to 1.0.83 the iOS renderer drew the divider’s label on a transparent background whatever label_bg_color said, while Android drew the chip — so a divider authored with one rendered differently on the two platforms.

The features list’s rows and icons

Set on the features section, and applied to every row. 🔴 item_excluded_color is for a row that is NOT included. A feature item carrying included: false is drawn dimmed with a cross rather than a tick, in this colour — the red default makes “not in this plan” unmistakable. It is the field to change if your design wants the exclusion to read as neutral rather than negative. icon_bg_color is what turns a bare icon into a filled circle behind it; icon_bg_opacity and icon_bg_size only matter once it is set. A rich item’s own icon, emoji or image_url wins over the section’s styling for that row.
Requires iOS SDK 1.0.84 and Android SDK 1.0.56. Two things changed about a feature row.A row’s icon takes a named icon as well as an emoji — a Lucide, SF Symbols or Material name — and resolves it to the platform’s own glyph, falling back to the closest emoji when a name has no mapping on that platform. Before those versions a name was handed straight to the native symbol loader, so Android drew the name as literal text (the word, where a tick was authored) and iOS drew nothing at all.And item_text_color and item_subtitle_color resolve from the page background when unset, as the table above says. Android drew them white whatever the page was, so every feature row on a light paywall was white on white — drawn the whole time, and invisible.

The price block

Three strings on each plan, and four styling fields on the plans section. 🔴 price_layout decides whether price_total_display is drawn at all. inline (the default) puts the struck price next to the current one and has no place for a total. headline_stacked draws a large current price with both the struck was-price and the charged total beneath it — so a plan carrying price_total_display shows it only under headline_stacked. That is the pairing to remember: authoring a total without switching the layout is a value that travels and is never drawn. strikethrough_font_size applies to the charged total as well as the struck price, since they share the line beneath the headline.

What a plan row shows

Five toggles on the plans section, and every one is off by default — so a plan carrying an icon, an image, a subtitle, its own feature list or a savings figure draws none of them until the matching toggle is on. That is the most common reason an authored plan looks emptier than expected. 🔴 A subtitle needs both show_plan_subtitles AND text on the plan. The toggle alone draws nothing, and subtitle_position does nothing without the toggle — three fields that have to agree before anything appears, which is why they are listed together.

The plan cards’ box

🔴 card_gap’s default is per display style, not one number. A vertical stack, a grid, a horizontal scroll and a pill row each space their cards differently out of the box (between 8 and 12 points), so authoring card_gap replaces a value you may not have expected. Set it when you want the same spacing whatever the style, and leave it unset when you want each style’s own look. card_height is applied as a minimum, so a card with more content than the height allows grows rather than clipping. A card with less content pads out to it.

The plan tiles’ selected and unselected states

Set on the plans section, and applied to every tile. 🔴 Leave selected_text_color unset and the SDK derives it from the tile, which is the point. The ink has to contrast with whatever the tile ends up being, not with the page behind it — a white label on a near-white selected fill is invisible even though the page is dark. Both platforms composite the selected fill over the page and then pick readable ink for the result, so an unauthored tile is always legible. Author selected_text_color only when you have checked it against your own selected_bg_color. The two fills are deliberately different alphas when unauthored — the selected tile composites at a higher opacity than the unselected one — so the two states are distinguishable from each other rather than merely from the page. selected_scale applies a transform, so a value much above 1.05 can overlap the neighbouring tile in a tight grid.

The CTA button’s own look

The buy button’s styling lives in more than one place, and the order matters — this is the element most likely to be restyled and the one where a value set in the wrong place looks like a bug. The paywall-level cta object carries the button’s content and its base styling: Each of those may also be set inside a nested style object on the same cta, which wins over the flat field: the resolution is cta.style.bg_color → cta.bg_color → brand accent, and the same shape for text_color, corner_radius, height and font_size. The section’s style beats both. A button element style on the cta section — style.elements.button.container.background.color for the fill, and its text_style.color for the label — is checked first and short-circuits the chain above. So the full order for the fill is:
🔴 Setting a colour in two of those places is not additive — the earliest one wins silently. The Android source records what that cost: a paywall authored cta: { bg_color: "#FFFFFF", text_color: "#000000" } once rendered as the indigo default, reported as “text-only with no background”, because only the section style was being read. Two more fields live on the cta SECTION rather than on the object: cta_font_size and cta_font_weight, plus cta_action and the Restore fields above. A cta_gradient on the section replaces the flat fill entirely when it carries two or more stops.
A CTA with nothing to buy is disabled and drawn at half opacity — Android always; iOS from SDK 1.0.84. When no plan is selected — an empty or unresolvable plans array — the button refuses the tap and shows that it has.Up to 1.0.83 iOS’s cta-section button was disabled only while a purchase was in flight, so the same document drew a full-strength button there that accepted the tap and did nothing. iOS’s root-cta fallback already behaved correctly, so the two iOS renderers of the same control also disagreed with each other.A paywall with plans is unaffected: the default plan is selected as the screen appears, so the button is live from the first frame.
A layout with NO cta section still draws the button — Android SDK 1.0.56 and later. When the cta object is set at the paywall level and the layout holds neither a cta section nor a sticky_footer, the renderer pins a button built from that object alone. Its label falls back to Continue.Up to 1.0.82 Android looked for the SECTION and stopped there, so a paywall authored that way rendered every other section and then ended: the plans were selectable and nothing on the screen could buy them. iOS has drawn the fallback since its renderer was written, so the same published document bought on one platform and not the other.A sticky_footer carries its own button and suppresses the fallback, so the pinned area never holds two.
The pinned bar at the bottom of the screen draws its own button, and it takes the cta_*-prefixed keys — which is the naming trap here, because they look like they belong to the cta section: 🔴 blur_background: true makes the footer frosted glass rather than a solid fill, so the page is visible through it as it scrolls underneath. iOS fills it with .ultraThinMaterial; Compose has no equivalent, so Android approximates it with a translucent white layer — close enough to read as the same design over a solid page, and not identical pixel for pixel. It overrides background_color when set. It can also carry one secondary action under the button — usually “Restore purchases”: The label is drawn in the platform’s secondary text colour at 13pt, so it stays legible on a light footer and a dark one. A link opens through the same URL allowlist as every other config-driven URL, so an off-list host is refused rather than opened. 🔴 secondary_text alone draws a dead control. With no secondary_action the label renders and the tap falls through both branches, so always author the two together.

A plan’s badge

plans[].badge is the text (“Most popular”, “Best value”); the plans section’s own config styles every badge on the paywall. badge_shape sets the corner: rectangle is a 2pt corner, rounded a 6pt one, and ribbon is a rectangle whose trailing edge caves inward to a point at mid-height, giving pennant tails. 🔴 square is an Android-only alias for rectangle. iOS does not recognise it and falls through to the default — a capsule — so a document carrying badge_shape: "square" is a box on Android and a pill on iOS. The Console never writes it; author rectangle. | badge_bg_color | hex string | your brand accent | | badge_text_color | hex string | readable ink for the badge fill | | badge_border_color | hex string | no border | | badge_border_width | number | 0 | | badge_font_size | number | 11 | | badge_icon | an icon reference drawn before the text | no icon | The three top_* positions float the badge over the card’s upper edge, which reserves space above the card so it is not clipped. inline puts the badge inside the card body, on the same row as the plan name, and reserves nothing — use it when cards sit flush against each other. 🔴 A hyphenated value works too. The Console writes top-right; both SDKs normalise hyphens to underscores before matching, so top-right and top_right are the same thing. An unrecognised value falls back to top_right rather than dropping the badge.

countdown

🔴 text_color tints the whole digit row — the chip behind each number, the unit letters and the : between them — because they are one element to an author. The label has its own label_color. layout decides whether there is a container at all. inline draws the label and the digits with no background; boxed wraps them in a 12 pt rounded panel filled with background_color; banner is the same panel with square corners, edge to edge. A background_color on an inline countdown is ignored. Where a section style.elements.value.text_style is set, it wins over digit_font_size, digit_font_weight and text_color: it is the design-system channel and the more specific of the two. 🔴 variant is read by no renderer on any platform. The field validates and persists — digital, circular, flip, bar — and every value draws the same digits. There is one timer style, laid out by layout.

What happens when it expires

hide removes the section outright. show_expired_text replaces the digits with expired_text, and does nothing without it. auto_advance dismisses the paywall, reported to your delegate as DismissReason.programmatic — the user did nothing; the timer ran out. It fires once, so a paywall left open past zero is not dismissed repeatedly. Leaving on_expire_action unset keeps the pre-existing behaviour: the timer reaches 00:00:00 and stays there.

Which units are shown

🔴 Authoring any one of the four switches all four to their defaults above, so a countdown with only show_days: true draws days, hours, minutes and seconds. Set none and you get the historical h : m : s, unchanged. With days hidden, the hours column carries the whole remainder — a three-day countdown reads 71h 59m 59s rather than 2d 23h 59m 59s. That is why show_days exists, and why turning it on changes the hours number as well as adding a column.

toggle

An on/off row — an add-on, or an annual-billing switch. 🔴 There is no authorable OFF colour on a paywall toggle. The off state is the platform’s own switch grey. off_color exists in the type definitions but is read only by the onboarding toggle block, not this one, and toggle_label_color is read by neither — use label_color for the label.

rive

The animation autoplays and is centred. A state machine named here must exist in the file; naming one that does not falls back to the file’s own default rather than drawing nothing.

promo_input

Both messages are drawn under the field after the host answers the promo callback, so they are the only words a user sees about the outcome — the SDK does not validate a code itself.

comparison_table

A column is either a plain string or an object, element by element, and both platforms read both forms:
columns is read as a fallback for table_columns with the same rules, which is the spelling the Console writes. Use the object form when you want highlighted — a string column cannot carry it. 🔴 A table with rows and no columns draws nothing. The cells are positional — each row’s values line up with the columns — so with no columns there is nothing to line them up against, and both platforms render an empty section rather than a feature list. If you want a plain list of features, use a features section.

Prices and Periods Come From the Store

A plan’s price and billing period are read from the store — Play Console — not from what an author typed, so a price change you make in the store reaches the paywall without a republish. The trial sentence is resolved in four rungs, first non-empty wins: 🔴 trial is an object of two halves and the schema requires both, so { duration_days: 7, label: "7-day free trial" } is the normal shape and rung 1 wins. Rung 3 exists for a plan that carries the object with no label — an imported or hand-written one — which used to draw nothing at all despite having an authored trial length. The lookup is bounded and never blocks the paywall. It runs in parallel with the image prefetch on the same short budget; a product the store does not know, a lookup that fails and a lookup that outlives the deadline are the same outcome, and every plan then falls back to its authored values. 🔴 A trial sentence is a promise, so it is shown only on a positive signal. Google answers eligibility structurally: getSubscriptionOfferDetails returns only the offers a user may actually buy, so a free-trial offer coming back is Play saying this user can have it — a user who has already used it gets no such offer. The reverse is never done: an absent offer does not hide a trial label you authored, because Play can also grant a trial through a promo code that never appears in the list. Only a zero-price phase counts; an introductory price is a different promise. And it is the trial the purchase starts. Tapping the CTA buys that same free-trial offer, so the sheet opens on the trial the paywall stated rather than on the base plan (Android SDK 1.0.56+; before it, the base plan was always bought and Play charged from day one). A user Play no longer offers the trial to buys the base plan, and sees no trial sentence either. Opt out per plan, not globally. use_store_price: false exists for a plan that deliberately shows a marketing price — a struck-through “was $19.99”, a price your app subsidises, or a plan with no store product behind it.
The period differs from the price in one way: an authored period always wins, because it is often deliberate copy (“mo”, “per month”). The store’s value fills it only when you have authored none — so adding this changes no paywall that already sets one. The price works the other way round: the store wins unless you opt out, because a screen showing a different price from the one being charged is a billing problem rather than a styling one.

Countdown variants

A countdown section’s variant chooses how the time is drawn. All four are honoured on every platform and in the Console preview. 🔴 An unrecognised value draws digital, which is what every countdown drew before the variants were implemented — so a document carrying a value this build does not know degrades rather than drawing nothing. An empty string counts as unrecognised. The digits’ own controls apply to every variant: digit_font_size, digit_font_weight and text_color style the numbers, and the value style element still wins over them. circular and bar derive their fill from the time remaining against the countdown’s own starting value, so a fixed_datetime countdown that begins partway through its duration starts partway along.

Plan rows announce which one is selected

Every plan row reports its selected state to the platform’s accessibility layer, on both platforms — iOS adds the isSelected trait to the selected row, Android marks it selected with the radio-button role. A screen-reader user hears which plan is currently chosen, not just that a row exists. This matters because selection is otherwise conveyed only visually: the radio symbol, the border colour, the background fill and the font weight all change, and none of those reaches a screen reader. Nothing is required of you to get it — it applies to every plan display style and layout below.

Plan Display Styles

The plan selection section supports the following display styles, configured per paywall in the Console as a lowercase plan_display_style string: minimal_chips and toggle_cards are no longer rendered on either platform and fall back to the vertical stack. They were never offered by the Console’s Plan display style control, so no paywall you can author uses one; a hand-edited document that names one now gets the same layout on both platforms instead of a pill selector on Android and a stack on iOS.
An unrecognised value renders the default vertical stack, silently. Both renderers dispatch on this field with a final default arm — there is no error, no warning and no blank screen, so a misspelled or invented style looks like a layout that simply did not take effect. Author it from the console’s own picker where you can, and check the value against this table where you cannot.

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.

Dismiss, Audience and Placement

Three Console settings decide whether the paywall can be left, who sees it, and where it is asked for. All three are read by the SDK off the published document.

dismiss

back_button draws a chevron instead of a cross and leaves the paywall the same way a close does, returning the user to whatever was presented before it — a previous paywall, an onboarding step or a screen. It is a style rather than a separate control because on a single-view paywall “back” and “close” do the same thing to the paywall; only the glyph differs, so DismissReason is unchanged. swipe_down and text_link draw no glyph, so the header reclaims the inset an x_button reserves. A testimonial section draws one quote with its attribution; reviews_carousel scrolls several. On reviews_carousel: 🔴 Both dot colours resolve from the PAGE, not from a fixed white. They used to be an opaque white dot and the rest at 30%, so a carousel on a light paywall showed no dots at all and a reader had no way to know how many pages there were. An authored indicator_color is taken at full strength on both platforms; only the unset default is derived. 🔴 show_rating_stars and auto_scroll default to TRUE, unlike the plans section’s five toggles, which all default to false. So a reviews carousel scrolls and shows stars unless you turn them off — the opposite polarity from the plan row, on the same paywall. avatar_emoji is the fallback for avatar_url, not an alternative to it: set both and the image wins.

The remaining sections’ fields

🔴 image_alignment and image_max_height belong to the header section, not to the image one, despite the names. They position and cap the hero image above the title. The lottie and video_background sections each fall back to a plain height before their own default, so height works as a shared spelling for both — useful when one layout carries both.

Where a section sits, and how a plan is drawn

body scrolls with the page. bottom is pinned above the fold with the CTA, in the order the sections are authored, so what the user is choosing between stays on screen while the page scrolls behind it. Before this existed only the CTA section — and a legal placed after it — could be pinned, which is why a generated paywall could leave a gap where the plans had been and push them off a short screen. text_only strips a plan’s border, background and card chrome and draws it as a single centred line; text_only_font_size and text_only_color exist for it because there is no card left to inherit sizing from. card is every paywall authored before the mode existed. A theme_preset other than custom supplies the page’s colours as a set, so an author picks one instead of setting each colour. custom means “use what I set”.

audience

placement

onboarding · settings · feature_gate · push_upsell · home · custom A placement is what presentByPlacement resolves against, and it is recorded on every paywall_view event — so it is also how the same paywall’s performance is compared across the surfaces it is shown on. custom is for a placement of your own naming.

post_purchase

What happens after the purchase succeeds or fails, configured per paywall. show_message draws message for delay_ms before dismissing; deep_link opens deep_link_url; next_step hands control back to the onboarding flow that presented the paywall. show_error and retry draw a red banner just above the purchase button, showing message (default “Payment failed. Please try again.”). The rest of the paywall stays usable behind it, so the user can pick another plan or tap the purchase button again. retry adds a button labelled retry_text (default “Try Again”) to the banner, which starts the purchase again for the selected plan, exactly as tapping the purchase button does. Tapping the banner hides it, and a show_error banner hides itself after 4 s. The rest of the paywall stays usable while a banner is up — tapping the purchase button again buys. dismiss closes the paywall. iOS and Android draw the same banner.

Promo Code Handling

When a paywall includes a Promo input section, implement the promo code delegate method to validate codes against your backend. The completion callback is a simple (Boolean) -> Unit — true accepts the code, false rejects it (the SDK renders the in-built rejection message):

Auto-Tracked Events

The paywall module automatically tracks the following events:
Purchase completion and failure events are tracked by the billing module, not the paywall module. See the Billing documentation for the full list of purchase-related events.

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).
  • A Google location answer (one carrying place_id, on the SDK releases that resolve Google answers) shows the text the user typed on a paywall, in-app message, survey or push: any {{responses.…}} field of it, and the whole-answer token, resolve to its raw_query. Google content is credited only on onboarding screens, and these surfaces have no room for that. 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 Google Play 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 Google Play Console. Mismatched identifiers will cause purchase failures.

Full Example

The paywall module integrates with the billing module for purchase handling. See the Billing guide for the full purchase API.

Next Steps

Per-plan keys

These sit on a plan inside plans, not on the section:

When the CTA names the plan by itself

On a paywall with more than one plan, where no plan carries a cta_text, the SDKs append the selected plan’s name to the CTA — so a button configured Subscribe draws Subscribe — Annual. It exists because a tap on a plan row that drifts far enough is claimed by the scrolling container, so the selection does not change and the CTA buys the plan that was selected before it. That is the platform’s own gesture convention and the SDKs do not override it; naming the plan on the button is what makes a stale selection visible before the purchase rather than on the receipt after. Three things it deliberately does not do:
  • it leaves a single-plan paywall exactly as configured — there is no choice to get wrong;
  • an authored cta_text on the plan wins outright, so you can say it your own way;
  • a CTA whose text already contains the plan’s name is left alone, so Start Annual does not become Start Annual — Annual.
You control it on the paywall’s top-level cta: Both halves are translated (cta.text and plan.<index>.name), and so is the format, as cta.plan_format, so a locale can change the separator or the order. The sticky-footer button names the plan the same way. The name used is the plan’s label (falling back to name) — the short word you typed, not a price.