npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ridwan-retainer/paywall

v0.1.2

Published

Revenue-first monetization layer using RevenueCat for in-app purchases and subscriptions

Downloads

282

Readme

@ridwan-retainer/paywall

A complete, production-ready monetization solution for React Native apps using RevenueCat. Implements in-app purchases, subscriptions, paywalls, and entitlement management with a developer-friendly API.

npm version License: MIT

📋 Table of Contents

✨ Features

  • 💰 RevenueCat Integration: Full SDK integration with cross-platform support
  • 🎨 Pre-built Components: Ready-to-use paywall, purchase buttons, and subscription gates
  • 🔐 Entitlement Management: Automatic entitlement checking and caching
  • ♻️ Restore Purchases: Built-in restore functionality with error handling
  • 📦 Offering Management: Easy access to products and packages
  • 🎯 Feature Gates: Control feature access based on subscriptions
  • ⚡ Performance: Optimized caching and minimal re-renders
  • 🪝 React Hooks: Modern hooks-based API
  • 📝 TypeScript: Full type safety
  • ✅ Well-tested: Comprehensive test coverage

📦 Installation

npm install @ridwan-retainer/paywall

Peer Dependencies

npm install react-native-purchases \
            react-native-purchases-ui \
            @react-native-async-storage/async-storage \
            react \
            react-native

Platform Setup

iOS Setup

  1. Install CocoaPods dependencies:
cd ios && pod install
  1. Configure App Store Connect:

    • Create your app in App Store Connect
    • Set up in-app products and subscriptions
    • Submit for review
  2. Add StoreKit configuration (for testing in Xcode):

    • File → New → File → StoreKit Configuration File
    • Add your products for local testing

Android Setup

  1. Configure Google Play Console:

    • Create your app in Google Play Console
    • Set up in-app products and subscriptions
    • Add test users for testing
  2. Add billing permission in android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="com.android.vending.BILLING" />

RevenueCat Setup

  1. Create RevenueCat account: https://app.revenuecat.com/signup
  2. Create a project and configure your app
  3. Link App Store/Google Play:
    • iOS: Add App Store Connect API Key
    • Android: Add Google Play Service Account JSON
  4. Set up products:
    • Create offerings
    • Add products to offerings
    • Configure entitlements
  5. Get API keys:
    • Settings → API Keys
    • Copy iOS and Android keys

🚀 Quick Start

1. Configure Environment Variables

Create a .env file:

EXPO_PUBLIC_REVENUECAT_IOS=your_ios_api_key
EXPO_PUBLIC_REVENUECAT_ANDROID=your_android_api_key

2. Initialize RevenueCat

import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
import { useEffect } from 'react';

function App() {
  const { isInitialized, error } = useRevenueCatInitialization({
    appUserID: 'user_123', // Optional: your user ID
  });

  if (error) {
    console.error('RevenueCat initialization failed:', error);
  }

  return isInitialized ? <YourApp /> : <LoadingScreen />;
}

3. Display a Paywall

import { Paywall } from '@ridwan-retainer/paywall';

function PaywallScreen() {
  return (
    <Paywall
      onPurchaseCompleted={({ customerInfo }) => {
        console.log('Purchase successful!', customerInfo);
        // Navigate away or show success message
      }}
      onDismiss={() => {
        console.log('Paywall dismissed');
      }}
      onPurchaseError={({ error }) => {
        console.error('Purchase failed:', error);
      }}
    />
  );
}

4. Gate Features

import { SubscriptionGate } from '@ridwan-retainer/paywall';

function PremiumFeature() {
  return (
    <SubscriptionGate>
      <Text>This is premium content!</Text>
      <PremiumFeatureContent />
    </SubscriptionGate>
  );
}

🧠 Core Concepts

Offerings and Packages

Offerings are collections of products (packages) you want to sell. Create them in RevenueCat dashboard.

Packages are individual subscription or purchase options (e.g., Monthly, Annual).

import { useCurrentOffering } from '@ridwan-retainer/paywall';

function ProductList() {
  const { offering, isLoading } = useCurrentOffering();

  if (isLoading) return <Loading />;

  return (
    <View>
      {offering?.availablePackages.map((pkg) => (
        <Text key={pkg.identifier}>
          {pkg.product.title} - {pkg.product.priceString}
        </Text>
      ))}
    </View>
  );
}

