Skip to main content
Shipnative uses RevenueCat for subscriptions on iOS, Android, and Web. The payment UI and subscription store are pre-built.

Setup

yarn setup
The wizard prompts for your RevenueCat API keys (iOS, Android, Web). Or manually add to apps/app/.env:
EXPO_PUBLIC_REVENUECAT_IOS_KEY=your_ios_key
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=your_android_key
EXPO_PUBLIC_REVENUECAT_WEB_KEY=your_web_key
Without keys: Mock mode simulates purchases. Connect real RevenueCat before production to test sandbox purchases.

RevenueCat Configuration

  1. Create a project at app.revenuecat.com
  2. Link your App Store Connect and Google Play Console apps
  3. Get API keys from Project SettingsAPI Keys
  4. Create products in App Store Connect / Google Play Console
  5. Add products to an Offering (typically “default”) in RevenueCat
  6. Create an Entitlement (e.g., “pro”) and link your products to it
For detailed setup, see RevenueCat Getting Started.

Usage

The app auto-detects the platform and uses the appropriate RevenueCat SDK.

Subscription Store

import { useSubscriptionStore } from '@/stores/subscriptionStore'

function MyComponent() {
  const {
    isPro,               // Is user subscribed?
    packages,            // Available subscription packages
    loading,             // Purchase in progress?
    purchasePackage,     // Start a purchase
    restorePurchases,    // Restore previous purchases
    addLifecycleListener // Listen to subscription events (new!)
  } = useSubscriptionStore()
}

Gating Features

import { useSubscriptionStore } from '@/stores/subscriptionStore'

function PremiumFeature() {
  const { isPro } = useSubscriptionStore()

  if (!isPro) {
    return <PaywallScreen />
  }

  return <PremiumContent />
}

Custom Purchase UI

import { getPromotionalOfferText, calculateSavings } from '@/utils'

function CustomPricing() {
  const { packages, purchasePackage, loading } = useSubscriptionStore()

  return (
    <View>
      {packages.map((pkg, index) => {
        // Calculate savings for annual plans
        const monthlyPkg = packages.find(p => p.billingPeriod === 'monthly')
        const savings = pkg.billingPeriod === 'annual' && monthlyPkg
          ? calculateSavings(monthlyPkg.price * 12, pkg.price)
          : null

        // Get promotional offer text (free trials, intro pricing)
        const promoText = getPromotionalOfferText(pkg)

        return (
          <View key={pkg.id}>
            {/* Show savings badge */}
            {savings > 0 && <Badge>Save {savings}%</Badge>}

            {/* Show promotional offer */}
            {promoText && <Text>{promoText}</Text>}

            {/* Free trial badge */}
            {pkg.freeTrialPeriod && <Text>🎉 {pkg.freeTrialPeriod} free</Text>}

            <Button
              title={`${pkg.title} - ${pkg.priceString}`}
              onPress={() => purchasePackage(pkg)}
              disabled={loading}
            />
          </View>
        )
      })}
    </View>
  )
}

Subscription Lifecycle Events

Track subscription events like trial starts, renewals, and cancellations:
import { useEffect } from 'react'
import { useSubscriptionStore } from '@/stores/subscriptionStore'

function SubscriptionTracker() {
  const addLifecycleListener = useSubscriptionStore(s => s.addLifecycleListener)

  useEffect(() => {
    const unsubscribe = addLifecycleListener((event) => {
      console.log('Event:', event.event) // trial_started, subscription_renewed, etc.

      // Trigger actions based on events
      switch (event.event) {
        case 'trial_started':
          analytics.track('Trial Started')
          break
        case 'subscription_cancelled':
          showRetentionOffer()
          break
        case 'billing_issue':
          showPaymentUpdateAlert()
          break
      }
    })

    return unsubscribe // Cleanup
  }, [])

  return null
}
Available events:
  • trial_started - Free trial started
  • trial_converted - Trial converted to paid
  • trial_cancelled - Trial cancelled
  • subscription_started - New subscription
  • subscription_renewed - Auto-renewed
  • subscription_cancelled - Cancelled (still active until expiry)
  • subscription_expired - Access ended
  • subscription_restored - Restored from previous purchase
  • billing_issue - Payment problem detected

Price Localization

Helper functions for proper price formatting:
import {
  formatLocalizedPrice,
  calculateSavings,
  getMonthlyEquivalent,
  formatExpirationStatus
} from '@/utils'

// Format prices with user's locale
const price = formatLocalizedPrice(9.99, 'USD') // "$9.99"
const euroPrice = formatLocalizedPrice(9.99, 'EUR', 'de-DE') // "9,99 €"

// Calculate savings percentage
const savings = calculateSavings(119.88, 99.99) // 17

// Get monthly equivalent for annual plans
const monthlyEquiv = getMonthlyEquivalent(99.99, 'annual') // 8.33

// Format subscription expiration
const status = formatExpirationStatus(customerInfo) // "Renews in 5 days"

