Skip to main content

Overview

By default, Superwall handles purchases through the platform’s native payment systems. However, you may need to integrate with a custom payment provider or subscription management service. The CustomPurchaseControllerProvider allows you to implement your own purchase and restore logic while still leveraging Superwall’s paywall presentation and analytics.

When to Use Custom Purchase Handling

Use custom purchase handling when you need to:
  • Integrate with RevenueCat, Qonversion, or other subscription platforms
  • Implement custom payment flows or validation
  • Add server-side purchase verification
  • Support alternative payment methods
  • Track purchases in your own analytics system
  • Handle complex subscription logic

Setup

1

Install your payment provider

First, install your payment provider SDK. For this example, we’ll use RevenueCat:
npm install react-native-purchases
2

Create a purchase controller

Create an object that implements the purchase controller interface:
import Purchases from 'react-native-purchases';

const purchaseController = {
  async onPurchase(params) {
    const { productId, paywallInfo } = params;
    
    try {
      const result = await Purchases.purchaseProduct(productId);
      
      // Check if purchase granted entitlements
      if (Object.keys(result.customerInfo.entitlements.active).length > 0) {
        return { type: "purchased" };
      }
      
      return { type: "failed", error: "No entitlements granted" };
    } catch (error) {
      if (error.userCancelled) {
        return { type: "cancelled" };
      }
      return { type: "failed", error: error.message };
    }
  },
  
  async onPurchaseRestore() {
    try {
      const info = await Purchases.restorePurchases();
      
      if (Object.keys(info.entitlements.active).length > 0) {
        return { type: "restored" };
      }
      
      return { type: "failed", error: "No purchases to restore" };
    } catch (error) {
      return { type: "failed", error: error.message };
    }
  }
};
3

Wrap your app

Add the CustomPurchaseControllerProvider to your component tree, inside SuperwallProvider:
import { SuperwallProvider, CustomPurchaseControllerProvider } from 'expo-superwall';

export default function App() {
  return (
    <SuperwallProvider apiKeys={{ ios: "YOUR_API_KEY" }}>
      <CustomPurchaseControllerProvider controller={purchaseController}>
        <YourApp />
      </CustomPurchaseControllerProvider>
    </SuperwallProvider>
  );
}
4

Sync subscription status

After purchases, update Superwall’s subscription status to enable proper feature gating:
import { useUser } from 'expo-superwall';
import Purchases from 'react-native-purchases';

function App() {
  const { setSubscriptionStatus } = useUser();
  
  useEffect(() => {
    const syncStatus = async () => {
      const info = await Purchases.getCustomerInfo();
      const hasActive = Object.keys(info.entitlements.active).length > 0;
      
      await setSubscriptionStatus({
        status: hasActive ? "ACTIVE" : "INACTIVE",
        entitlements: hasActive 
          ? Object.keys(info.entitlements.active).map(id => ({
              id,
              type: "SERVICE_LEVEL"
            }))
          : undefined
      });
    };
    
    syncStatus();
  }, []);
  
  return <YourAppContent />;
}

Purchase Controller Interface

Your controller must implement two methods:

onPurchase

Called when a user initiates a purchase from a paywall.
params
OnPurchaseParams
Returns: Promise<PurchaseResult | void>
return { type: "purchased" };
// or just return void for implicit success

onPurchaseRestore

Called when a user initiates a restore purchases action. Returns: Promise<RestoreResult | void>
return { type: "restored" };
// or just return void for implicit success

Complete Examples

RevenueCat Integration

import { SuperwallProvider, CustomPurchaseControllerProvider, useUser } from 'expo-superwall';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { useEffect } from 'react';

// Initialize RevenueCat
Purchases.setLogLevel(LOG_LEVEL.DEBUG);

const purchaseController = {
  async onPurchase(params) {
    const { productId, paywallInfo } = params;
    
    console.log(`Purchasing ${productId} from paywall "${paywallInfo.name}"`);
    
    try {
      // Make the purchase
      const { customerInfo } = await Purchases.purchaseProduct(productId);
      
      // Verify entitlements were granted
      const entitlements = Object.keys(customerInfo.entitlements.active);
      
      if (entitlements.length > 0) {
        console.log('Purchase successful, entitlements:', entitlements);
        return { type: "purchased" };
      }
      
      console.error('Purchase completed but no entitlements granted');
      return { type: "failed", error: "Entitlement verification failed" };
      
    } catch (error) {
      // Handle cancellation
      if (error.userCancelled) {
        console.log('User cancelled the purchase');
        return { type: "cancelled" };
      }
      
      // Handle other errors
      console.error('Purchase error:', error);
      return { 
        type: "failed", 
        error: error.message || "Unknown error occurred" 
      };
    }
  },
  
  async onPurchaseRestore() {
    console.log('Restoring purchases...');
    
    try {
      const { customerInfo } = await Purchases.restorePurchases();
      const entitlements = Object.keys(customerInfo.entitlements.active);
      
      if (entitlements.length > 0) {
        console.log('Restore successful, entitlements:', entitlements);
        return { type: "restored" };
      }
      
      console.log('No active subscriptions found');
      return { 
        type: "failed", 
        error: "No active subscriptions to restore" 
      };
      
    } catch (error) {
      console.error('Restore error:', error);
      return { 
        type: "failed", 
        error: error.message || "Restore failed" 
      };
    }
  }
};

