@billingbear/purchases
v0.9.5
Published
Cross-platform subscription management SDK for React Native — with remote-configured paywalls
Downloads
1,744
Readme
@billingbear/purchases
Cross-platform subscription management SDK for React Native. Handles Apple, Google, and Stripe subscriptions with anonymous-first user management.
Plug & play — wraps react-native-iap internally. You never touch IAP code directly.
Install
npm install @billingbear/purchases react-native-iap @react-native-async-storage/async-storageQuick Start
import { Purchases } from '@billingbear/purchases';
// 1. Configure at app startup
await Purchases.configure({
apiKey: 'pk_live_...',
productIds: ['com.myapp.premium'], // or ['com.myapp.premiumMonthly', 'com.myapp.premiumYearly']
});
// 2. Show products
const offering = await Purchases.getOffering();
console.log(offering.monthly?.product.localizedPrice); // "$2.99"
// 3. Purchase — one line, handles everything
const result = await Purchases.purchase(offering.monthly!);
console.log(result.customerInfo.isPremium); // trueThat's it. The SDK handles store connection, purchase flow, receipt validation, and entitlement sync.
Full API
Configure
await Purchases.configure({
apiKey: 'pk_live_...', // Required
baseUrl: 'https://custom.url', // Optional
appUserId: 'known-user-id', // Optional, defaults to anonymous
productIds: ['com.myapp.premium'], // Optional, fetches products on init
});Products & Offerings
// Raw products
const products = await Purchases.getProducts(['com.myapp.premium']);
// Organized as offering with monthly/yearly
const offering = await Purchases.getOffering();
offering.monthly // { identifier: 'monthly', product: { localizedPrice: '$2.99', ... } }
offering.yearly // { identifier: 'yearly', product: { localizedPrice: '$29.99', ... } }Purchase
// Purchase a package from offering
const result = await Purchases.purchase(offering.monthly!);
// Or purchase a product directly
const result = await Purchases.purchase(products[0]);
// Result
result.customerInfo.isPremium // true
result.productId // 'com.myapp.premiumMonthly'
result.transactionId // '1000000...'Restore Purchases
const info = await Purchases.restore();
// Finds all store purchases and validates them with the backend
// Transfers subscriptions if they belong to another userCheck Entitlements
const info = await Purchases.getCustomerInfo();
info.isPremium // true/false
info.entitlements.premium?.isActive // true/false
info.activeSubscription?.store // 'apple', 'google', 'stripe'
info.activeSubscription?.expiresAt // '2026-12-31T23:59:59Z'
// Shortcuts
await Purchases.isPremium() // true/false
await Purchases.checkEntitlement('pro') // true/falseLogin / Logout
// After your auth system confirms the user
await Purchases.login('firebase-uid-123');
// Merges anonymous purchases into the identified user
// On logout
await Purchases.logout();
// New anonymous ID, identified user keeps everything
// Check
Purchases.isAnonymous // true/false
Purchases.appUserId // current IDWeb Checkout (Stripe)
const { url } = await Purchases.createCheckoutSession({
priceId: 'price_1ABC...',
successUrl: 'https://myapp.com/success',
cancelUrl: 'https://myapp.com/cancel',
});
// Open url in browser — payment processed via Stripe webhookReact Hooks
import { usePurchases, useEntitlement } from '@billingbear/purchases';
function PaywallScreen() {
const { isPremium, loading, refetch } = usePurchases();
if (loading) return <ActivityIndicator />;
if (isPremium) return <PremiumContent />;
return <Paywall />;
}
function FeatureGate({ children }) {
const { isActive } = useEntitlement('premium');
if (!isActive) return <UpgradePrompt />;
return children;
}Listen for Changes
const unsubscribe = Purchases.addListener((info) => {
console.log('Premium:', info.isPremium);
});Cleanup
// On app unmount
await Purchases.dispose();Android Base Plans
For Android apps using a single product with base plans:
await Purchases.configure({
apiKey: 'pk_live_...',
productIds: ['com.myapp.premium'], // Single product ID
});
const offering = await Purchases.getOffering();
// Base plans are automatically extracted:
offering.monthly // { product: { offerToken: '...', period: 'monthly' } }
offering.yearly // { product: { offerToken: '...', period: 'yearly' } }
// offerToken is passed automatically during purchase
await Purchases.purchase(offering.monthly!);How It Works
Your App SDK Backend
│ │ │
├─ configure() ──────────►│── init IAP connection │
│ │── fetch products │
│ │── fetch entitlements ──────►│
│ │◄─────────── customerInfo ──│
│ │ │
├─ purchase(monthly) ────►│── requestPurchase (store) │
│ │◄─ store confirms purchase │
│ │── validate receipt ────────►│── validate with Apple/Google
│ │◄─────────── customerInfo ──│── update entitlements
│ │── finishTransaction │
│◄─── PurchaseResult ────│ │
│ │ │
├─ login('user-123') ───►│── merge anonymous → user ──►│── transfer subscriptions
│◄─── customerInfo ──────│◄─────────── merged ────────│Native Paywall (SwiftUI / Jetpack Compose)
<Paywall> renders the native paywall — SwiftUI on iOS, Jetpack Compose on Android — when the
SDK's autolinked native module is present in your build, and transparently falls back to the JS
renderer on Expo Go, react-native-web, SSR and unit tests. Same component, same props, zero JS
rendering dependencies. This is the same approach RevenueCat's react-native-purchases-ui uses to
bridge to RevenueCatUI.
import { Paywall } from '@billingbear/purchases';
// Renders native on a real iOS/Android build, JS elsewhere — no code change needed.
<Paywall
identifier="premium-default"
onPurchaseCompleted={() => navigation.goBack()}
onClose={() => navigation.goBack()}
/>You can also branch on availability or render native explicitly:
import { NativePaywall, isNativePaywallAvailable } from '@billingbear/purchases';
isNativePaywallAvailable(); // true on a linked native build
<NativePaywall identifier="…" />; // force the native view
<Paywall identifier="…" forceJs />; // force the JS renderer (e.g. visual regression)How the bridge works: the JS layer fetches the published paywall (stale-while-revalidate) and hands
the document to the native renderer, which draws it pixel-native. When the user taps the CTA the
native view fires onPurchase back to JS, where the configured Purchases runs the real
StoreKit/Play purchase and conversion telemetry — so all billing stays in JS where Purchases is
configured.
iOS install
The native paywall view is autolinked via the umbrella podspec (BillingBearPurchases.podspec),
which depends on the standalone BillingBear pod (the SwiftUI renderer). Add the renderer to
your app's ios/Podfile, then pod install:
# ios/Podfile (monorepo / local SDK)
pod 'BillingBear', :path => '../node_modules/@billingbear/purchases/../../sdk/ios'
# …or once BillingBear is published to CocoaPods trunk, no Podfile line is needed.BillingBear pulls Lottie transitively via the lottie-ios pod (animated paywall backgrounds
render out of the box). Minimum iOS 15.
Android install
The native paywall view is autolinked via BillingBearPackage. The Gradle library depends on the
Android renderer (io.billingbear:purchases), which resolves from Maven Central — no extra
setup for published SDK versions. For monorepo development against an unpublished renderer build,
publish it to your local Maven repo first:
# from sdk/android
./gradlew publishToMavenLocal # publishes io.billingbear:purchases:<version>Host app requirement (SDK ≥ 0.8): the SDK's android module hosts the Compose paywall renderer
and applies org.jetbrains.kotlin.plugin.compose, which must be on the host's buildscript
classpath or the build fails with Plugin with id 'org.jetbrains.kotlin.plugin.compose' not found.
Add it next to the Kotlin plugin in your root android/build.gradle:
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion") // ← required
}
}The renderer bundles Compose, Coil, Media3/ExoPlayer (video backgrounds) and lottie-compose transitively — animated paywalls work with no extra setup. Minimum SDK 24, JVM target 17.
Verification: the JS (
tsc+jest) and the native Kotlin/Swift sources all compile headless. Final on-device rendering (the SwiftUIUIHostingControllerhost and the ComposeComposeViewhost) must be verified on a simulator/emulator with a host app, since the RN view managers link againstReact-Core/react-androidwhich only resolve inside a built app.