Advanced Features

Promotional Offers & Intro Pricing

RevenueCat automatically detects and displays promotional offers configured in App Store Connect or Google Play Console:
import { useSubscription } from '@/hooks/useSubscription'

function PricingCard() {
  const { packages } = useSubscription()

  return packages.map(pkg => {
    const product = pkg.product

    // Check for free trial
    if (product.freeTrialPeriod) {
      return (
        <Text>
          {product.freeTrialPeriod.value} {product.freeTrialPeriod.unit} free trial
          then {product.priceString}/{product.subscriptionPeriod}
        </Text>
      )
    }

    // Check for intro pricing
    if (product.introPrice) {
      return (
        <Text>
          {product.introPrice.priceString} for {product.introPrice.period} {product.introPrice.periodUnit}
          then {product.priceString}
        </Text>
      )
    }

    // Regular pricing
    return <Text>{product.priceString}/{product.subscriptionPeriod}</Text>
  })
}

Lifecycle Event Details

The addCustomerInfoUpdateListener provides detailed event tracking:
import { subscriptionService } from '@/services/revenuecat'

// Available event types
type SubscriptionEvent =
  | 'trial_started'        // Free trial activated
  | 'trial_converted'      // Trial converted to paid
  | 'trial_cancelled'      // Trial cancelled before conversion
  | 'subscription_started' // New paid subscription
  | 'subscription_renewed' // Auto-renewal succeeded
  | 'subscription_cancelled' // User cancelled (still active until expiry)
  | 'subscription_expired' // Subscription ended (no longer active)
  | 'subscription_restored' // Purchase restored from another device
  | 'billing_issue'        // Payment failed or grace period started
  | 'product_changed'      // Upgraded/downgraded plans

// Listen to all events
subscriptionService.addCustomerInfoUpdateListener((customerInfo) => {
  const status = customerInfo.status
  const expirationDate = customerInfo.expirationDate
  const isTrial = customerInfo.isTrial

  // Track in analytics
  trackEvent('subscription_status_changed', {
    status,
    isTrial,
    expiresAt: expirationDate,
  })
})

Price Localization Helpers

Format prices correctly for international users:
import {
  formatLocalizedPrice,
  calculateSavings,
  getMonthlyEquivalent,
  formatExpirationStatus
} from '@/utils/subscriptionHelpers'

// Format with user's locale
const priceUSD = formatLocalizedPrice(9.99, 'USD')        // "$9.99"
const priceEUR = formatLocalizedPrice(9.99, 'EUR', 'de-DE') // "9,99 €"
const priceJPY = formatLocalizedPrice(1200, 'JPY', 'ja-JP') // "¥1,200"

// Calculate discount percentage
const savings = calculateSavings(119.88, 99.99) // 17% savings

// Show monthly equivalent for annual plans
const monthlyEquiv = getMonthlyEquivalent(99.99, 12) // "$8.33/mo"

// Format subscription status
const status = formatExpirationStatus({
  expirationDate: '2026-02-12',
  willRenew: true,
  isActive: true,
}) // "Renews on Feb 12, 2026"

const expiredStatus = formatExpirationStatus({
  expirationDate: '2026-01-01',
  willRenew: false,
  isActive: false,
}) // "Expired on Jan 1, 2026"

Customer Info Storage

Store subscription details for offline access:
import { subscriptionService } from '@/services/revenuecat'

// Get current subscription info
const info = await subscriptionService.getSubscriptionInfo()

// Returns structured data:
{
  platform: 'revenuecat',
  status: 'active' | 'trial' | 'expired' | 'cancelled' | 'none',
  productId: 'pro_monthly' | null,
  expirationDate: '2026-02-12T00:00:00Z' | null,
  willRenew: true | false,
  isActive: true | false,
  isTrial: true | false,
}
For more advanced patterns including paywalls, A/B testing, and analytics integration, see boilerplate/vibe/SUBSCRIPTION_ADVANCED.md.

Testing

Mock mode: Without API keys, purchases simulate success. Includes realistic promotional offers (7-day trial for monthly, 14-day trial + intro pricing for annual). Dev menu includes a Free/Pro toggle. Sandbox testing:
  • iOS: Configure a sandbox tester in App Store Connect
  • Android: Configure a test account in Google Play Console
  • Web: Use Stripe test mode with RevenueCat Web Billing

Webhooks

For real-time subscription status updates, configure webhooks in RevenueCatProject SettingsIntegrationsWebhooks. See RevenueCat Webhooks for setup.

Troubleshooting

“No products registered” errors?
  • Expected when API keys are set but products aren’t configured in RevenueCat
  • Create products and add them to an Offering, then restart the app
Packages not loading?
  • Verify API keys are correct
  • Check that products are linked to an Offering in RevenueCat
  • Call restorePurchases() if user has an active subscription
Still in mock mode?
  • Verify apps/app/.env has the correct keys
  • Restart Metro: yarn start --clear
For more help, see RevenueCat Troubleshooting.