Entitlements

Entitlements represent access to features (configured in RevenueCat dashboard).

import { useEntitlement } from '@ridwan-retainer/paywall';

function PremiumButton() {
  const { hasEntitlement, isLoading } = useEntitlement('premium');

  return (
    <Button disabled={!hasEntitlement}>
      {hasEntitlement ? 'Premium Feature' : 'Upgrade to Access'}
    </Button>
  );
}

Customer Info

Customer Info contains all subscription and purchase data for a user.

import { useCustomerInfo } from '@ridwan-retainer/paywall';

function SubscriptionStatus() {
  const { customerInfo, isLoading } = useCustomerInfo();

  const activeSub = customerInfo?.activeSubscriptions[0];

  return (
    <Text>
      Status: {activeSub ? 'Subscribed' : 'Free'}
    </Text>
  );
}

📚 API Reference

Initialization

useRevenueCatInitialization(config?: RevenueCatConfig)

Initialize RevenueCat SDK. Call once at app startup.

import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';

function App() {
  const { isInitialized, error } = useRevenueCatInitialization({
    appUserID: 'user_123',           // Optional: Custom user ID
    observerMode: false,             // Optional: Observer mode (default: false)
    useAmazon: false,                // Optional: Use Amazon store (default: false)
  });

  return isInitialized ? <Main /> : <Splash />;
}

Returns:

{
  isInitialized: boolean;
  error: Error | null;
}

initializeRevenueCat(apiKey: string, config?: RevenueCatConfig): Promise<void>

Programmatic initialization (alternative to hook).

import { initializeRevenueCat } from '@ridwan-retainer/paywall';

await initializeRevenueCat('rcb_abc123', {
  appUserID: 'user_123',
});

Hooks

useOfferings()

Get all available offerings.

import { useOfferings } from '@ridwan-retainer/paywall';

function OfferingsList() {
  const { offerings, currentOffering, isLoading, error } = useOfferings();

  if (isLoading) return <Loading />;
  if (error) return <Error message={error.message} />;

  return (
    <View>
      {offerings?.all && Object.values(offerings.all).map((offering) => (
        <OfferingCard key={offering.identifier} offering={offering} />
      ))}
    </View>
  );
}

Returns:

{
  offerings: PurchasesOfferings | null;
  currentOffering: PurchasesOffering | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}

useCurrentOffering()

Get the current (default) offering.

import { useCurrentOffering } from '@ridwan-retainer/paywall';

function SubscriptionPlans() {
  const { offering, isLoading } = useCurrentOffering();

  return (
    <View>
      {offering?.availablePackages.map((pkg) => (
        <PlanCard key={pkg.identifier} package={pkg} />
      ))}
    </View>
  );
}

Returns:

{
  offering: PurchasesOffering | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}

useCustomerInfo()

Get customer subscription information.

import { useCustomerInfo } from '@ridwan-retainer/paywall';

function AccountScreen() {
  const { customerInfo, isLoading, refresh } = useCustomerInfo();

  const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
  const expirationDate = customerInfo?.entitlements.active['premium']?.expirationDate;

  return (
    <View>
      <Text>Status: {isPremium ? 'Premium' : 'Free'}</Text>
      {expirationDate && (
        <Text>Expires: {new Date(expirationDate).toLocaleDateString()}</Text>
      )}
      <Button onPress={refresh}>Refresh</Button>
    </View>
  );
}

Returns:

{
  customerInfo: CustomerInfo | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}

useEntitlement(entitlementId: string)

Check if user has a specific entitlement.

import { useEntitlement } from '@ridwan-retainer/paywall';

function PremiumFeatureButton() {
  const { hasEntitlement, isLoading, entitlementInfo } = useEntitlement('premium');

  if (isLoading) return <Loading />;

  return (
    <Button disabled={!hasEntitlement}>
      {hasEntitlement ? 'Access Premium' : 'Upgrade Required'}
    </Button>
  );
}

Returns:

{
  hasEntitlement: boolean;
  entitlementInfo: PurchasesEntitlementInfo | null;
  isLoading: boolean;
  error: Error | null;
}

usePurchase()

Handle purchase flow.

import { usePurchase } from '@ridwan-retainer/paywall';