function AppContent() {
  const { setSubscriptionStatus } = useUser();
  
  // Sync RevenueCat status with Superwall
  useEffect(() => {
    const syncSubscriptionStatus = async () => {
      try {
        const customerInfo = await Purchases.getCustomerInfo();
        const activeEntitlements = Object.keys(customerInfo.entitlements.active);
        
        if (activeEntitlements.length > 0) {
          await setSubscriptionStatus({
            status: "ACTIVE",
            entitlements: activeEntitlements.map(id => ({
              id,
              type: "SERVICE_LEVEL"
            }))
          });
        } else {
          await setSubscriptionStatus({ status: "INACTIVE" });
        }
      } catch (error) {
        console.error('Failed to sync subscription status:', error);
      }
    };
    
    syncSubscriptionStatus();
    
    // Listen for purchase updates
    const listener = Purchases.addCustomerInfoUpdateListener(syncSubscriptionStatus);
    
    return () => {
      listener.remove();
    };
  }, [setSubscriptionStatus]);
  
  return <YourApp />;
}

export default function App() {
  useEffect(() => {
    // Configure RevenueCat
    Purchases.configure({
      apiKey: Platform.select({
        ios: 'YOUR_REVENUECAT_IOS_KEY',
        android: 'YOUR_REVENUECAT_ANDROID_KEY'
      })
    });
  }, []);
  
  return (
    <SuperwallProvider apiKeys={{ 
      ios: "YOUR_SUPERWALL_IOS_KEY",
      android: "YOUR_SUPERWALL_ANDROID_KEY"
    }}>
      <CustomPurchaseControllerProvider controller={purchaseController}>
        <AppContent />
      </CustomPurchaseControllerProvider>
    </SuperwallProvider>
  );
}

Server-Side Verification

const purchaseController = {
  async onPurchase(params) {
    const { productId, paywallInfo } = params;
    
    try {
      // First, make the purchase
      const receipt = await InAppPurchases.purchaseItemAsync(productId);
      
      // Verify with your backend
      const response = await fetch('https://api.yourapp.com/verify-purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          receipt,
          productId,
          paywallName: paywallInfo.name
        })
      });
      
      const result = await response.json();
      
      if (result.verified) {
        return { type: "purchased" };
      }
      
      return { 
        type: "failed", 
        error: "Server verification failed" 
      };
      
    } catch (error) {
      return { type: "failed", error: error.message };
    }
  },
  
  async onPurchaseRestore() {
    try {
      // Get purchase history
      const { results } = await InAppPurchases.getPurchaseHistoryAsync();
      
      // Verify with backend
      const response = await fetch('https://api.yourapp.com/restore', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ receipts: results })
      });
      
      const result = await response.json();
      
      if (result.hasActiveSubscription) {
        return { type: "restored" };
      }
      
      return { 
        type: "failed", 
        error: "No active subscriptions" 
      };
      
    } catch (error) {
      return { type: "failed", error: error.message };
    }
  }
};

Error Handling

Always handle errors gracefully and return informative error messages:
async onPurchase(params) {
  try {
    const result = await performPurchase(params.productId);
    
    if (result.success) {
      return { type: "purchased" };
    }
    
    // Specific error handling
    if (result.error === 'insufficient_funds') {
      return { 
        type: "failed", 
        error: "Payment method declined. Please check your payment method." 
      };
    }
    
    return { 
      type: "failed", 
      error: result.error || "Unknown error" 
    };
    
  } catch (error) {
    // Network errors
    if (error.message.includes('network')) {
      return { 
        type: "failed", 
        error: "Network error. Please check your connection." 
      };
    }
    
    // User cancellation
    if (error.userCancelled || error.code === 'E_USER_CANCELLED') {
      return { type: "cancelled" };
    }
    
    // Generic error
    return { 
      type: "failed", 
      error: error.message || "Purchase failed" 
    };
  }
}

Best Practices

Log Everything

Add comprehensive logging to debug purchase flows in production

Sync Status

Always sync subscription status after successful purchases

Handle Errors

Provide clear error messages for better user experience

Test Thoroughly

Test all purchase flows including success, failure, and cancellation
Always test purchases in sandbox/test mode before going to production:
// RevenueCat example
await Purchases.configure({
  apiKey: __DEV__ ? 'sandbox_key' : 'production_key'
});
Track purchase failures to identify issues:
if (result.type === "failed") {
  analytics.track('purchase_failed', {
    productId: params.productId,
    error: result.error,
    paywallName: params.paywallInfo.name
  });
}
Implement retry logic for network-related failures:
const MAX_RETRIES = 3;
let retries = 0;

while (retries < MAX_RETRIES) {
  try {
    return await performPurchase(productId);
  } catch (error) {
    if (error.isNetworkError && retries < MAX_RETRIES - 1) {
      retries++;
      await delay(1000 * retries); // Exponential backoff
      continue;
    }
    throw error;
  }
}

CustomPurchaseControllerProvider

API reference for the purchase controller component

Handling Subscriptions

Learn about subscription status management

PurchaseController (Compat)

Compat SDK purchase controller interface

useUser

User and subscription management hook

Build docs developers (and LLMs) love