Paywalls
Placements, drafts and versions, save-to-live publishing, the paywall editor, and the full paywall document schema reference.
Xizor paywalls are server-hosted web documents — authored as self-contained HTML and served to the SDKs as a page rendered in a WebView (WKWebView on iOS, react-native-webview in Expo). The browser is the reference renderer, so keyframe-style motion, ambient video backgrounds, and Lottie all work; media is only decoded when it is actually visible, and StoreKit purchases plus analytics stay native via a bridge.
Concepts
Placements. App code never references a paywall directly — it asks for a _placement_ ("onboarding", "upsell", "settings"). The dashboard decides which paywall each placement shows. Swap the paywall behind a placement anytime without touching app code.
Drafts and versions. Every paywall has a draft you edit freely and a list of immutable published versions. Analytics are recorded per paywall _version_, so you can compare conversion across versions after every publish.
Save → live. Publishing creates a new version and changes the document's ETag. SDKs revalidate on every presentation (If-None-Match), so the very next time the paywall is shown, users get the new version — no app update, no review, no cache TTL to wait out.
Localization is first-class. Every text in a document is a locale map ({"en": "...", "de": "..."}). Add locales in the editor's locale column view — or let an AI agent do it with the MCP tool localize_paywall (MCP guide).
Rules & context. A placement can route by _presentation context_: the app passes key/values with present({ context: { wasReferred: true } }), and the placement's ordered rules (Placements → Rules, or MCP set_placement_rules) pick the paywall — first match wins, the assigned paywall stays the fallback (also when a rule's target is unpublished, so a hard gate never comes up empty). The same context is available inside the document as {{context.<key>}} variables and lands on funnel events as context.<key> properties.
The editor in 60 seconds
- In the dashboard, open your app → Paywalls → New paywall and pick a template (
cinematic,clean, orlottie_delight) or start blank. - The canvas shows a live native-equivalent preview; the tree on the left is the node hierarchy (stacks, text, media, purchase buttons…). Select a node to edit its properties, style, and animations.
- Map product slots (e.g.
yearly,monthly) to your App Store product ids under Products. - Add locales in the locale column view — every text field shows all locales side by side.
- Save keeps the draft; Publish validates the document (broken slot references, missing default-locale entries, and unresolved theme tokens are rejected) and makes it live.
- Assign the paywall to a placement — that's what the SDK requests.
Everything the editor can do, the MCP tools can do too: create_paywall, update_paywall_draft, localize_paywall, publish_paywall, validate_paywall_document.
Schema reference (schemaVersion 1)
Reference for the JSON document format of server-hosted paywalls. The source of truth is the @xizor/paywall-schema package (zod v4); it is the storage/wire IR the authoring web format compiles to. On presentation the server decompiles it back to a self-contained page that the SDKs (@xizor/expo, XizorKit) load into a WebView, with StoreKit and analytics native via a bridge.
Core properties of the format:
- Discriminated union by
type— renderers map every node 1:1 to a native view. - Never crash: unknown
type→ render itsfallback, otherwise skip the node (see Fallback semantics). - Product-agnostic: nodes reference slot ids (
yearly); the mapping tostore_product_idlives inproducts. The same document works in any app that fills the slots. - Declarative animations —
entrance/loopwith presets + cubic-bezier, implementable in SwiftUI _and_ Reanimated. - Media is always lazy — decoders only when visible; in a carousel, only the active page.
Package API surface
import {
paywallDocumentSchema, // zod schema (structure + semantic cross-checks)
validatePaywallDocument, // (doc: unknown) => { ok, document } | { ok, errors: {path, message, code}[] }
resolveText,
pickLocalizedText, // variable interpolation + locale selection
parseMarkdownLite,
extractVariables,
paywallTemplates, // "cinematic" | "clean" | "lottie_delight"
type PaywallDocument,
type PaywallNode, // + every node type individually
} from "@xizor/paywall-schema";
validatePaywallDocument is used identically by the dashboard editor, api.xizor.dev (publish guard), and the MCP tool validate_paywall_document. Errors carry JSON paths like $.root.children[3].productSlot.
Root: PaywallDocument
| Property | Type | Required | Description |
|---|---|---|---|
schemaVersion | 1 (literal) | yes | Version of the schema format (see Versioning policy) |
id | string | yes | Document id |
name | string | yes | Display name in the dashboard |
locales | string[] (min 1) | yes | Supported locales (BCP-47) |
defaultLocale | string | yes | Must be in locales; every localized text must have an entry for it |
theme | Theme | yes | Color/font/radius tokens |
root | Node | yes | Root node (typically a stack) |
products | ProductSlot[] | yes | Slot → store product mapping |
settings | Settings | yes | Dismiss, safe-area, and background behavior |
Theme
{
"colors": { "bg": "#070707", "panel": "#121212", "text": "#FFFFFF" }, // values ALWAYS hex
"fonts": { "heading": { "family": "Inter", "weight": 700 } }, // optional
"radii": { "card": 16, "pill": 999 }, // optional
}
Reference tokens with $token: colors anywhere a color is expected ("$panel"), radii via style.borderRadius: "$card", fonts via style.font.family: "$heading". Unresolved tokens are validation errors.
ProductSlot
| Property | Type | Description |
|---|---|---|
slot | string | Document-local id, e.g. "yearly". Must be unique per document |
storeProductId | string | App Store product id — the only store reference in the document |
offerId | string? | Promotional offer id (App Store Connect). Purchases from this slot apply the discount — the SDK fetches the ES256 signature server-side automatically. Omit instead of "" |
Settings
| Property | Type | Description |
|---|---|---|
dismissable | boolean? | false = no close button; only purchase/restore ends the paywall |
safeArea | "all" | "top" | "bottom" | "none"? | Which edges the _content_ respects; the background always fills the whole screen |
background | Background? | Discriminated union by kind: |
{ "kind": "color", "color": "$bg" }
{ "kind": "gradient", "gradient": { "kind": "linear", "angle": 180, "stops": [{ "color": "#070707", "position": 0 }, …] } }
{ "kind": "image", "src": "https://…", "blurhash": "…" }
{ "kind": "video", "src": "https://…", "poster": "https://…", "loop": true, "muted": true,
"overlay": { "kind": "linear", "angle": 180, "stops": [ … ] } } // scrim for text contrast
Background video is always muted-first and decoded lazily (poster immediately, player only once the paywall is visible).
Common node fields
Every node has, in addition to its specific properties:
| Property | Type | Description |
|---|---|---|
id | string? | For events/debugging/editor selection |
style | Style? | see Style |
animation | Animation? | see Animation |
visibility | { locales?: string[], platforms?: "ios"[] }? | Render the node only if all conditions match. Locale matching also by language prefix (de matches de-AT). iOS is the only platform today; the enum grows as new platforms launch |
fallback | Node? | Replacement node for renderers that don't know the type |
Localized text (LocalizedText)
A map locale → template: { "en": "Start **free**", "de": "**Kostenlos** starten" }. Selection order (pickLocalizedText): exact locale → language prefix (de matches de-AT, both directions) → defaultLocale → first entry. Every map must have an entry for defaultLocale (validation error otherwise).
Templates support variables and markdown-lite: **bold**, *em*, \* = literal asterisk. Deliberately, there is no more markdown than that.
Nodes (discriminated union by type)
stack
Layout container. direction: "vertical" = VStack/Column, "horizontal" = HStack/Row, "layered" = ZStack (children on top of each other).
| Property | Type | Description |
|---|---|---|
direction | "vertical" | "horizontal" | "layered" | Required |
children | Node[] | Required |
gap | number? | Spacing between children |
align | "start" | "center" | "end" | "stretch"? | Cross axis |
distribution | "start" | "center" | "end" | "space_between" | "space_around" | "space_evenly"? | Main axis |
padding | EdgeInsets? | number or {top, bottom, leading, trailing} |
scroll | boolean? | Content scrolls along the main axis |
text
| Property | Type | Description |
|---|---|---|
text | LocalizedText | Required. Variables + markdown-lite |
maxLines | number? | Truncation |
Typography via style.font, style.color, style.textAlign.
image
| Property | Type | Description |
|---|---|---|
src | URL | Required |
blurhash | string? | Placeholder, visible immediately |
resizeMode | "cover" | "contain" | "stretch" | "center"? | default cover |
lazy | boolean? | default true — decode only when (nearly) visible |
video
| Property | Type | Description |
|---|---|---|
src | URL | Required |
loop / autoplay | boolean? | |
muted | boolean? | default true — paywall videos are ambient |
poster | URL? | Immediately visible frame |
lazy | boolean? | default true. Renderers MUST decode lazily: create the player/decoder only when the node becomes visible. In a carousel: only the active page holds a decoder |
lottie
| Property | Type | Description |
|---|---|---|
src | URL | .json (Lottie) or .lottie (dotLottie) |
loop | boolean? | |
speed | number? | 1 = normal |
playMode | "autoplay" | "on_visible" | "paused"? | on_visible starts only when visible |
Recommendation: set a fallback to a static image (see the lottie_delight template).
button
| Property | Type | Description |
|---|---|---|
label | LocalizedText | Required |
action | Action | Required — see Actions |
preset | "primary" | "secondary" | "outline" | "ghost" | "link"? | Renderer style preset, overridable via style |
purchase_button
Starts the StoreKit purchase of the referenced slot and automatically tracks purchase_started/completed/failed.
| Property | Type | Description |
|---|---|---|
productSlot | string | Slot id from products or "$selected" (follows the product_selector selection) |
label | LocalizedText | Required, with variables: "Start {{trial.days}}-day free trial" |
preset | "solid" | "gradient" | "glass" | "outline"? |
restore_button
| Property | Type | Description |
|---|---|---|
label | LocalizedText? | Without a label, the renderer shows a localized default ("Restore purchases") |
carousel
| Property | Type | Description |
|---|---|---|
children | Node[] | Required — the pages |
autoAdvanceMs | number? | Auto-advance interval; omit = manual |
loop | boolean? | Endless |
indicators | boolean | { activeColor?, inactiveColor? }? | Page dots |
lazyRender | boolean? | default true — decode only the visible page. Invisible pages must not hold video/image decoders; neighbor pages at most layout + BlurHash/poster |
timer
| Property | Type | Description |
|---|---|---|
countdown | { kind: "until", endsAt: ISO-8601 } | { kind: "duration", seconds } | Required. duration counts from first display; the renderer persists the start time per subscriber (no reset on re-open) |
format | "hh:mm:ss" | "mm:ss" | "dd:hh:mm:ss"? | default hh:mm:ss |
expiredText | LocalizedText? | Shown after expiry; without it the timer stays at 00:00 |
feature_list
| Property | Type | Description |
|---|---|---|
items | { icon?: string, text: LocalizedText }[] (min 1) | icon: SF Symbol name, emoji, or image URL (renderer heuristic: contains :// → URL; single emoji grapheme → emoji; otherwise SF Symbol) |
iconColor | Color? | |
gap | number? | Spacing between rows |
staggerMs | number? | Offset per item for a staggered entrance; item _i_ starts at animation.entrance.delayMs + i * staggerMs |
spacer
size (fixed, points) or flex (weight); with neither = flexible spacer (flex 1).
divider
thickness? (default 1), color?, inset? (horizontal inset).
badge
text: LocalizedText (required), variant: "filled" | "outline"?. Looks via style (pill: borderRadius: "$pill").
product_selector
Renders the slots as selectable options (cards/segments). The selection is the state that purchase_button binds to with "$selected".
| Property | Type | Description |
|---|---|---|
slots | { slot, label?, sublabel?, badge? }[] (min 1) | slot must exist in products. Without label the renderer shows product name + price. sublabel/badge support variables ("{{products.yearly.pricePerMonth}} / mo", "BEST VALUE") |
selectedSlot | string? | Initial selection (must be one of the options) |
direction | "vertical" | "horizontal"? | Card list vs. toggle |
Inside an option, the product variable context is bound to that option's slot.
footer_links
privacyUrl?, termsUrl?, showRestore? (default true — Apple requires a restore entry point), labels?: { privacy?, terms?, restore? } (LocalizedText overrides). Renders the usual "Privacy · Terms · Restore" row.
Style
A deliberately small, precisely defined subset that SwiftUI and RN can implement losslessly — no CSS passthrough.
| Property | Type |
|---|---|
width, height | number | "50%" | "auto" |
flex | number ≥ 0 |
padding, margin | number | { top?, bottom?, leading?, trailing? } |
background | Color | Gradient |
borderRadius | number | "$radiusToken" |
borderWidth | number ≥ 0 |
borderColor | Color |
shadow | { color?, opacity? (0–1), radius, x?, y? } |
opacity | 0–1 |
font | { family? (name or "$fontToken"), size?, weight? (100–900), letterSpacing?, lineHeight? (multiplier) } |
color | Color |
textAlign | "left" | "center" | "right" |
Color = theme token ("$primary") or hex (#RGB, #RGBA, #RRGGBB, #RRGGBBAA).
Gradient = { kind: "linear" | "radial", angle? (linear only, degrees, 0 = up, 90 = right), stops: { color, position: 0–1 }[] (min 2) }.
Animation
Declarative, so SwiftUI (withAnimation/keyframes) and Reanimated (withTiming/withRepeat) render identically.
{
"entrance": {
"kind": "slide_up",
"delayMs": 120,
"durationMs": 450,
"easing": "ease_out",
},
"loop": { "kind": "pulse", "durationMs": 2400 },
}
Entrance presets (once on appear; defaults: delay 0, duration 350 ms, easing ease_out):
| kind | Behavior |
|---|---|
fade | opacity 0 → 1 |
slide_up | translateY +24 pt → 0 + fade |
slide_down | translateY −24 pt → 0 + fade |
scale | scale 0.92 → 1 + fade |
none | none (overrides inherited editor defaults) |
Loop presets (endless, starting when the entrance ends; default duration 2000 ms):
| kind | Behavior |
|---|---|
pulse | scale 1 → 1.03 → 1 (ease_in_out) |
float | translateY ±4 pt, sinusoidal |
shimmer | a sheen gradient sweeps across the node once per cycle |
rotate | 360° rotation per cycle, linear |
Easing: preset (linear, ease, ease_in, ease_out, ease_in_out, spring) or a cubic-bezier tuple [x1, y1, x2, y2], e.g. [0.16, 1, 0.3, 1]. spring = critically damped spring (SwiftUI .spring, Reanimated withSpring); there durationMs is interpreted as the target settle duration.
Reduced motion (OS setting): renderers replace all entrances with fade and disable loop.
Actions
Discriminated union by type:
| Action | Fields | Behavior |
|---|---|---|
purchase | productSlot (slot id or "$selected") | Start the StoreKit purchase |
restore | — | Restore purchases |
dismiss | — | Close the paywall (paywall_dismiss event) |
open_url | url, inApp? | inApp: true → SFSafariViewController / Custom Tab |
select_product | slot | Set the selection (like tapping a product_selector option) |
custom | name, payload? (JSON object) | Passed through to the host app handler (onCustomAction) |
Variables
Interpolation in all LocalizedText templates: {{path}} (whitespace allowed: {{ product.price }}). Implemented by resolveText(template, ctx, { onMissing }) — a pure TS function shared by the RN renderer and the dashboard preview; the SwiftUI port follows the same spec so both renderers produce identical output.
Context binding: inside purchase_button and inside product_selector options, product is that slot. Outside a slot context: product = the selected slot (if a selector exists), otherwise the first slot. {{products.<slot>.<field>}} works everywhere.
| Variable | Example value | Description |
|---|---|---|
{{product.price}} | "€39.99" | Store-formatted price (always a pre-formatted string) |
{{product.period}} | "year" | Localized period name |
{{product.periodUnit}} | "year" | day | week | month | year |
{{product.periodCount}} | 3 | Units per period ("3 months") |
{{product.trialDays}} | 7 | Trial length in days |
{{product.pricePerMonth}} | "€3.33" | Normalized to a month, formatted |
{{product.pricePerWeek}} / {{product.pricePerYear}} | analogous | |
{{product.name}} | "Pro Yearly" | Display name |
{{product.currencyCode}} | "EUR" | ISO 4217 |
{{products.<slot>.<field>}} | Any slot, same fields | |
{{trial.days}} | 7 | Falls back to product.trialDays |
{{custom.<name>}} / {{<name>}} | Host-app variables | |
{{context.<key>}} | "onboarding" | Presentation context from present({ context }); numbers/booleans stringified |
onMissing: "keep" (default; editor/preview shows raw placeholders) or "empty" (production — end users never see {{…}}).
Fallback semantics & forward compat
Goal: a document published with a _newer_ schema feature must never crash an _older_ renderer.
- Unknown node
type: the renderer rendersfallback(recursively — a fallback can itself have a fallback). Nofallback→ skip the node silently (layout as if it weren't there) + telemetry eventpaywall_unknown_node. - Unknown properties on known nodes are ignored (SDK parsers are tolerant, not
strict). This allows additive extensions without aschemaVersionbump. - Unknown enum values (e.g. a new animation
kind): the renderer treats them like the neutral default (fade/ no loop /cover…) — never throw. - Unknown
schemaVersion: don't render the document; the SDK falls back along its fallback chain (cache → bundled default JSON). - Authoring rule: every node using a new feature (e.g.
lottie) should set afallbackto an older, universal node (image/text).
The zod validation (dashboard/API/MCP), by contrast, is strict: unknown types, broken references (slots, tokens), and missing defaultLocale entries are rejected on save/publish — tolerance is the renderers' job, not the editor's.
Versioning policy
schemaVersionversions the format, not the content (content versions = published paywall versions).- Additive changes (new node types, new optional properties, new enum values, new variables) do not bump
schemaVersion— old renderers keep working thanks to fallback semantics. - Breaking changes (removing/renaming a property, changing semantics, adding a required field) ⇒
schemaVersion: 2as a _new_ schema; the API serves the highest format each SDK version supports (SDKs sendX-Xizor-Schema-Versionwhen fetching a paywall) or migrates down server-side. - The package exports exactly one schema per format; migrations live server-side, never in the client.
Renderer obligations (performance)
- Respect
video.lazy/image.lazy/carousel.lazyRender: decoders only for visible content. - On paywall fetch, prefetch the assets of the first visible nodes (posters/BlurHash first), the rest on demand.
- Background video: poster immediately, player after first paint; release the player immediately on dismiss.
- Timers with
kind: "duration": persist the start time per (subscriber, paywall, node id).
Example templates
The package ships three complete, validated documents as typed TS constants and via paywallTemplatesJson() as JSON:
| ID | Contents |
|---|---|
cinematic | Fullscreen video background with gradient scrim, trial badge, feature list, 3 product slots (weekly/yearly/lifetime), "$selected" CTA with pulse |
clean | Image hero with BlurHash, screenshot carousel (lazyRender), horizontal yearly/monthly toggle with "SAVE 40%" badge |
lottie_delight | Lottie hero (.lottie, on_visible, image fallback), staggered feature list (staggerMs), 30-minute countdown offer with gradient CTA |
Minimal example:
{
"schemaVersion": 1,
"id": "pw_minimal",
"name": "Minimal",
"locales": ["en"],
"defaultLocale": "en",
"theme": {
"colors": {
"bg": "#070707",
"text": "#FFFFFF",
"accent": "#FFFFFF",
"onAccent": "#070707",
},
},
"products": [{ "slot": "monthly", "storeProductId": "com.app.pro.monthly" }],
"settings": {
"dismissable": true,
"safeArea": "all",
"background": { "kind": "color", "color": "$bg" },
},
"root": {
"type": "stack",
"direction": "vertical",
"gap": 16,
"padding": 24,
"distribution": "end",
"children": [
{
"type": "text",
"text": { "en": "Go **Pro**" },
"style": { "color": "$text", "font": { "size": 34, "weight": 700 } },
},
{
"type": "purchase_button",
"productSlot": "monthly",
"label": { "en": "Subscribe — {{product.price}} / {{product.period}}" },
"style": {
"background": "$accent",
"color": "$onAccent",
"borderRadius": 14,
"padding": { "top": 16, "bottom": 16 },
"textAlign": "center",
},
},
{
"type": "footer_links",
"privacyUrl": "https://example.com/privacy",
"termsUrl": "https://example.com/terms",
},
],
},
}