function BuyButton({ package: pkg }) {
  const { purchase, isPurchasing, error } = usePurchase();

  const handlePurchase = async () => {
    try {
      const result = await purchase(pkg);
      console.log('Purchase successful:', result);
      // Show success message
    } catch (err) {
      console.error('Purchase failed:', err);
      // Show error message
    }
  };

  return (
    <Button onPress={handlePurchase} disabled={isPurchasing}>
      {isPurchasing ? 'Processing...' : `Buy ${pkg.product.priceString}`}
    </Button>
  );
}

Returns:

{
  purchase: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
  isPurchasing: boolean;
  error: PurchaseError | null;
}

useRestorePurchases()

Restore previous purchases.

import { useRestorePurchases } from '@ridwan-retainer/paywall';

function RestoreButton() {
  const { restore, isRestoring, error } = useRestorePurchases();

  const handleRestore = async () => {
    try {
      const result = await restore();
      if (result.customerInfo.activeSubscriptions.length > 0) {
        Alert.alert('Success', 'Purchases restored!');
      } else {
        Alert.alert('No Purchases', 'No purchases found to restore.');
      }
    } catch (err) {
      Alert.alert('Error', 'Failed to restore purchases');
    }
  };

  return (
    <Button onPress={handleRestore} disabled={isRestoring}>
      {isRestoring ? 'Restoring...' : 'Restore Purchases'}
    </Button>
  );
}

Returns:

{
  restore: () => Promise<RestoreResult>;
  isRestoring: boolean;
  error: RestoreError | null;
}

usePaywall()

Access paywall controller for programmatic control.

import { usePaywall } from '@ridwan-retainer/paywall';

function CustomPaywall() {
  const { showPaywall, dismissPaywall } = usePaywall();

  const handleShowPaywall = () => {
    showPaywall({
      onPurchaseCompleted: (info) => {
        console.log('Purchase completed:', info);
        dismissPaywall();
      },
      onDismiss: () => {
        console.log('Paywall dismissed');
      },
    });
  };

  return <Button onPress={handleShowPaywall}>Upgrade</Button>;
}

Components

<Paywall />

Full-screen RevenueCat paywall UI.

import { Paywall } from '@ridwan-retainer/paywall';

<Paywall
  onPurchaseStarted={({ packageBeingPurchased }) => {
    console.log('Starting purchase:', packageBeingPurchased);
  }}
  onPurchaseCompleted={({ customerInfo, storeTransaction }) => {
    console.log('Purchase completed!');
    navigation.goBack();
  }}
  onPurchaseError={({ error }) => {
    Alert.alert('Purchase Failed', error.message);
  }}
  onPurchaseCancelled={() => {
    console.log('User cancelled purchase');
  }}
  onRestoreCompleted={({ customerInfo }) => {
    Alert.alert('Success', 'Purchases restored!');
  }}
  onRestoreError={({ error }) => {
    Alert.alert('Error', 'Restore failed');
  }}
  onDismiss={() => {
    navigation.goBack();
  }}
/>

Props:

{
  onPurchaseStarted?: (data: { packageBeingPurchased: PurchasesPackage }) => void;
  onPurchaseCompleted?: (data: { customerInfo: CustomerInfo; storeTransaction: any }) => void;
  onPurchaseError?: (data: { error: PurchasesError }) => void;
  onPurchaseCancelled?: () => void;
  onRestoreCompleted?: (data: { customerInfo: CustomerInfo }) => void;
  onRestoreError?: (data: { error: PurchasesError }) => void;
  onDismiss?: () => void;
}

<SubscriptionGate />

Show content only to subscribers.

import { SubscriptionGate } from '@ridwan-retainer/paywall';

<SubscriptionGate>
  <PremiumContent />
</SubscriptionGate>

// Automatically redirects non-subscribers to paywall

<PaywallGate />

Control feature access with modal paywall.

import { PaywallGate } from '@ridwan-retainer/paywall';

<PaywallGate
  entitlementId="premium"
  onAccessGranted={() => {
    console.log('User now has access!');
  }}
>
  <View>
    <Text>Premium Feature</Text>
    <Button title="Click to Access" />
  </View>
</PaywallGate>

Props:

{
  entitlementId: string;
  children: ReactNode;
  onAccessGranted?: () => void;
}

<PurchaseButton />

Pre-styled purchase button.

import { PurchaseButton } from '@ridwan-retainer/paywall';

