Back to resources
Implementation Swift / StoreKit 2 React Native RevenueCat

How to Implement Storefront Gating in 4 Weeks

US users get web checkout. Everyone else gets IAP. Here's the exact code and plan - working Swift and React Native snippets, a remote config kill switch, and a pre-submission checklist.

~10 min read · Updated March 2026
Week 1
Detect Storefront
StoreKit 2 + RN
Week 2
Conditional UI
Kill switch + Remote Config
Week 3
Redemption Links
Universal Links + deep link
Week 4
Test + Submit
Device testing + App Review

The #1 Rejection Risk

Since Apple's May 2025 guideline update, US storefront apps can link to external payments with zero commission. But show a web checkout button to a non-US user and you violate guideline 3.1.1(a) and face immediate rejection. Storefront gating is not optional - it's the prerequisite for the whole flow to be legal.

What you'll walk away with: A 4-week implementation plan with working Swift and React Native code, a remote config kill switch you can flip without an app update, redemption link deep link handlers, and a pre-submission checklist. Everything you need to ship this safely.

Week 1: Detect the Storefront

You need to know, at runtime, whether the user is shopping on the US App Store. On iOS, use StoreKit 2's Storefront.current. On Android, use BillingClient to get the country code. The critical rule: never cache this. Users who travel or use VPNs can switch storefronts between sessions - always fetch it fresh right before rendering your paywall.

Swift (StoreKit 2)

Call this right before you show the paywall - not in viewDidLoad, not on app launch. The Storefront struct is part of StoreKit 2 and requires iOS 15+.

Swift StorefrontGating.swift
import StoreKit

/// Returns true only when the user is on the US App Store.
/// Never cache - storefront can change when users travel or use a VPN.
func isUSStorefront() async -> Bool {
    guard let storefront = await Storefront.current else {
        return false
    }
    return storefront.countryCode == "USA"
}

// Usage: call this right before presenting your paywall
Task {
    let showWebCheckout = await isUSStorefront()
    await MainActor.run {
        paywallView.configure(webCheckoutEnabled: showWebCheckout)
    }
}

React Native

React Native has no direct StoreKit 2 access. Write a thin native module that bridges Storefront.current on iOS. Then expose it as a hook so any paywall screen can use it cleanly.

TypeScript useStorefront.ts
import { NativeModules, Platform } from 'react-native';
import { useState, useEffect } from 'react';

// Native module bridges StoreKit 2's Storefront.current on iOS
const { StorefrontModule } = NativeModules;

async function getStorefrontCountry(): Promise<string | null> {
  if (Platform.OS !== 'ios') return null;
  try {
    const countryCode = await StorefrontModule.getCountryCode();
    return countryCode ?? null;
  } catch {
    return null;
  }
}

export function useIsUSStorefront() {
  const [isUS, setIsUS] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getStorefrontCountry().then((code) => {
      setIsUS(code === 'USA');
      setLoading(false);
    });
  }, []);

  return { isUS, loading };
}

Pro tip: don't use device locale

Locale.current.region reflects the device language setting, not the App Store account. A US citizen living in Germany can have a US App Store account with a German device locale. Always use Storefront.current - it's the authoritative source and the one Apple expects you to use.

Week 2: Conditional UI + Remote Config Kill Switch

The gating logic needs two conditions: isUSStorefront AND a remote config flag. The flag is your kill switch. If Apple changes a policy, your web checkout breaks, or you need to pull it for any reason - flip the flag. Web checkout disappears instantly, no app update required. Use Firebase Remote Config, LaunchDarkly, or even a simple JSON file on your CDN.

Firebase Remote Config Setup

TypeScript remoteConfig.ts
import remoteConfig from '@react-native-firebase/remote-config';

async function initRemoteConfig() {
  // Safe defaults - web checkout is OFF until explicitly enabled
  await remoteConfig().setDefaults({
    enable_web_checkout: false,
    web_checkout_url: '',
  });

  await remoteConfig().setConfigSettings({
    minimumFetchIntervalMillis: 3600000, // 1 hour TTL
  });

  await remoteConfig().fetchAndActivate();
}

export function getWebCheckoutConfig() {
  return {
    enabled: remoteConfig().getValue('enable_web_checkout').asBoolean(),
    url: remoteConfig().getValue('web_checkout_url').asString(),
  };
}

PaywallGate Component

Keep all gating logic in one component. Don't scatter the isUSStorefront check across multiple screens - one gate, audited in one place.

TypeScript / React Native PaywallGate.tsx
import { useEffect, useState } from 'react';
import { useIsUSStorefront } from './useStorefront';
import { getWebCheckoutConfig } from './remoteConfig';

