AboutBlogContact
Mobile DevelopmentJuly 22, 2025 9 min read 129Updated: June 10, 2026

In-App Purchases in React Native: StoreKit 2, Google Play Billing, and the Hard Parts

AunimedaAunimeda
📋 Table of Contents

In-App Purchases in React Native: StoreKit 2, Google Play Billing, and the Hard Parts

In-app purchases are among the most deceptively complex integrations in mobile development. The surface area is simple: user taps "Subscribe", money changes hands, user gets access. The implementation involves:

  • Two completely different native APIs with different paradigms
  • Server-side receipt validation (both Apple and Google require it)
  • Subscription lifecycle events: renewals, cancellations, grace periods, billing retries, refunds
  • Handling purchase interruptions (payment fails mid-flow, app killed during purchase)
  • App Store and Play Store review that will reject almost any attempt to work around their payment systems

This is the complete guide.


The library: react-native-iap or RevenueCat

Build vs buy decision first:

react-native-iap - open source, free, direct access to StoreKit and Google Play Billing. You handle everything: receipt validation, subscription status, entitlements.

RevenueCat SDK - paid (free tier to $2500/mo revenue), abstracts both stores into one unified API, handles receipt validation, subscription status, and analytics. $99-$499/month after the free tier.

For most apps generating under $10k/month from IAP: RevenueCat is worth the cost. It eliminates weeks of backend work and a class of subscription edge-case bugs that will bite you in production.

For large-scale apps or teams who need full control: react-native-iap.

This guide covers both but focuses on react-native-iap for the technical depth.


Setup

npm install react-native-iap
cd ios && pod install
<!-- android/app/build.gradle -->
dependencies {
    implementation 'com.android.billingclient:billing:6.2.0'
}
// iOS: no extra setup needed - StoreKit 2 is included in iOS 15+
// For iOS 14 and below: react-native-iap falls back to StoreKit 1

Products and subscriptions: the mental model

Apple and Google categorize in-app purchases differently:

Type Apple Google
One-time purchase Non-consumable / Consumable One-time product
Recurring payment Auto-renewable subscription Subscription
Credits/coins Consumable Consumable

Consumables (credits, coins, lives): can be purchased multiple times, deliver value on purchase, server must track balance.

Non-consumables (remove ads, unlock feature): purchased once, user owns forever, restored via "Restore Purchases."

Subscriptions: recurring, have tiers, have trial periods, can be cancelled, can fail to renew (billing retry), have grace periods.

Define your products in both App Store Connect and Google Play Console before writing code. The product IDs you define there are what you reference in code:

com.yourapp.premium_monthly    → matches App Store + Play Store product ID
com.yourapp.premium_annual
com.yourapp.coin_pack_100

Use identical product IDs on both platforms - it simplifies code significantly.


Initialization

import { initConnection, endConnection, getProducts, getSubscriptions,
         requestPurchase, requestSubscription, getPurchaseHistory,
         finishTransaction, purchaseErrorListener, purchaseUpdatedListener } from 'react-native-iap';

const PRODUCT_IDS = ['com.yourapp.coin_pack_100', 'com.yourapp.remove_ads'];
const SUBSCRIPTION_IDS = ['com.yourapp.premium_monthly', 'com.yourapp.premium_annual'];

class IAPManager {
  private purchaseUpdateSubscription: any;
  private purchaseErrorSubscription: any;
  
  async initialize() {
    try {
      await initConnection();
      
      // Set up purchase listeners BEFORE requesting any purchase
      this.purchaseUpdateSubscription = purchaseUpdatedListener(
        async (purchase) => {
          await this.handlePurchaseUpdate(purchase);
        }
      );
      
      this.purchaseErrorSubscription = purchaseErrorListener(
        (error) => {
          console.warn('Purchase error:', error);
        }
      );
      
      // Check for any pending purchases from previous sessions
      await this.processPendingPurchases();
      
    } catch (err) {
      console.error('IAP initialization failed:', err);
    }
  }
  
  dispose() {
    this.purchaseUpdateSubscription?.remove();
    this.purchaseErrorSubscription?.remove();
    endConnection();
  }
}

Critical: Set up purchaseUpdatedListener before calling requestPurchase. Purchases can complete asynchronously - if the app is killed during a purchase, the transaction will be delivered the next time the listener is set up.


Fetching products

async function loadProducts() {
  try {
    const [products, subscriptions] = await Promise.all([
      getProducts({ skus: PRODUCT_IDS }),
      getSubscriptions({ skus: SUBSCRIPTION_IDS }),
    ]);
    
    // Products come back in arbitrary order - sort by your preferred order
    return {
      products: PRODUCT_IDS.map(id => products.find(p => p.productId === id)).filter(Boolean),
      subscriptions: SUBSCRIPTION_IDS.map(id => subscriptions.find(s => s.productId === id)).filter(Boolean),
    };
  } catch (err) {
    // No network, no products. Handle gracefully - don't crash the paywall.
    console.error('Failed to load products:', err);
    return { products: [], subscriptions: [] };
  }
}

