@fictura/sdk
v0.13.0
Published
Fictura mobile SDK — analytics, server-driven paywalls, experiments, cancel surveys. One provider, zero re-submissions.
Downloads
2,111
Maintainers
Readme
@fictura/sdk
The Fictura mobile SDK for Expo/React Native apps. One provider gives you analytics, churn tracking, cancel surveys, server-driven paywalls, and A/B experiments — all controlled from the dashboard, with zero app updates to change any of it.
Apps built inside the Fictura builder ship with this pre-wired. Existing apps integrate in ~10 minutes:
Install (existing Expo app)
npm install @fictura/sdk
npx expo install expo-secure-store expo-application # reinstall-proof device id (optional but recommended)Wire it up
// app/_layout.tsx — module scope, before components render
import { Growth, GrowthProvider } from "@fictura/sdk";
Growth.init({
apiBase: process.env.EXPO_PUBLIC_GROWTH_API_BASE ?? "",
apiKey: process.env.EXPO_PUBLIC_GROWTH_API_KEY ?? "", // Dashboard → Setup
// If you use RevenueCat (recommended):
getRevenueCatId: async () => (await Purchases.getCustomerInfo()).originalAppUserId,
purchases: revenueCatAdapter, // see below
});
export default function Layout() {
return <GrowthProvider>{/* your app */}</GrowthProvider>;
}Mount a paywall slot
import { GrowthPaywall } from "@fictura/sdk";
<GrowthPaywall
placement="default"
onClose={() => router.back()}
onPurchaseComplete={() => router.replace("/")}
/>;Which paywall renders there — template, copy, pricing presentation, experiment variant — is decided on the dashboard. Prices always come from the store at runtime; configs cannot contain price strings.
Mount an onboarding slot
import { GrowthOnboarding } from "@fictura/sdk";
<GrowthOnboarding
placement="default"
assets={ONBOARDING_TEMPLATE_ASSETS} // your bundled artwork (see below)
onComplete={() => router.replace("/home")}
fallback={<YourHardcodedOnboarding />} // shown when no live flow is published
/>;Which onboarding renders — template, copy, which steps run, experiment variant —
is decided on the dashboard. Built-in templates (house_ai, temp4, temp5,
temp6) ship in the SDK and resolve the moment you import the mount point; a
founder picking one on the dashboard needs no app wiring.
Imagery is host-provided, not shipped in the SDK. Template code lives in the
SDK; the photos each template draws are passed through assets (bundled
require()s, keyed by the names the template reads). This keeps the SDK light,
lets each app brand its own artwork, avoids shipping licensed images inside a
distributed package — and bundling (vs a CDN) keeps the first onboarding screen
instant, with no network wait.
Runs on Expo AND bare React Native CLI
The onboarding templates render through a small UI adapter instead of importing Expo packages directly, so they work in both environments.
Required in both: react-native-svg (the default gradient and the icons use it).
npm install react-native-svgExpo app — inject expo-image (cached, top-crop) and expo-linear-gradient
for the best result:
import { setFicturaUI } from "@fictura/sdk";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
setFicturaUI({ Image, LinearGradient }); // module scope, before renderBare React Native CLI app — do nothing. The SDK falls back to RN's built-in
Image + a react-native-svg gradient, which render everywhere. (You may inject
react-native-fast-image / react-native-linear-gradient the same way if you
prefer.)
Why an adapter and not a
try/catch require: Metro resolves everyrequirestring at build time, so a bare-RN app bundlingrequire('expo-image')would fail to build even inside a try/catch. The SDK references no Expo module — the Expo host imports its own copy and injects it, and Metro resolves that in the host's context, where it's installed.
RevenueCat adapter (~20 lines)
import Purchases from "react-native-purchases";
import type { PurchasesAdapter } from "@fictura/sdk";
export const revenueCatAdapter: PurchasesAdapter = {
async getPackages(offeringId) {
const offerings = await Purchases.getOfferings();
const offering = offeringId ? offerings.all[offeringId] : offerings.current;
return (offering?.availablePackages ?? []).map((p) => ({
id: p.identifier,
product_id: p.product.identifier,
price_string: p.product.priceString,
price_micros: Math.round(p.product.price * 1_000_000),
currency: p.product.currencyCode ?? "USD",
period:
p.packageType === "MONTHLY" ? "monthly"
: p.packageType === "ANNUAL" ? "annual"
: p.packageType === "WEEKLY" ? "weekly"
: p.packageType === "LIFETIME" ? "lifetime"
: "other",
trial_days: p.product.introPrice?.periodUnit === "DAY"
? p.product.introPrice.periodNumberOfUnits
: p.product.introPrice ? 7 : null,
}));
},
async purchase(packageId, offeringId) {
try {
const offerings = await Purchases.getOfferings();
const offering = offeringId ? offerings.all[offeringId] : offerings.current;
const pkg = offering?.availablePackages.find((p) => p.identifier === packageId);
if (!pkg) return { success: false, error: "package_not_found" };
await Purchases.purchasePackage(pkg);
return { success: true };
} catch (e: any) {
return e?.userCancelled ? { success: false, cancelled: true } : { success: false, error: String(e) };
}
},
async restore() {
try { await Purchases.restorePurchases(); return true; } catch { return false; }
},
};In previews / development without RevenueCat, pass mockPurchasesAdapter.
Webview paywalls that BUY — binding buttons to YOUR packages
A served (HTML) paywall renders in a webview and can trigger real purchases through the same adapter above — the page asks, the native side buys. Two rules:
- Prices are never in the HTML. The SDK injects your live store packages as
window.__FICTURA__.packages; the page reads them. A config that hardcoded a price is rejected server-side. - Buying is native. A button calls
window.__FICTURA__.purchase(id); the SDK routes it to yourPurchasesAdapter.purchase(id)→ your RevenueCat.
Bind a button — by PERIOD (recommended)
Tag any buy button. Bind by period (weekly / monthly / annual / lifetime)
so it works regardless of your identifier names — you never hardcode
$rc_monthly, and another app's custom identifiers still resolve:
<button data-fictura-plan="annual"><span data-fictura-price></span></button>
<button data-fictura-plan="weekly"><span data-fictura-price></span></button>An exact package id also works: data-fictura-plan="$rc_monthly" (id match first,
then period). A button whose plan matches no package is auto-hidden — never a
dead button. Drop this universal binder once, anywhere in the page:
<script>
(function () {
function wire() {
var F = window.__FICTURA__ || {}, pkgs = F.packages || [];
function find(sel) {
return pkgs.filter(function (p) { return p.id === sel; })[0]
|| pkgs.filter(function (p) { return p.period === sel; })[0];
}
document.querySelectorAll('[data-fictura-plan]').forEach(function (btn) {
var pkg = find(btn.getAttribute('data-fictura-plan'));
if (!pkg) { btn.style.display = 'none'; return; }
var el = btn.querySelector('[data-fictura-price]');
if (el) el.textContent = pkg.price_string; // live, per-storefront price
btn.addEventListener('click', function () { F.purchase(pkg.id); });
});
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', wire) : wire();
})();
</script>Also available: window.__FICTURA__.restore(), .close(), .track(event, props).
Which offering/packages a paywall uses
Set per-paywall on the dashboard (paywall config), not in code:
offering_id— which of your RevenueCat offerings (blank = your current offering)package_selection—"all", or specific package identifiers to showhighlight_package— which to pre-select
These are just string references to your account — Fictura never holds your RevenueCat key. Package data (prices, products) always comes from your app's adapter at runtime.
What YOU own — client checklist
Fictura and the dashboard cannot do these for you: they touch your store account or your app code.
- [ ] Your products — create weekly/monthly/annual products in App Store Connect / Play Console, put them in a RevenueCat Offering, define your entitlement.
- [ ] RevenueCat → Fictura webhook — paste your app's webhook URL (Dashboard → Setup) into RevenueCat so revenue events + the cancel survey fire.
- [ ] Wire
PurchasesAdapterto your own RevenueCat (~20 lines above). This is what makes every paywall show your packages and buy through your account. - [ ]
Growth.initwith yourapiBase+apiKey(Dashboard → Setup). - [ ] Mount points —
GrowthPaywall/GrowthOnboardingwith afallback(your built-in screen when nothing is live) and, for native templates,assets. - [ ] Native template imagery — pass your artwork via
assets(SDK ships code, not photos). - [ ] Legal URLs — set
legal: { termsUrl, privacyUrl }inGrowth.init. Paywall "Terms of Service" / "Privacy Policy" links open these (App Store Review 3.1.2 needs them wherever a subscription sells). The SDK ships no client's URLs. - [ ] Webview paywall buttons — tag buy buttons with
data-fictura-plan+ drop the universal binder (above). Bind by period so identifier names don't matter. - [ ] Attribution (optional) — install
expo-linking; registeronConversionData/onDeepLinkat module scope; observe only, don't fight your existing router. - [ ] Push (optional) — call
registerForPush()after the user grants permission.
What's automatic once wired
app_open, screen views + dwell, auto-named taps, batched delivery- Identity resolution (device / RevenueCat / account → one user)
- Cancel survey after trial cancellation (armed server-side)
- Live paywalls + experiment assignment per placement
- Remote kill-switches: every tool and every event can be disabled from the dashboard — the SDK obeys on next launch/foreground, fail-open on network errors
