@billdog.io/react-native
v1.1.0-beta.3
Published
React Native paywall rendering library for BillDog
Maintainers
Readme
@billdog.io/react-native
React Native library for rendering BillDog paywalls with native components.
What's New in 1.4 — Purchase Parity APIs
The RN bridge now exposes the full purchase parity surface (PurchaseParams, PurchaseController, CustomProduct, attribution setters, setProxyURL, eligibility, canMakePayments, presentCodeRedemptionSheet, storefront observer):
import { BillDog, PurchaseParams } from '@billdog.io/react-native';
const result = await BillDog.purchase(
PurchaseParams.builder('pro_yearly')
.promoOfferId('intro_2024')
.appAccountToken(uuid)
.isPersonalizedOffer(true)
.build()
);
// result.kind: 'purchased' | 'cancelled' | 'pending' | 'failed' | 'restored'
await BillDog.setAdjustID(adid);
await BillDog.setAppsflyerID(appsflyerId);
await BillDog.setProxyURL('https://billdog-edge.example.com');Full guides: Custom Purchases · Attribution · Self-Hosted Proxy
Your own purchase code (host PurchaseController)
If the app buys through its own code (react-native-purchases,
react-native-iap, …), register a PurchaseController at configure. Every
purchase — a native paywall button or BillDog.purchase — then runs it through
the native SDK, which records the purchase from the store evidence it returns.
BillDog never finishes or acknowledges a transaction in this mode.
import { BillDog, BillDogConfiguration, StoreEvidence } from '@billdog.io/react-native';
BillDogConfiguration.configure({
apiKey: 'bd_...',
purchaseController: {
capabilities: [], // 'quantity' | 'promotional_offers' | 'win_back_offers' | 'upgrades'
storeKitVersion: 'storeKit2',
async purchase(productId, request) {
const tx = await myStore.buy(productId, { accountToken: request?.storeAccountToken });
return {
result: { type: 'purchased', transactionId: tx.id, productId },
evidence: [
Platform.OS === 'ios'
? StoreEvidence.appStore({ transactionId: tx.id, productId })
: StoreEvidence.googlePlay({ purchaseToken: tx.purchaseToken, productIds: [productId], orderId: tx.orderId }),
],
};
},
async restorePurchases() {
return { type: 'restored', items: [] };
},
},
});- Return a bare
PurchaseResultif you have no evidence: BillDog derives it from the store (on Android by the result'stransactionIdread as the Play order id), or records the purchase at its next sweep. - A purchase your controller does not answer within five minutes, or one in
flight when the JavaScript context reloads, is reported
pending, never failed: BillDog records it when the store reports it. BillDog.setPurchaseControlleris gone; pass the controller toconfigure.- With RevenueCat, use
@billdog.io/integration-revenuecat.
Purchases your code makes outside a paywall:
const recording = await BillDog.recordPurchase(StoreEvidence.appStore({ transactionId, productId }));
BillDog.observePurchaseResult(productId, { transactionId, evidence }); // analytics + recording
const held = await BillDog.syncStorePurchases(); // backstop sweep, never prompts
const issues = await BillDog.purchaseRecordingIssues(); // never shown as success
const token = await BillDog.storeAccountToken(); // appAccountToken / obfuscatedAccountIdFeatures
✨ AI-Generated Paywalls - Create paywalls using AI prompts or templates
⚡ Smart Caching - Instant display with <100ms load time (Phase 3)
🎨 Cross-Platform - Works on iOS and Android
📊 Real-time Analytics - Track conversions and revenue
🔌 Offline Support - Cached paywalls work without network
🎯 A/B Testing - Run experiments without app updates
📱 Dynamic Updates - Update paywalls remotely
Advanced Caching System
BillDog SDK includes a production-grade caching system that dramatically improves performance:
⚡ 60% Storage Reduction - GZIP compression automatically reduces cache size
🚀 Sub-50ms Load Times - Multi-level caching (memory + AsyncStorage)
🧠 Intelligent Prefetching - Learns user patterns and preloads paywalls
📊 Real-time Analytics - Monitor cache performance and hit rates
🔄 Background Updates - Silent version checking keeps content fresh
📴 Offline Support - Cached paywalls work without network
Quick Start with Caching
import { PaywallView } from '@billdog.io/react-native';
function MyPaywall() {
return (
<PaywallView
paywallIdentifier="premium_offer"
cacheConfig={{
enabled: true, // Enable caching (default: true)
compressionEnabled: true, // Enable GZIP (default: true)
memoryCacheEnabled: true, // Memory cache (default: true)
maxAge: 24 * 60 * 60 * 1000, // 24 hours (default: 7 days)
prefetchEnabled: true, // Smart prefetching (default: false)
}}
onButtonPress={(id, action) => handlePurchase(id, action)}
/>
);
}Cache Analytics
import { AdvancedPaywallCache } from '@billdog.io/react-native';
const cache = new AdvancedPaywallCache({
enabled: true,
compressionEnabled: true,
});
// Get real-time analytics
const analytics = await cache.getAnalytics();
console.log(`Hit Rate: ${(analytics.hitRate * 100).toFixed(1)}%`);
console.log(`Avg Load Time: ${analytics.avgLoadTime.toFixed(0)}ms`);
console.log(`Storage Saved: ${(analytics.compressionRatio * 100).toFixed(0)}%`);📖 Full Caching Guide: See CACHING_GUIDE.md for advanced configuration, platform comparison, and best practices.
Installation
npm install @billdog.io/react-native
# or
yarn add @billdog.io/react-nativeUsage
Configure the SDK once at startup; every component and hook reads the API key from it:
import { BillDogConfiguration } from '@billdog.io/react-native';
BillDogConfiguration.configure({ apiKey: 'your_api_key' });Basic Usage
import { PaywallView } from '@billdog.io/react-native';
function App() {
return (
<PaywallView
paywallIdentifier="premium_offer"
onButtonPress={(componentId, action) => {
console.log('Button pressed:', componentId, action);
// Handle purchase flow
}}
onDismiss={() => {
console.log('Paywall dismissed');
}}
/>
);
}With Direct Configuration
import { PaywallView, PaywallConfiguration } from '@billdog.io/react-native';
const config: PaywallConfiguration = {
components: [
{
id: 'title',
type: 'text',
name: 'Title',
position: { x: 0, y: 0 },
size: { width: '100%', height: 'auto' },
layout: { width: 'fill', height: 'hug' },
appearance: {},
properties: {
text: 'Unlock Premium Features',
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
},
},
// ... more components
],
};
function App() {
return (
<PaywallView
configuration={config}
onButtonPress={(id, action) => {
// Handle purchase
}}
/>
);
}Custom Loading and Error Components
import { PaywallView } from '@billdog.io/react-native';
import { Text, View } from 'react-native';
function LoadingPaywall() {
return (
<View>
<Text>Loading your offers...</Text>
</View>
);
}
function ErrorPaywall({ error }: { error: Error }) {
return (
<View>
<Text>Failed to load: {error.message}</Text>
</View>
);
}
function App() {
return (
<PaywallView
paywallIdentifier="premium_offer"
LoadingComponent={LoadingPaywall}
ErrorComponent={ErrorPaywall}
/>
);
}API Reference
PaywallView Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| paywallIdentifier | string | No | Paywall identifier to fetch from API |
| configuration | PaywallConfiguration | No | Direct configuration (skips API fetch) |
| onButtonPress | (id: string, action: string) => void | No | Called when user presses buttons |
| onDismiss | () => void | No | Called when paywall is dismissed |
| LoadingComponent | ComponentType | No | Custom loading component |
| ErrorComponent | ComponentType<{ error: Error }> | No | Custom error component |
usePaywall Hook
import { usePaywall } from '@billdog.io/react-native';
const { configuration, loading, error, refetch } = usePaywall({
paywallIdentifier: 'premium_offer',
userContext: {
app_user_id: 'user_123',
country: 'US',
platform: 'ios',
},
});Headless offerings (bring your own UI)
One model, one path. The native SDK reads the offering (so the request carries the device proof and the session, and is cached natively) and merges live App Store / Play prices into the products. These calls need the native module: a development or release build, not a JS-only run.
import {
BillDog,
PlacementAccessWithheldError,
PlacementNotFoundError,
BillDogApiError,
} from '@billdog.io/react-native';
// The project default. No placement is named.
const offerings = await BillDog.getOfferings(); // Offering[]
const current = await BillDog.getCurrentOffering(); // Offering | null
// A placement. The server's decision is surfaced, never replaced.
try {
const result = await BillDog.getOffering('onboarding'); // OfferingResult
if (result.presentation.holdout || !result.offering) {
// Held out, or nothing selected: show nothing.
return;
}
BillDog.trackPaywallPresented(result.offering, result.placement);
const pkg = result.offering.packages[0];
await BillDog.purchasePackage(pkg);
} catch (e) {
if (e instanceof PlacementAccessWithheldError) {
// Withheld from this caller (e.reason). Keep the feature shut.
} else if (e instanceof PlacementNotFoundError) {
// No such placement in this project.
} else if (e instanceof BillDogApiError) {
// e.code: PROOF_REQUIRED, PROOF_INVALID, REAUTH_REQUIRED, ACCESS_DENIED
// (e.reason === 'target_mismatch'), PURCHASE_OWNER_CONFLICT, ...
}
}OfferingResult is { offering, offerings, placement, presentation,
experiment, campaign }. offering is the offering the server selected for
this caller; it is null on a holdout or when nothing is selected, and the
SDK never substitutes the current or default offering for it.
useOfferings Hook
The same read as a hook. It returns the same Offering model.
import { useOfferings } from '@billdog.io/react-native';
const { offerings, currentOffering, loading, error, refetch, getOffering } = useOfferings();skipFetch(option): do not read on mount; callrefetch()when ready.erroris aBillDogApiError(see above), ornull.getOffering(placement)isBillDog.getOffering(placement).
BillDog Auth
Sign-in, sessions and second factors. The bridge delegates to the native
SDKs rather than reimplementing them, so the refresh token lives in the iOS
Keychain and Android EncryptedSharedPreferences — never in
AsyncStorage, which is plain text on disk.
Requires
1.1.0-beta.3of the native binaries. It is not that beta.2 is missing two methods — the whole auth module postdates it.BillDogFull 1.0.0-beta.2shipped 2026-08-08 and contains noBillDogAuth; Maven Central carries noio.billdog:billdog-authat any version before beta.3. A build that resolves beta.2 fails to compile.
import { BillDogAuth } from '@billdog.io/react-native';
// False when the native module is not linked into the host app.
if (!BillDogAuth.isAvailable) return;
await BillDogAuth.restore(); // { signedIn: false } on first launch
const r = await BillDogAuth.attempt({
identifier: '[email protected]',
strategy: 'password',
password,
});
// Check this BEFORE treating it as a sign-in: against an enrolled account a
// correct password issues no session.
if (r.mfaRequired) {
await BillDogAuth.attempt({
identifier: '[email protected]',
strategy: 'totp',
code,
mfaToken: r.mfaToken, // proves the first factor was accepted
});
}Passwordless, and phone numbers
// A RETURNING user gets a fresh code from requestCode. Without it a code could
// only be obtained as a side effect of signing up.
await BillDogAuth.requestCode({ identifier: '+15551234567', kind: 'phone' });
await BillDogAuth.attempt({
identifier: '+15551234567',
kind: 'phone', // REQUIRED — selects the identity row
strategy: 'sms_code',
code,
});kind defaults to 'email'. A phone sign-in that omits it looks up a phone
number among email addresses and silently matches nothing.
The rest of the surface
isSignedIn() · signUp({ identifier, kind?, password? }) · signOut('this' | 'others' | 'all') ·
passkeysAvailable() · registerPasskey(label?) · signInWithPasskey() ·
requestPasswordReset({ identifier, kind? }) · resetPassword({ identifier, kind?, code, newPassword }) ·
changePassword({ currentPassword, newPassword }) · deleteAccount() ·
BillDogAuth.organizations — create, list, members, roles, invitations, domains.
Deleting an account
import { BillDogAuth, BillDogApiError } from '@billdog.io/react-native';
try {
const { deleted } = await BillDogAuth.deleteAccount();
} catch (e) {
if (e instanceof BillDogApiError && e.isReauthRequired) {
// The sign-in is too old for this. Ask the person to sign in again,
// then call deleteAccount() again.
}
}The local session is cleared only when the server confirmed the deletion. Any
refusal leaves the person signed in and rejects with a BillDogApiError
carrying the server's code.
What this SDK cannot do
- Sign up into a project with bot protection or
invite_onlyaccess.signUptakes no captcha or invitation token, and there is noconfig()to discover that one is required — the server answers 400. Use hosted sign-in for those projects until the parameters ship.
Supported Component Types
text- Text labels and headingsimage- Images and iconsbutton- Call-to-action buttonsstack- Container for layout (vertical/horizontal)package- Subscription package displayfeature-list- List of features with checkmarksprice- Price display with currencybadge- Small labels (e.g., "Popular")divider- Visual separatorspacer- Empty space for layouticon- Icon componentsvideo- Video players
Theme Support
Paywalls support custom themes with colors and fonts:
const config: PaywallConfiguration = {
components: [...],
theme: {
colors: {
primary: '#007AFF',
background: '#FFFFFF',
text: '#000000',
},
fonts: {
body: 'System',
heading: 'System-Bold',
},
},
};Variable Substitution
Use {{variableName}} in text to inject dynamic values:
const config: PaywallConfiguration = {
components: [
{
id: 'welcome',
type: 'text',
properties: {
text: 'Welcome, {{userName}}!',
},
},
],
variables: {
userName: 'John Doe',
},
};License
MIT