// Display localized price directly from the product object
// NEVER hardcode prices in your app - they come from the store
function PriceDisplay({ product }) {
  return (
    <Text>
      {product.localizedPrice} / {product.subscriptionPeriodUnitIOS === 'MONTH' ? 'month' : 'year'}
    </Text>
  );
}

Initiating a purchase

async function purchaseSubscription(productId: string) {
  try {
    // Android requires offerToken for subscriptions in Billing Library 5+
    const subscription = subscriptions.find(s => s.productId === productId);
    
    if (Platform.OS === 'android') {
      // Android: must pass offerToken
      const offerToken = subscription?.subscriptionOfferDetails?.[0]?.offerToken;
      await requestSubscription({
        sku: productId,
        ...(offerToken && { subscriptionOffers: [{ sku: productId, offerToken }] }),
      });
    } else {
      // iOS
      await requestSubscription({ sku: productId });
    }
  } catch (err) {
    if (err.code === 'E_USER_CANCELLED') {
      // User cancelled - not an error, don't show error UI
      return;
    }
    throw err;
  }
}

The purchase handler: the most critical code

async function handlePurchaseUpdate(purchase) {
  const { productId, transactionId, transactionReceipt } = purchase;
  
  if (!transactionReceipt) return;
  
  try {
    // 1. Validate receipt with YOUR server (which validates with Apple/Google)
    // NEVER validate directly from the client - receipt data can be forged
    const response = await api.post('/purchases/validate', {
      productId,
      transactionId,
      receipt: transactionReceipt,
      platform: Platform.OS,
    });
    
    if (response.valid) {
      // 2. Grant entitlement to user
      await updateUserEntitlements(response.entitlements);
      
      // 3. ALWAYS finish the transaction - this is required
      // Not finishing transactions causes the purchase to be re-delivered
      // and can result in App Store review rejection
      await finishTransaction({ purchase, isConsumable: false });
    } else {
      // Invalid receipt - could be fraud attempt
      await finishTransaction({ purchase, isConsumable: false });
      // Don't grant access
    }
  } catch (err) {
    // Don't finish transaction if server validation failed due to network error
    // It will be re-delivered on next app open
    console.error('Purchase validation failed:', err);
  }
}

The finishTransaction rule: Every purchase must be finished (acknowledged). On iOS, unfinished transactions are re-delivered for up to 30 days. On Android (Billing Library 5+), unacknowledged purchases are refunded automatically after 3 days. Finish every transaction exactly once, after validation and entitlement delivery.


Server-side receipt validation

The server receives the receipt from the mobile client and validates it directly with Apple/Google. This is mandatory - receipt validation from the client is insecure.

// Express backend

// Apple validation
async function validateAppleReceipt(receiptData, isSandbox = false) {
  const endpoint = isSandbox
    ? 'https://sandbox.itunes.apple.com/verifyReceipt'
    : 'https://buy.itunes.apple.com/verifyReceipt';
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      'receipt-data': receiptData,
      'password': process.env.APPLE_SHARED_SECRET, // App-specific shared secret from App Store Connect
      'exclude-old-transactions': true,
    }),
  });
  
  const data = await response.json();
  
  // Status 21007 means "this is a sandbox receipt, use sandbox endpoint"
  if (data.status === 21007) {
    return validateAppleReceipt(receiptData, true);
  }
  
  if (data.status !== 0) {
    throw new Error(`Apple receipt validation failed: ${data.status}`);
  }
  
  // Extract the most recent in-app purchase info
  const latestReceipt = data.latest_receipt_info?.[0];
  
  return {
    valid: true,
    productId: latestReceipt.product_id,
    expiresAt: latestReceipt.expires_date_ms 
      ? new Date(parseInt(latestReceipt.expires_date_ms))
      : null,
    isInIntroOfferPeriod: latestReceipt.is_in_intro_offer_period === 'true',
    cancellationDate: latestReceipt.cancellation_date_ms
      ? new Date(parseInt(latestReceipt.cancellation_date_ms))
      : null,
  };
}

// Google validation
const { google } = require('googleapis');

async function validateGooglePurchase(productId, purchaseToken, isSubscription) {
  const auth = new google.auth.GoogleAuth({
    credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON),
    scopes: ['https://www.googleapis.com/auth/androidpublisher'],
  });
  
  const androidPublisher = google.androidpublisher({ version: 'v3', auth });
  
  const packageName = 'com.yourapp';
  
  if (isSubscription) {
    const { data } = await androidPublisher.purchases.subscriptionsv2.get({
      packageName,
      token: purchaseToken,
    });
    
    return {
      valid: data.subscriptionState === 'SUBSCRIPTION_STATE_ACTIVE',
      productId,
      expiresAt: data.lineItems?.[0]?.expiryTime
        ? new Date(data.lineItems[0].expiryTime)
        : null,
    };
  } else {
    const { data } = await androidPublisher.purchases.products.get({
      packageName,
      productId,
      token: purchaseToken,
    });
    
    // Acknowledge the purchase (required within 3 days)
    if (data.acknowledgementState === 0) {
      await androidPublisher.purchases.products.acknowledge({
        packageName,
        productId,
        token: purchaseToken,
      });
    }
    
    return {
      valid: data.purchaseState === 0, // 0 = purchased
      productId,
    };
  }
}

