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 dependencies — npx 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 path —
purchase/restoretaps 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), andopen_urllinks open in the system or in-app browser (expo-web-browser; override withonOpenUrl). - 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 (passproductVariableswith a prebuilthtmlpage 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:
| Event | When |
|---|---|
paywall_view | paywall presented |
paywall_dismiss | closed without purchase |
paywall_conversion | unlocked (purchase or restore), once per presentation |
purchase_started / purchase_completed / purchase_failed / purchase_cancelled | purchase lifecycle |
purchase_cancel_salvaged | a reported cancel turned out to be a completed purchase |
restore | restore requested |
paywall_unknown_node | renderer 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()throwplatform_not_supportedon Android for now. - Token persistence: install
expo-secure-store(recommended) or@react-native-async-storage/async-storageso the subscriber token survives app restarts; a custom adapter can be passed asstoragetoconfigure. - False-cancel salvage: StoreKit occasionally reports
userCancelledfor 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_urlactions withinApp: trueopen in the in-app browser (expo-web-browser); passonOpenUrlto 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.