function SubscriptionPlan({ package: pkg }) {
  return (
    <PurchaseButton
      package={pkg}
      title={`Subscribe for ${pkg.product.priceString}`}
      onSuccess={() => {
        Alert.alert('Welcome!', 'You are now subscribed!');
      }}
      onError={(error) => {
        Alert.alert('Error', error.message);
      }}
      style={{ backgroundColor: '#007AFF', padding: 16 }}
    />
  );
}

Props:

{
  package: PurchasesPackage;
  onSuccess?: () => void;
  onError?: (error: Error) => void;
  title?: string;
  style?: ViewStyle;
}

<RestoreButton />

Pre-styled restore purchases button.

import { RestoreButton } from '@ridwan-retainer/paywall';

<RestoreButton
  onSuccess={() => {
    Alert.alert('Success', 'Purchases restored!');
  }}
  onError={(error) => {
    Alert.alert('Error', error.message);
  }}
  title="Restore Purchases"
  style={{ padding: 12 }}
/>

Props:

{
  onSuccess?: () => void;
  onError?: (error: Error) => void;
  title?: string;
  style?: ViewStyle;
}

<PaywallFooter />

Displays legal text required for App Store compliance.

import { PaywallFooter } from '@ridwan-retainer/paywall';

<PaywallFooter
  termsUrl="https://yourapp.com/terms"
  privacyUrl="https://yourapp.com/privacy"
/>

Managers

For advanced use cases, you can use managers directly:

offeringsManager

import { offeringsManager } from '@ridwan-retainer/paywall';

// Fetch offerings
const offerings = await offeringsManager.fetchOfferings();

// Get cached offerings
const cached = offeringsManager.getCachedOfferings();

customerInfoManager

import { customerInfoManager } from '@ridwan-retainer/paywall';

// Get customer info
const info = await customerInfoManager.getCustomerInfo();

// Check entitlement
const hasAccess = await customerInfoManager.hasEntitlement('premium');

purchaseManager

import { purchaseManager } from '@ridwan-retainer/paywall';

// Make purchase
const result = await purchaseManager.purchasePackage(package);

restoreManager

import { restoreManager } from '@ridwan-retainer/paywall';

// Restore purchases
const result = await restoreManager.restorePurchases();

💡 Complete Examples

Example 1: Subscription Paywall Screen

import React from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import {
  useCurrentOffering,
  PurchaseButton,
  RestoreButton,
  PaywallFooter,
} from '@ridwan-retainer/paywall';

function SubscriptionScreen() {
  const { offering, isLoading } = useCurrentOffering();

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (!offering) {
    return <ErrorView message="No plans available" />;
  }

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>Upgrade to Premium</Text>
      <Text style={styles.subtitle}>
        Unlock all features and support development
      </Text>

      <View style={styles.features}>
        <FeatureRow icon="✓" text="Unlimited access" />
        <FeatureRow icon="✓" text="Priority support" />
        <FeatureRow icon="✓" text="Remove ads" />
        <FeatureRow icon="✓" text="Cloud sync" />
      </View>

      {offering.availablePackages.map((pkg) => (
        <View key={pkg.identifier} style={styles.plan}>
          <Text style={styles.planTitle}>{pkg.product.title}</Text>
          <Text style={styles.planPrice}>{pkg.product.priceString}</Text>
          <PurchaseButton
            package={pkg}
            title="Subscribe"
            style={styles.button}
            onSuccess={() => {
              navigation.goBack();
            }}
          />
        </View>
      ))}

      <RestoreButton
        title="Restore Purchases"
        style={styles.restoreButton}
      />

      <PaywallFooter
        termsUrl="https://yourapp.com/terms"
        privacyUrl="https://yourapp.com/privacy"
      />
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
  subtitle: { fontSize: 16, color: '#666', textAlign: 'center', marginTop: 8 },
  features: { marginVertical: 24 },
  plan: { marginVertical: 12, padding: 16, borderWidth: 1, borderRadius: 8 },
  button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, marginTop: 12 },
  restoreButton: { marginTop: 16, padding: 12 },
});

Example 2: Feature Gating

import { useEntitlement } from '@ridwan-retainer/paywall';

