Supported on: iOS SDK
1.0.70+ · Android SDK 1.0.42+ · React Native SDK 1.0.7+ (New Architecture only)Present a Paywall
Present a paywall by its identifier:The paywall ID must match the ID configured in the AppDNA Console under Monetization > Paywalls.
Present by Placement
Pass a placement string inPaywallContext 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 topresent():
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 viaAppDNA.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’srestore_text and restore_position config in the Console). When the user taps it, the SDK runs:
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 toonPaywallRestoreFailed(see below).- 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/verifyin 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. - On success:
onPaywallRestoreCompleted(paywallId, restoredProductIds)fires, and the SDK emits apurchase_restoredevent automatically. - On failure:
onPaywallRestoreFailed(paywallId, error)fires and the SDK emitspurchase_restore_failed.
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:- Call it before the user taps Restore. Presenting the paywall is the natural moment. Setting it from inside
onPaywallRestoreCompletedis 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.
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 apaywall_trigger graph node, a successful restore that returns at least one product automatically:
- Dismisses the paywall.
- Refreshes the in-memory entitlement cache.
- Routes the onboarding flow via the trigger’s
on_success_target— same path as a real purchase.
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 includepaywall_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 viaon_subscribed_skip_target(falls back toon_success_target, then to the downstream edge). Nopaywall_viewevent 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.
Refreshing the entitlement cache
When you callAppDNA.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:
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:AppDNA.paywall.present(...). Reserve custom presentation for cases where the rendered paywall cannot meet a hard product requirement.
PaywallAction Values
Theaction 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 viaonPaywallDismissed. Reasons include:
Paywall Sections
Paywalls configured in the Console support the following content sections:Section config reference
Each section’sconfig 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.
legal
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 thefeatures 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 theplans 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 theplans 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 theplans 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-levelcta 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:
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.sticky_footer
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’svariant 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 theisSelected 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 lowercaseplan_display_style string.
Card & Badge Customization
Plan cards support per-plan styling configured in the Console: badge text and color (e.g., “Best Value”), card border and shadow, auto-calculated save percentage on annual plans, trial label, card background (solid / gradient / image), and per-card corner radius.Layout Types
Rich Media in Paywalls
Paywall sections support: Lottie animations, Rive animations, inline video, haptic feedback on plan selection / CTA, particle effects (confetti) on purchase, and per-section styling (background colors, gradients, images, borders, shadows, corner radius). See the Rich Media guide for full details.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.
testimonial and reviews_carousel
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 aPromo input section validate codes server-side against the rules configured in the Console (under Monetization > Promo Codes). When the user submits a code:
- The SDK posts it to the Console validation endpoint along with the paywall ID, the active product set, and the user’s identity.
- On a valid code, the matching discount is applied to the displayed plans and an updated price is rendered.
- On an invalid or expired code, the SDK surfaces the rejection inline inside the section.
onPromoCodeSubmit on the paywall delegate:
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
Error Handling
- Purchase errors arrive via
onPaywallPurchaseFailed(paywallId, error, errorType, productId).erroris the platform’s error message string (localizedDescriptionon iOS,messageon Android) — it is not a JSErrorand has no.messageproperty;String(error)is the whole of it. Branch onerrorType, which is the stable reason code:userCancelled,productNotFound,verificationFailed,networkError,serverError,pending,providerNotAvailable,unknown.productIdnames the plan that failed, or isnullif none was selected. The paywall stays visible so the user can retry, unless its On failure action is set to Dismiss.providerNotAvailableis also the intended answer underbillingProvider: '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.
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.
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 itsraw_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.
Configuration in Console
- Navigate to Monetization > Paywalls.
- Create or edit a paywall.
- Add sections (header, features, plans, CTA, social proof, guarantee).
- Link App Store and Google Play products to the plan options.
- Optionally assign the paywall to an experiment for A/B testing.
- 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
- Set up Billing for detailed purchase handling
- Configure Onboarding flows that transition into paywalls
- Learn about Offline Support for paywall config caching
Per-plan keys
These sit on a plan insideplans, not on the section:
When the CTA names the plan by itself
On a paywall with more than one plan, where no plan carries acta_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_texton 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 Annualdoes not becomeStart Annual — Annual.
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.
