Skip to content

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

  1. In the dashboard, open your app → Paywalls → New paywall and pick a template (cinematic, clean, or lottie_delight) or start blank.
  2. 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.
  3. Map product slots (e.g. yearly, monthly) to your App Store product ids under Products.
  4. Add locales in the locale column view — every text field shows all locales side by side.
  5. 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.
  6. 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 its fallback, otherwise skip the node (see Fallback semantics).
  • Product-agnostic: nodes reference slot ids (yearly); the mapping to store_product_id lives in products. The same document works in any app that fills the slots.
  • Declarative animationsentrance/loop with 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

PropertyTypeRequiredDescription
schemaVersion1 (literal)yesVersion of the schema format (see Versioning policy)
idstringyesDocument id
namestringyesDisplay name in the dashboard
localesstring[] (min 1)yesSupported locales (BCP-47)
defaultLocalestringyesMust be in locales; every localized text must have an entry for it
themeThemeyesColor/font/radius tokens
rootNodeyesRoot node (typically a stack)
productsProductSlot[]yesSlot → store product mapping
settingsSettingsyesDismiss, 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

PropertyTypeDescription
slotstringDocument-local id, e.g. "yearly". Must be unique per document
storeProductIdstringApp Store product id — the only store reference in the document
offerIdstring?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

PropertyTypeDescription
dismissableboolean?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
backgroundBackground?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:

PropertyTypeDescription
idstring?For events/debugging/editor selection
styleStyle?see Style
animationAnimation?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
fallbackNode?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).

PropertyTypeDescription
direction"vertical" | "horizontal" | "layered"Required
childrenNode[]Required
gapnumber?Spacing between children
align"start" | "center" | "end" | "stretch"?Cross axis
distribution"start" | "center" | "end" | "space_between" | "space_around" | "space_evenly"?Main axis
paddingEdgeInsets?number or {top, bottom, leading, trailing}
scrollboolean?Content scrolls along the main axis

text

PropertyTypeDescription
textLocalizedTextRequired. Variables + markdown-lite
maxLinesnumber?Truncation

Typography via style.font, style.color, style.textAlign.

image

PropertyTypeDescription
srcURLRequired
blurhashstring?Placeholder, visible immediately
resizeMode"cover" | "contain" | "stretch" | "center"?default cover
lazyboolean?default true — decode only when (nearly) visible

video

PropertyTypeDescription
srcURLRequired
loop / autoplayboolean?
mutedboolean?default true — paywall videos are ambient
posterURL?Immediately visible frame
lazyboolean?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

PropertyTypeDescription
srcURL.json (Lottie) or .lottie (dotLottie)
loopboolean?
speednumber?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

PropertyTypeDescription
labelLocalizedTextRequired
actionActionRequired — 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.

PropertyTypeDescription
productSlotstringSlot id from products or "$selected" (follows the product_selector selection)
labelLocalizedTextRequired, with variables: "Start {{trial.days}}-day free trial"
preset"solid" | "gradient" | "glass" | "outline"?

restore_button

PropertyTypeDescription
labelLocalizedText?Without a label, the renderer shows a localized default ("Restore purchases")
PropertyTypeDescription
childrenNode[]Required — the pages
autoAdvanceMsnumber?Auto-advance interval; omit = manual
loopboolean?Endless
indicatorsboolean | { activeColor?, inactiveColor? }?Page dots
lazyRenderboolean?default truedecode only the visible page. Invisible pages must not hold video/image decoders; neighbor pages at most layout + BlurHash/poster

timer

PropertyTypeDescription
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
expiredTextLocalizedText?Shown after expiry; without it the timer stays at 00:00

feature_list

PropertyTypeDescription
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)
iconColorColor?
gapnumber?Spacing between rows
staggerMsnumber?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".

PropertyTypeDescription
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")
selectedSlotstring?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.

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.

PropertyType
width, heightnumber | "50%" | "auto"
flexnumber ≥ 0
padding, marginnumber | { top?, bottom?, leading?, trailing? }
backgroundColor | Gradient
borderRadiusnumber | "$radiusToken"
borderWidthnumber ≥ 0
borderColorColor
shadow{ color?, opacity? (0–1), radius, x?, y? }
opacity0–1
font{ family? (name or "$fontToken"), size?, weight? (100–900), letterSpacing?, lineHeight? (multiplier) }
colorColor
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):

kindBehavior
fadeopacity 0 → 1
slide_uptranslateY +24 pt → 0 + fade
slide_downtranslateY −24 pt → 0 + fade
scalescale 0.92 → 1 + fade
nonenone (overrides inherited editor defaults)

Loop presets (endless, starting when the entrance ends; default duration 2000 ms):

kindBehavior
pulsescale 1 → 1.03 → 1 (ease_in_out)
floattranslateY ±4 pt, sinusoidal
shimmera sheen gradient sweeps across the node once per cycle
rotate360° 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:

ActionFieldsBehavior
purchaseproductSlot (slot id or "$selected")Start the StoreKit purchase
restoreRestore purchases
dismissClose the paywall (paywall_dismiss event)
open_urlurl, inApp?inApp: true → SFSafariViewController / Custom Tab
select_productslotSet the selection (like tapping a product_selector option)
customname, 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.

VariableExample valueDescription
{{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}}3Units per period ("3 months")
{{product.trialDays}}7Trial 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}}7Falls 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.

  1. Unknown node type: the renderer renders fallback (recursively — a fallback can itself have a fallback). No fallback → skip the node silently (layout as if it weren't there) + telemetry event paywall_unknown_node.
  2. Unknown properties on known nodes are ignored (SDK parsers are tolerant, not strict). This allows additive extensions without a schemaVersion bump.
  3. Unknown enum values (e.g. a new animation kind): the renderer treats them like the neutral default (fade / no loop / cover …) — never throw.
  4. Unknown schemaVersion: don't render the document; the SDK falls back along its fallback chain (cache → bundled default JSON).
  5. Authoring rule: every node using a new feature (e.g. lottie) should set a fallback to 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

  • schemaVersion versions 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: 2 as a _new_ schema; the API serves the highest format each SDK version supports (SDKs send X-Xizor-Schema-Version when 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:

IDContents
cinematicFullscreen video background with gradient scrim, trial badge, feature list, 3 product slots (weekly/yearly/lifetime), "$selected" CTA with pulse
cleanImage hero with BlurHash, screenshot carousel (lazyRender), horizontal yearly/monthly toggle with "SAVE 40%" badge
lottie_delightLottie 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",
      },
    ],
  },
}