Skip to content

Expo quickstart (@xizor/expo)

Install @xizor/expo with its peer dependencies, wrap your app in XizorProvider, and use hooks and XizorPaywall in a dev build.

@xizor/expo is the Xizor SDK for React Native / Expo: StoreKit 2 purchases via expo-iap and a server-driven paywall renderer — the server-hosted paywall is served as a self-contained web page that the SDK loads into a WebView (react-native-webview), while StoreKit purchases and analytics stay native through a bridge.

Requirements: Expo SDK 57+, React Native 0.86+, React 19 (New Architecture). iOS first — Android purchase paths throw a clear platform_not_supported error until Play Billing lands.

Install

npx expo install expo-iap react-native-webview expo-web-browser \
  expo-secure-store expo-haptics
pnpm add @xizor/expo   # or npm/yarn

All of the above are peer dependenciesnpx expo install picks the versions matching your Expo SDK. expo-iap (StoreKit 2), react-native-webview (the paywall renderer), and expo-web-browser (in-app browser for paywall links) are required; expo-secure-store (or @react-native-async-storage/async-storage) persists the subscriber token, and expo-haptics powers paywall haptic feedback — both optional but recommended.

You need a development build (no Expo Go)

In-app purchases require native StoreKit code that is not part of Expo Go. Build a development client instead:

npx expo run:ios          # local dev build
# or with EAS:
eas build --profile development --platform ios

Everything else about your workflow stays the same — fast refresh, Expo Router, etc. Only the container changes.

Provider and hooks

Wrap your app in XizorProvider with your publishable API key (xz_pub_…, from the dashboard, safe to embed):

import { XizorProvider, useEntitlements, usePaywall } from "@xizor/expo";

export default function App() {
  return (
    <XizorProvider apiKey="xz_pub_…">
      <Main />
    </XizorProvider>
  );
}

function Main() {
  const { isPro, loading } = useEntitlements(); // isPro = hasEntitlement("pro")
  const paywall = usePaywall("onboarding");

  if (loading) return <Splash />;
  if (!isPro) {
    return (
      <Button
        title="Go Pro"
        onPress={() => paywall.present({ context: { source: "home" } })}
      />
    );
  }
  return <ProContent />;
}

usePaywall(placement) returns { loading, error, present, dismiss, presented }. present() fetches the published paywall for the placement (ETag-cached — an edit saved in the dashboard is live at the very next presentation), prefetches its assets, and shows it as a fullscreen modal. The optional context drives placement rules (which paywall the server picks), {{context.<key>}} variables, and event properties.

The SDK is anonymous by design: it uses an on-device-generated subscriber token (registered with the backend at launch), persisted via expo-secure-store (or AsyncStorage — install one of them; without either the token lives only for the JS runtime). To attach your own user id after login, send await client.getAppAccountToken() to your backend and call POST /v1/subscribers/alias there with your secret key — existing subscriber histories merge automatically (details).

On startup the provider also reconciles store transactions with the backend once per cold start — existing subscribers (e.g. migrating from another provider) get their entitlements without any import step.

Imperative client

Outside React (or for full control), use XizorClient:

import { XizorClient } from "@xizor/expo";

const xizor = XizorClient.configure({ apiKey: "xz_pub_…" });

await xizor.identify(); // POST /v1/identify
const entitlements = await xizor.getEntitlements();
const result = await xizor.purchase("com.app.pro.yearly"); // StoreKit 2 via expo-iap
// With a promotional offer (signed server-side; a signature failure aborts —
// never a silent full-price charge):
await xizor.purchase("com.app.pro.yearly", { offerId: "referral_yearly" });
await xizor.restore();
xizor.trackEvent("onboarding_finished", { step: 4 });
const paywall = await xizor.getPaywall("onboarding", {
  context: { wasReferred: true }, // optional — placement rules
}); // ETag-cached
const token = await xizor.getAppAccountToken(); // for /v1/subscribers/alias

Purchases attach the appAccountToken (generated on-device, registered via /v1/identify) to the StoreKit transaction and ship the signed JWS to POST /v1/transactions for server-side verification — entitlements are always computed server-side.

Presenting paywalls

Two levels, from most to least convenient:

// 1. Hook (provider-hosted modal) — recommended
const { present } = usePaywall("upsell");

// 2. Component — self-contained modal, controlled via `visible`
<XizorPaywall
  placement="upsell"
  visible={showPaywall}
  onPurchaseComplete={(entitlements) => setShowPaywall(false)}
  onDismiss={() => setShowPaywall(false)}
/>;

Both render the server-delivered, self-contained paywall page in a react-native-webview, with everything money-related staying native via the bridge:

  • Native purchase pathpurchase/restore taps in the page call back into expo-iap over the bridge; the WebView never handles payment. Navigation inside the WebView is locked down (no foreign URLs), and open_url links open in the system or in-app browser (expo-web-browser; override with onOpenUrl).
  • Media is strictly lazy — only the visible pager/carousel page plays or decodes video and Lottie; neighbors prefetch at most metadata, everything else stays cold.
  • Product variables{{product.price}}, {{product.pricePerMonth}}, {{trial.days}}, … are filled from expo-iap product metadata and injected into the page (pass productVariables with a prebuilt html page to override, e.g. in previews).
  • Offline pages<XizorPaywall html={prebuiltPage} /> renders a prebuilt page without any network fetch.
  • Contained failures — unknown node types render their server-injected fallbacks; a paywall can never crash the app.

Events

Tracked automatically with placement/paywall-version context:

EventWhen
paywall_viewpaywall presented
paywall_dismissclosed without purchase
paywall_conversionunlocked (purchase or restore), once per presentation
purchase_started / purchase_completed / purchase_failed / purchase_cancelledpurchase lifecycle
purchase_cancel_salvageda reported cancel turned out to be a completed purchase
restorerestore requested
paywall_unknown_noderenderer met an unknown node type (forward-compat telemetry)

Presentation context is attached to all of these as flattened context.<key> properties.

Custom events: xizor.trackEvent(name, properties). Events are batched and flushed every 5 s (or at 50 events); xizor.flushEvents() forces a flush.

Notes & current limitations

  • iOS first: purchase()/restore() throw platform_not_supported on Android for now.
  • Token persistence: install expo-secure-store (recommended) or @react-native-async-storage/async-storage so the subscriber token survives app restarts; a custom adapter can be passed as storage to configure.
  • False-cancel salvage: StoreKit occasionally reports userCancelled for purchases that completed. After a cancel the SDK waits 2.5 s (salvageDelayMs), silently checks the store, and converts the result to a success when the transaction exists.
  • open_url actions with inApp: true open in the in-app browser (expo-web-browser); pass onOpenUrl to handle them yourself.

Next steps

  • Apple setup — server notifications and sandbox testers.
  • Paywalls — the document schema your paywalls are made of.
  • Analytics — what the funnel events feed into.