Skip to main content
Supported on: iOS SDK 1.0.70+ · Android SDK 1.0.42+ · React Native SDK 1.0.7+ (New Architecture only)
The AppDNA React Native 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 below).
  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, fails, or you opted out with AppDNA.paywall.skipNextAutoDismissOnRestore(true).

Keeping the paywall up after a restore

To show your own confirmation, or to upsell on top of the paywall the user is still looking at, opt out of that one auto-dismiss:
Two things make this work, and both are easy to get wrong:
  • Call it before the user taps Restore. Presenting the paywall is the natural moment. Setting it from inside onPaywallRestoreCompleted is too late: that callback crosses the bridge asynchronously on both platforms, and the native SDK reads the flag on the line after it forwards the callback. (The iOS and Android guides show the in-delegate form because a native delegate call is synchronous — a bridged one is not.)
  • It is one-shot. The SDK clears it on every restore terminal event — success, empty success and failure — so it can never leak into the next paywall, and you re-arm it per presentation.
A restore that finds nothing never dismisses, so the flag changes nothing there. With it set, onPaywallDismissed fires when the user closes the paywall themselves — still exactly once.

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 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 TypeScript 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 React Native components 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 React Native you receive the lifecycle via onPaywallDismissed. Reasons include:

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 — App Store Connect or 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, and each store answers eligibility its own way: iOS asks StoreKit isEligibleForIntroOffer directly, while Google answers 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. The reverse is never done on either platform: a negative answer does not hide a trial label you authored. Only a free-trial offer counts; an introductory price is a different promise. 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

Configured per paywall as a lowercase plan_display_style string.
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”), 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.

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

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:
onPromoCodeSubmit is the one veto whose default is to refuse. If you don’t implement it, or your Promise takes longer than vetoTimeout, the code is rejected — never accepted.
The SDK awaits your Promise<boolean>, 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). error is the platform’s error message string (localizedDescription on iOS, message on Android) — it is not a JS Error and has no .message property; String(error) is the whole of it. Branch on errorType, which is the stable reason code: userCancelled, productNotFound, verificationFailed, networkError, serverError, pending, providerNotAvailable, unknown. productId names the plan that failed, or is null if none was selected. The paywall stays visible so the user can retry, unless its On failure action is set to Dismiss. providerNotAvailable is also the intended answer under billingProvider: 'revenueCat' / Adapty (see below).
  • Restore errors arrive via onPaywallRestoreFailed(paywallId, error) — again a message string. 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.
When AppDNA does not own billing — billingProvider is 'revenueCat' / Adapty (the provider owns purchases and restores) or 'none', or the provider is not linked — onPaywallRestoreFailed fires immediately with the refusal message (“RevenueCat: purchases are made by RevenueCat in your app”, “Adapty: purchases are made by Adapty in your app” or “No billing provider configured”) and onPaywallRestoreStarted is not fired (on both platforms). The callback carries no error code, so branch on your configured provider. Under a provider this is the intended answer, not a misconfiguration: restore through your provider (e.g. RevenueCat’s restorePurchases). A paywall plan tap in the same setups calls onPaywallPurchaseFailed with errorType: 'providerNotAvailable' and the tapped productId, and no store sheet opens — start the purchase with your provider from that callback (recipe). 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.
Telling “not configured yet” apart from a real failure, in your analytics. A purchase or restore refused before billing is ready carries an extra property on its event — reason: "not_configured" on purchase_failed and on purchase_restore_failed — on both platforms. The errorType for that case is unknown, which on its own is indistinguishable from a genuine unknown store error, so reason is what separates “the host called too early” from “something broke”. It is absent on every other failure, so filter on its presence.

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

  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

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.