Subscription lifecycle: what your server must handle

Apple and Google send server-to-server notifications for subscription events. Implement a webhook endpoint to handle them.

Critical events to handle:

// Apple Server Notifications V2 (via App Store Connect webhook URL)
app.post('/webhooks/apple', async (req, res) => {
  const { signedPayload } = req.body;
  
  // Decode and verify the signed JWT from Apple
  const payload = await verifyAppleNotification(signedPayload);
  const { notificationType, subtype, data } = payload;
  
  switch (notificationType) {
    case 'SUBSCRIBED':
      // New subscription or resubscription after expiry
      await activateSubscription(data.transactionInfo.appAccountToken);
      break;
      
    case 'DID_RENEW':
      // Successful renewal - extend access
      await extendSubscription(data.transactionInfo.appAccountToken, 
        new Date(data.transactionInfo.expiresDate));
      break;
      
    case 'EXPIRED':
      // Subscription expired (after all billing retries failed)
      await deactivateSubscription(data.transactionInfo.appAccountToken);
      break;
      
    case 'DID_FAIL_TO_RENEW':
      if (subtype === 'GRACE_PERIOD') {
        // In grace period - keep access, show in-app "update payment" prompt
        await setGracePeriod(data.transactionInfo.appAccountToken);
      }
      break;
      
    case 'REFUND':
      // Apple issued a refund - revoke access
      await deactivateSubscription(data.transactionInfo.appAccountToken);
      break;
      
    case 'PRICE_INCREASE':
      // User must consent to price increase
      // If they don't, subscription will lapse
      break;
  }
  
  res.sendStatus(200);
});

The grace period is important: when a subscription renewal fails due to a payment issue, Apple (and Google) give the user a grace period (up to 16 days on iOS) before the subscription is considered expired. During this time you should keep access active but show an in-app prompt to update payment details.


Restore purchases

Users who reinstall the app or switch devices need to restore non-consumable purchases and subscriptions.

import { getAvailablePurchases } from 'react-native-iap';

async function restorePurchases() {
  try {
    const purchases = await getAvailablePurchases();
    
    // Validate each restored purchase with your server
    for (const purchase of purchases) {
      await handlePurchaseUpdate(purchase);
    }
    
    return purchases.length > 0;
  } catch (err) {
    throw new Error('Restore failed: ' + err.message);
  }
}

Apple requires a visible "Restore Purchases" button on your paywall screen. Apps without it get rejected. Place it as a small text link below the main purchase buttons.


Common rejection reasons

"Your app appears to offer paid features without using Apple's IAP" - Any mention of web-based subscriptions, links to your website to subscribe, or external payment for digital content will get rejected. For digital content, IAP is mandatory. Physical goods and services (taxis, food delivery, real-world services) are exempt.

"Your paywall doesn't include a Restore Purchases button" - Add it.

"Your app doesn't clearly describe the subscription terms" - Show price, billing period, and cancellation instructions on the paywall. Include a link to Terms and Privacy Policy.

"Free trial terms are not clearly displayed" - If you offer a trial, display the trial length, what happens when it ends, and the price they'll be charged.


Testing

iOS: Use StoreKit testing in Xcode (no Apple ID needed, instant transactions, controllable renewal intervals) or Sandbox accounts (real Apple IDs in test environment, 1-day subscription = 5 minutes real time).

Android: Use Play Store licensing testers (real purchases that aren't charged) or test product IDs.

// Detect test environment for conditional behavior
const isTestEnvironment = __DEV__ || 
  (Platform.OS === 'ios' && receiptData.includes('Sandbox'));

Never ship IAP code without testing: cancelled subscriptions, failed renewals, refunds, and restore purchases. These edge cases are where most bugs hide.


Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.

Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development

Read Also

Mobile App Monetization Strategies in 2026: Which Model Fits Your Appaunimeda
Mobile Development

Mobile App Monetization Strategies in 2026: Which Model Fits Your App

A practical breakdown of mobile app monetization models in 2026: freemium, subscriptions, in-app purchases, ads, and paywalls. How to choose and implement the right model for your app type.

Flutter App Development in Bishkek: Cost, Timeline, and What to Expectaunimeda
Mobile Development

Flutter App Development in Bishkek: Cost, Timeline, and What to Expect

Everything you need to know about Flutter mobile app development in Bishkek, Kyrgyzstan in 2026: costs, timelines, team structure, and how to evaluate a development partner.

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)aunimeda
Mobile Development

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)

Push notifications are the single highest-ROI feature in mobile apps. Open rates are 7x higher than email. Here's how to implement them correctly in React Native - including background handling, deep linking, and analytics.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Mobile App Development

Get Consultation All articles