@billdog.io/expo
v1.1.0-beta.3
Published
Expo-compatible paywall rendering library for BillDog
Maintainers
Readme
BillDog Expo Paywalls SDK
Expo-compatible paywall rendering library for BillDog. Display beautiful, high-converting subscription paywalls in your Expo app with smart caching, A/B testing, and analytics built-in.
Features
- ✅ Expo Compatible: Works seamlessly with Expo managed workflow and EAS Build
- ✅ Smart Caching: Intelligent prefetching and offline support
- ✅ A/B Testing: Built-in experiment support
- ✅ Analytics: Track conversions and user behavior automatically
- ✅ Variable Substitution: Personalize paywalls with user data
- ✅ Custom Themes: Match your app's design system
- ✅ TypeScript: Full type safety
- ✅ BillDog Auth: Sign-in, sessions, MFA and organizations (development build required)
Installation
For Expo SDK 50+
npx expo install @billdog.io/expoAdd Config Plugin
Add the plugin to your app.json:
{
"expo": {
"plugins": [
"@billdog.io/expo"
]
}
}Rebuild Your App
After adding the plugin, you need to rebuild your app:
# For EAS Build
eas build --platform all
# For local development builds
npx expo run:ios
npx expo run:androidQuick Start
Configure the SDK once at startup; every component and hook reads the API key from it:
import { BillDogConfiguration } from '@billdog.io/expo';
BillDogConfiguration.configure({ apiKey: 'your_api_key' });import { PaywallView } from '@billdog.io/expo';
export default function SubscriptionScreen() {
return (
<PaywallView
paywallIdentifier="premium_offer"
onButtonPress={(buttonId, action) => {
console.log('Button pressed:', buttonId, action);
// Handle purchase action
}}
onDismiss={() => {
console.log('Paywall dismissed');
}}
/>
);
}Using with Expo Go
Important: everything that talks to the native BillDog SDK needs a custom development build (npx expo run:ios / npx expo run:android or an EAS build) and does not work in Expo Go. That covers purchases, the headless offering reads (getOfferings, getOffering, useOfferings), deleteAccount, BillDog Auth and the native paywall view. In Expo Go, render a paywall with WebViewPaywall.
import { isExpoGo, WebViewPaywall, NativePaywallView } from '@billdog.io/expo';
const Paywall = isExpoGo() ? WebViewPaywall : NativePaywallView;Headless (bring your own UI)
The offering reads go through the native SDK, so they carry the install's device proof, the session and the SDK cache. There is one offering model (Offering) and one path.
import {
BillDog,
useOfferings,
PlacementNotFoundError,
BillDogApiError,
} from '@billdog.io/expo';
function Plans() {
const { offerings, currentOffering, loading, error } = useOfferings();
if (loading) return <Text>Loading...</Text>;
if (error) return <Text>{error.code}: {error.message}</Text>;
return (
<View>
{(currentOffering?.packages ?? []).map((pkg) => (
<Text key={pkg.identifier}>{pkg.identifier}</Text>
))}
</View>
);
}
// A placement: the server's decision is on the result.
async function showOnboardingOffer() {
try {
const result = await BillDog.getOffering('onboarding');
if (result.presentation.holdout) return; // present nothing
if (!result.offering) return; // nothing selected (see presentation.withheld)
// result.presentation.gated: the feature behind this placement requires access
// render result.offering.packages with your own UI
} catch (e) {
if (e instanceof PlacementNotFoundError) { /* unknown placement identifier */ }
else if (e instanceof BillDogApiError) { /* e.code: PROOF_REQUIRED, PROOF_INVALID, ... */ }
}
}getOffering(placement) never returns the default offering in place of the one asked for.
Advanced Features
Smart Caching
Enable advanced caching for faster load times:
import { PaywallView } from '@billdog.io/expo';
<PaywallView
paywallIdentifier="premium_offer"
enableCache={true}
cacheConfig={{
maxAge: 3600000, // 1 hour
staleWhileRevalidate: 86400000, // 24 hours
prefetchRelated: true,
}}
/>Variable Substitution
Personalize paywalls with user data:
<PaywallView
paywallIdentifier="premium_offer"
variables={{
user_name: 'John',
trial_days: '7',
discount_percentage: '50',
}}
/>A/B Testing
Paywalls automatically participate in experiments:
<PaywallView
paywallIdentifier="premium_offer"
userContext={{
user_id: 'user_123',
segment: 'power_users',
}}
/>Custom Loading & Error States
<PaywallView
paywallIdentifier="premium_offer"
LoadingComponent={() => (
<View style={styles.loading}>
<ActivityIndicator size="large" />
<Text>Loading your offer...</Text>
</View>
)}
ErrorComponent={({ error, retry }) => (
<View style={styles.error}>
<Text>Oops! {error.message}</Text>
<Button title="Retry" onPress={retry} />
</View>
)}
/>BillDog Auth
Re-exported straight from @billdog.io/react-native, which owns the native
bridge — so the refresh token lives in the iOS Keychain and Android
EncryptedSharedPreferences, not in AsyncStorage.
Not available in Expo Go. The native module is not part of the Expo Go binary. Use a development build (
npx expo run:ios/eas build --profile development).BillDogAuth.isAvailablereports this up front rather than failing at the first sign-in attempt. Requires native binaries1.1.0-beta.3— auth is absent from the published beta.2 iOS XCFramework and has no Android artifact before that at all.
import { BillDogAuth } from '@billdog.io/expo';
if (!BillDogAuth.isAvailable) {
// Running in Expo Go — send the user to hosted sign-in, or prompt for a
// development build.
return;
}
await BillDogAuth.restore(); // { signedIn: false } on first launch
const r = await BillDogAuth.attempt({
identifier: '[email protected]',
strategy: 'password',
password,
});
if (r.mfaRequired) {
await BillDogAuth.attempt({
identifier: '[email protected]',
strategy: 'totp',
code,
mfaToken: r.mfaToken,
});
}Phone sign-in needs kind: 'phone' on both requestCode and attempt — it
selects the identity row, and omitting it looks a phone number up among email
addresses and matches nothing.
The full surface, its organization methods and its limitations — no captcha or invitation token on sign-up, no password reset, no self-serve account deletion — are documented in the React Native README.
Hooks API
usePaywall
Fetch a single paywall with caching:
import { usePaywall } from '@billdog.io/expo';
function MyComponent() {
const { configuration, loading, error, refetch } = usePaywall({
paywallIdentifier: 'premium_offer',
enableCache: true,
});
if (loading) return <Text>Loading...</Text>;
if (error) return <Text>Error: {error.message}</Text>;
return <Text>Paywall: {configuration?.name}</Text>;
}useOfferings
The project's default offering read (no placement). Returns the public Offering model.
import { useOfferings } from '@billdog.io/expo';
function OfferingsScreen() {
const { offerings, currentOffering, loading, error, refetch, getOffering } = useOfferings();
return (
<FlatList
data={offerings}
keyExtractor={(offering) => offering.id}
renderItem={({ item }) => <OfferingCard offering={item} selected={item.id === currentOffering?.id} />}
onRefresh={refetch}
refreshing={loading}
/>
);
}currentOffering is the offering the server selected for this caller, or null when it selected none. Pass { skipFetch: true } to read later with refetch().
Utilities
Expo Go Detection
import { isExpoGo, isStandaloneBuild } from '@billdog.io/expo';
if (isExpoGo()) {
console.log('Running in Expo Go - native features are unavailable; use WebViewPaywall');
} else if (isStandaloneBuild()) {
console.log('Running in standalone build - full native support');
}Configuration Options
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| paywallIdentifier | string | Yes | Unique identifier for the paywall |
| configuration | PaywallConfiguration | No | Direct configuration (bypasses API) |
| onButtonPress | (id: string, action: any) => void | No | Button press handler |
| onDismiss | () => void | No | Dismiss handler |
| variables | Record<string, string> | No | Variable substitution values |
| userContext | Record<string, any> | No | User context for A/B testing |
| enableCache | boolean | No | Enable smart caching (default: false) |
| cacheConfig | CacheConfig | No | Advanced cache configuration |
| LoadingComponent | React.ComponentType | No | Custom loading component |
| ErrorComponent | React.ComponentType | No | Custom error component |
TypeScript Support
Full TypeScript definitions included:
import type {
PaywallConfiguration,
PaywallComponent,
OfferingPackage,
UsePaywallOptions,
UseOfferingsOptions,
} from '@billdog.io/expo';Troubleshooting
"Native module not found" Error
Make sure you've:
- Added the plugin to
app.json - Rebuilt your app with
eas buildornpx expo run:ios/android - Installed all dependencies with
npx expo install
Paywall Not Displaying in Expo Go
Native paywall rendering requires a custom build. In Expo Go, render with WebViewPaywall. The headless reads (useOfferings, BillDog.getOffering) also need a custom build.
Cache Not Working
Ensure you've enabled caching:
<PaywallView enableCache={true} />Analytics Events Not Firing
Check that your API key has analytics permissions and that you're passing userContext for user tracking.
Migration from React Native SDK
If you're migrating from @billdog.io/react-native, simply:
- Replace imports:
// Before
import { PaywallView } from '@billdog.io/react-native';
// After
import { PaywallView } from '@billdog.io/expo';- Add the config plugin to
app.json - Rebuild your app
All APIs are identical!
Next Steps
- iOS In-App Purchase Integration
- Android In-App Purchase Integration
- A/B Testing Guide
- Analytics Dashboard
Support
- Documentation: https://docs.billdog.app
- GitHub Issues: https://github.com/billdog/expo-paywalls/issues
- Discord: https://discord.gg/billdog
License
MIT © BillDog