function AdvancedSettings() {
  const { hasEntitlement, isLoading } = useEntitlement('premium');

  if (isLoading) return <Loading />;

  return (
    <View>
      <SettingItem title="Basic Setting" />
      <SettingItem title="Another Setting" />
      
      {hasEntitlement ? (
        <>
          <SettingItem title="Premium Setting 1" />
          <SettingItem title="Premium Setting 2" />
          <SettingItem title="Advanced Options" />
        </>
      ) : (
        <UpgradePrompt
          message="Unlock advanced settings with Premium"
          onPress={() => navigation.navigate('Subscription')}
        />
      )}
    </View>
  );
}

Example 3: Usage-Based Limits

import { useCustomerInfo } from '@ridwan-retainer/paywall';
import { useState, useEffect } from 'react';

function ExportFeature() {
  const { customerInfo } = useCustomerInfo();
  const [exportCount, setExportCount] = useState(0);
  
  const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
  const maxExports = isPremium ? Infinity : 3;
  const canExport = exportCount < maxExports;

  const handleExport = async () => {
    if (!canExport && !isPremium) {
      navigation.navigate('Subscription');
      return;
    }

    await performExport();
    setExportCount(prev => prev + 1);
  };

  return (
    <View>
      {!isPremium && (
        <Text>{maxExports - exportCount} exports remaining</Text>
      )}
      <Button
        title="Export"
        onPress={handleExport}
        disabled={!canExport && !isPremium}
      />
    </View>
  );
}

🧪 Testing

Test Purchases

iOS

  1. Use Sandbox test users (App Store Connect → Users and Access → Sandbox Testers)
  2. Sign in with test account on device (Settings → App Store → Sandbox Account)
  3. Make purchases (won't charge real money)

Android

  1. Add test users in Google Play Console
  2. Use license testing or internal testing track
  3. Make purchases with test account

Test in Development

import { initializeRevenueCat } from '@ridwan-retainer/paywall';

// Enable debug logging
await initializeRevenueCat(API_KEY, {
  appUserID: `test_user_${Date.now()}`, // Unique test user
});

Mock for Unit Tests

jest.mock('@ridwan-retainer/paywall', () => ({
  useCustomerInfo: () => ({
    customerInfo: {
      entitlements: {
        active: {
          premium: { isActive: true },
        },
      },
    },
    isLoading: false,
  }),
}));

✅ Best Practices

1. Check Subscription Status Regularly

// On app open or resume
useEffect(() => {
  customerInfoManager.getCustomerInfo({ forceRefresh: true });
}, []);

2. Handle Errors Gracefully

const { purchase, error } = usePurchase();

useEffect(() => {
  if (error) {
    if (error.userCancelled) {
      // User cancelled, no need to show error
      return;
    }
    Alert.alert('Purchase Error', getUserFriendlyErrorMessage(error));
  }
}, [error]);

3. Provide Restore Option

Always show a "Restore Purchases" button for users who reinstalled the app.

4. Cache Appropriately

The library caches offerings and customer info automatically, but you can force refresh:

const { refresh } = useCustomerInfo();

// Force refresh on pull-to-refresh
<ScrollView
  refreshControl={
    <RefreshControl refreshing={isLoading} onRefresh={refresh} />
  }
>

5. Test Both Platforms

Subscription behavior differs between iOS and Android. Test thoroughly on both.

6. Follow App Store Guidelines

  • Show clear pricing
  • Include terms and privacy links
  • Provide restore functionality
  • Handle subscription management

🐛 Troubleshooting

Issue: "Unable to find offerings"

Solution: Check RevenueCat dashboard configuration:

  1. Verify offerings are created
  2. Check products are added to offerings
  3. Ensure API keys are correct

Issue: "Invalid product identifiers"

Solution: Product IDs must match exactly between:

  • App Store Connect / Google Play Console
  • RevenueCat dashboard
  • Your code

Issue: Purchases not working in production

Solution:

  1. Use production API keys (not sandbox)
  2. Verify app is live in stores
  3. Check RevenueCat integration status
  4. Test with real Apple/Google account

Issue: "Could not find customer"

Solution: Initialize RevenueCat before making any calls:

const { isInitialized } = useRevenueCatInitialization();

if (!isInitialized) {
  return <Loading />;
}

Issue: Subscription status not updating

Solution: Force refresh customer info:

const { refresh } = useCustomerInfo();
await refresh();

🤝 Contributing

Contributions welcome! Please submit Pull Requests.

📄 License

MIT © Ridwan Hamid

🔗 Links

📞 Support