export function PaywallGate() {
  const { isUS, loading } = useIsUSStorefront();
  const [config, setConfig] = useState({ enabled: false, url: '' });

  useEffect(() => {
    setConfig(getWebCheckoutConfig());
  }, []);

  if (loading) return <LoadingSpinner />;

  // Both conditions required: US storefront AND remote kill switch on
  const showWebCheckout = isUS && config.enabled && config.url.length > 0;

  if (showWebCheckout) {
    return <WebCheckoutButton url={config.url} />;
  }

  return <IAPPaywall />;
}

Week 3: Redemption Links + Deep Link Handling

When a user pays on web, RevenueCat emails them a redemption link. Tapping it opens your app and syncs the entitlement. This won't work automatically - you need to handle the incoming deep link explicitly and call Purchases.redeemWebPurchase(url).

Minimum SDK versions required: iOS 5.14.1+, Android 8.10.6+, React Native 8.5.0+. Check your package.json or Podfile.lock before starting this week. Full setup is in the RevenueCat Redemption Links docs.

You also need Universal Links (iOS) and App Links (Android) - which means hosting two well-known files at your domain root:

  • https://yourdomain.com/.well-known/apple-app-site-association
  • https://yourdomain.com/.well-known/assetlinks.json

Handle the Redemption Link (Swift)

In your SceneDelegate, intercept the Universal Link and pass it to RevenueCat. Never silently fail here - a broken redemption means a user who paid and didn't get access.

Swift SceneDelegate.swift
import RevenueCat

// Intercept incoming Universal Links in SceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }

    // Only handle RevenueCat redemption URLs
    guard url.absoluteString.contains("revenuecat") else { return }

    Task {
        do {
            let result = try await Purchases.shared.redeemWebPurchase(url)
            await MainActor.run {
                // Notify the rest of the app that entitlements changed
                NotificationCenter.default.post(
                    name: .purchaseRedeemed,
                    object: result.customerInfo
                )
            }
        } catch {
            await MainActor.run {
                // Surface this - do NOT silently swallow the error
                showRedemptionError(error)
            }
        }
    }
}

Handle the Redemption Link (React Native)

Handle two scenarios: the app is open when the link is tapped, and the app was killed (cold start). Both need to call Purchases.redeemWebPurchase(url).

TypeScript / React Native useRedemptionLink.ts
import { Linking } from 'react-native';
import Purchases from 'react-native-purchases';
import { useEffect } from 'react';

async function handleRedemptionUrl(url: string) {
  if (!url.includes('revenuecat')) return;
  try {
    const { customerInfo } = await Purchases.redeemWebPurchase(url);
    // customerInfo now has updated entitlements - refresh your UI
    console.log('Active entitlements:', customerInfo.activeSubscriptions);
  } catch (err) {
    console.error('Redemption failed:', err);
    // Surface this - don't let it fail silently
  }
}

export function useRedemptionLink() {
  useEffect(() => {
    // Case 1: app is open when link is tapped
    const sub = Linking.addEventListener('url', ({ url }) => {
      handleRedemptionUrl(url);
    });

    // Case 2: cold start - app was killed when link was tapped
    Linking.getInitialURL().then((url) => {
      if (url) handleRedemptionUrl(url);
    });

    return () => sub.remove();
  }, []);
}

Redemption links expire in 60 minutes

RevenueCat auto-sends a new link via email when the original expires - but you need to test this flow explicitly. "User pays, comes back the next day" is a real scenario. Add a "Resend redemption link" option in your Account screen so users aren't stuck.

Week 4: Kill Switch, Device Testing, App Review

Your remote config kill switch is only valuable if you've tested it before shipping. Flip the flag to false in staging and confirm the web checkout button disappears immediately - no app update. Flip it back and confirm it reappears. Then test the full flow end-to-end on real devices. Simulator storefront behavior doesn't match production.

Device Test Matrix

Scenario Account Flag Expected Result
Primary happy path US Sandbox true Web checkout shown
Non-US user Non-US Sandbox true IAP only - no web button
Kill switch test US Sandbox false IAP only - no web button
Full web → redemption US Sandbox true Pay on web → link → app unlocks
Expired link re-send US Sandbox true New link arrives via email
Cold-start redemption US Sandbox true Tap link → app opens → unlocks
Pre-Submission Checklist
Storefront detection returns correct country code on real device (not simulator)
Non-US sandbox account never sees web checkout button
Kill switch works: toggling remote config hides/shows web option without an app update
Web checkout URL loads correctly and completes a test transaction
Redemption link opens app from background state and unlocks entitlements
Redemption link opens app from cold-start (killed) state and unlocks entitlements
Expired redemption link triggers re-send (RevenueCat emails a new one automatically)
App Review notes written - entitlement parity sentence included
Screenshots attached: US paywall, non-US paywall, web checkout, redemption success screen

Official Documentation

Want help shipping this in 4 weeks?

Let's look at your stack, catch the edge cases, and get you to submission confidently.

Book a 15-min Call →