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.
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+.
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.
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
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.
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.
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).
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 |
Official Documentation
Related Articles
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 →