You shipped a subscription app built with Rork, TestFlight let it through, and then the rejection notice landed in your inbox. The reason: "Guideline 3.1.1 — In-App Purchase." The body says something like "we could not find a way to restore previous purchases." I have personally received this rejection three times, and the root cause was the same every time.
A surprising number of indie developers think they have implemented Restore Purchases when they have actually built a button that does almost nothing. The button sits visibly on the screen, but Apple's review team taps it, sees no behavior change, and bounces the build back. This guide walks through the fix, assuming you are working in a React Native codebase generated by Rork.
Why Restore Purchases is non-negotiable
App Store Review Guideline 3.1.1 and the Apple Developer Program License Agreement both require apps that sell subscriptions or non-consumable IAPs to provide a way to restore previous purchases. The reasoning is user protection more than legal coverage.
Picture the everyday scenarios. A user upgrades to a new iPhone. They delete and reinstall your app. They share an Apple ID with a family member and want to bring their subscription over to a second device. If your app's response is "please pay again," that is a direct violation of consumer expectations. Apple wants every previously paid purchase to be re-activatable at any time, with no extra charge, in one tap.
What "working" means to Apple is specific: the button calls StoreKit, retrieves active transactions tied to the user's Apple ID, and your app uses those transactions to re-grant entitlement — all in a single flow. A button that just shows "Coming soon" or even one that calls the API but ignores the result will not pass review.
Four common rejection patterns
Looking at codebases that came to me after a rejection, the cause typically falls into one of four buckets.
The button is missing entirely. This happens to first-timers who port a Stripe-style checkout to React Native without reading the IAP rules. Apple wants the button on either the purchase screen or the settings screen.
The button exists but does not call any API. When you ask Rork's AI for "a Restore Purchases button," it sometimes generates code that styles the button beautifully and runs Alert.alert("Restored!") on press without ever talking to StoreKit. Visually convincing, functionally empty.
The API is called but the result is ignored. You call getAvailablePurchases() and get back an array of transactions, but you never update the in-app entitlement flag (such as the is_premium flag in AsyncStorage). From the user's perspective, nothing happens.
Only sandbox testing was done. Passing in TestFlight is not the same as passing receipt validation in production. Sandbox receipts go to sandbox.itunes.apple.com, while production receipts go to buy.itunes.apple.com. If your code does not switch endpoints based on the environment, Restore can fail in production despite working in sandbox.
Implementation with expo-in-app-purchases
For Expo projects generated by Rork, you have two main options for IAP: expo-in-app-purchases (officially supported through Expo SDK 49, now in extras) and react-native-iap (community-maintained, more featureful). For new projects I recommend the latter, but if you already have expo-in-app-purchases wired up, here is the minimum viable fix.
// app/utils/restorePurchases.ts
import * as InAppPurchases from 'expo-in-app-purchases';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Alert } from 'react-native';
export async function restorePurchases(): Promise<boolean> {
try {
// Make sure StoreKit is connected
await InAppPurchases.connectAsync();
// Pull purchase history tied to the Apple ID
const { results, responseCode } = await InAppPurchases.getPurchaseHistoryAsync({
useGooglePlayCache: false, // ignored on iOS but explicit is better
});
if (responseCode !== InAppPurchases.IAPResponseCode.OK) {
throw new Error(`Restore failed with code ${responseCode}`);
}
// Find an active subscription
const activeSubscription = results?.find(
(purchase) =>
purchase.productId === 'com.example.app.premium_monthly' &&
purchase.transactionReceipt
);
if (activeSubscription) {
// Update entitlement flag — the most commonly forgotten step
await AsyncStorage.setItem('is_premium', 'true');
await AsyncStorage.setItem('premium_restored_at', new Date().toISOString());
Alert.alert('Restored', 'Your subscription has been restored.');
return true;
} else {
Alert.alert('No purchases found', 'No active subscriptions were found for this Apple ID.');
return false;
}
} catch (error) {
console.error('[restorePurchases] error:', error);
Alert.alert('Error', 'Restoring purchases failed. Please check your network and try again.');
return false;
} finally {
await InAppPurchases.disconnectAsync();
}
}The line that matters most is AsyncStorage.setItem('is_premium', 'true'). Without it, even a successful API call leaves the app in the same visual state, and your users will think the button is broken.
Implementation with react-native-iap
For new projects, or if you need finer-grained control (server-side receipt validation, StoreKit 2's async/await), use react-native-iap. It is actively maintained and its API surface maps closely to StoreKit 2.
// app/utils/restorePurchases.ts
import {
initConnection,
endConnection,
getAvailablePurchases,
finishTransaction,
Purchase,
} from 'react-native-iap';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Alert } from 'react-native';
const PRODUCT_IDS = {
PREMIUM_MONTHLY: 'com.example.app.premium_monthly',
PREMIUM_YEARLY: 'com.example.app.premium_yearly',
};
export async function restorePurchases(): Promise<boolean> {
try {
await initConnection();
// Get every active purchase for this Apple ID
const purchases: Purchase[] = await getAvailablePurchases();
// Filter down to subscription products
const activePurchases = purchases.filter((p) =>
Object.values(PRODUCT_IDS).includes(p.productId)
);
if (activePurchases.length === 0) {
Alert.alert('Nothing to restore', 'No active subscriptions found for this Apple ID.');
return false;
}
// Use the most recent purchase as the source of truth
const latestPurchase = activePurchases.sort(
(a, b) => (b.transactionDate ?? 0) - (a.transactionDate ?? 0)
)[0];
// If you do server-side receipt validation, send latestPurchase.transactionReceipt here
// const verified = await verifyReceiptOnServer(latestPurchase.transactionReceipt);
// Update the in-app entitlement
await AsyncStorage.setItem('is_premium', 'true');
await AsyncStorage.setItem('premium_product_id', latestPurchase.productId);
// Mark the transaction as finished — skip this and the same purchase keeps reappearing
await finishTransaction({ purchase: latestPurchase, isConsumable: false });
Alert.alert('Restored', 'Premium features are now available.');
return true;
} catch (error: any) {
console.error('[restorePurchases] error:', error);
Alert.alert('Error', error?.message ?? 'Failed to restore purchases.');
return false;
} finally {
await endConnection();
}
}If you forget finishTransaction, the same transaction keeps coming back as "unprocessed" on every app launch, and Restore reports the same purchase repeatedly. Apple's reviewers tend to tap Restore multiple times to verify behavior, so a stuck transaction can become a rejection in itself.
Place the button where reviewers will find it
Once the logic is in place, put the button somewhere a user — and a reviewer — can find without thinking. Apple recommends two locations:
The subscription purchase screen itself, so first-time visitors see "Already a subscriber? Restore." The settings screen under a Subscription section, which is the location reviewers check first.
// app/(tabs)/settings.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { restorePurchases } from '@/utils/restorePurchases';
export default function SettingsScreen() {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Subscription</Text>
<TouchableOpacity
style={styles.row}
onPress={() => restorePurchases()}
accessibilityLabel="Restore Purchases"
accessibilityRole="button"
>
<Text style={styles.label}>Restore Purchases</Text>
</TouchableOpacity>
</View>
);
}Always set accessibilityLabel and accessibilityRole. Reviewers occasionally use VoiceOver, and an unlabeled element is sometimes flagged as non-functional even when it works visually.
Sandbox verification flow
With the implementation in place, test it in a sandbox so no real money flows. The setup is straightforward.
Create a Sandbox tester email address in App Store Connect under "Users and Access." On a real device, sign that account into Settings → App Store → Sandbox Account. You need a separate Apple ID from your normal one, so a Gmail alias like yourname+sandbox1@gmail.com makes account hygiene easier.
Build and install the app from Xcode directly to a device — not via TestFlight. Some edge cases (especially around expiration and re-activation) are easier to reproduce when you control the build cycle. Run the purchase flow, complete a subscription, delete the app, reinstall, and tap Restore Purchases. The entitlement should come back without another payment.
A useful quirk: in Sandbox, monthly subscriptions renew every 3 minutes, so you can step through "purchase → renew → cancel → restore" in less than 15 minutes. There is no faster way to find the bugs that only appear during state transitions.
Resubmission notes that help
If you do get rejected, the response message you send back through the Resolution Center has a real impact on how the next review goes. A note like the one below makes it easy for the reviewer to verify the fix.
Thank you for the feedback. We have implemented the Restore Purchases functionality in the Settings screen. The button is located at Settings → Subscription → Restore Purchases. Tapping the button calls StoreKit's getAvailablePurchases() and restores any active subscriptions tied to the user's Apple ID. We have tested this with sandbox accounts and confirmed that the premium status is correctly re-activated after a fresh install.
Spelling out the exact location and behavior reduces the chance of a second rejection. "We fixed it" is not enough.
Related reading
To round out your understanding of common rejection causes, Why a Rork App Got Rejected from App Store Review Three Times — and How to Fix Each Cause walks through the broader rejection patterns. For Privacy Manifest specifically, see Fixing the Privacy Manifest API Declaration rejection.
If you want to go deeper into StoreKit 2 itself, Implementing StoreKit 2 In-App Purchases in Rork Max — A Complete Guide covers the modern Swift API end to end, and Four Triggers Where Family Sharing Quietly Erases Your IAP Revenue is essential reading once you start running your subscription business at scale.
Closing thought
Restore Purchases is not a UI element — it is a contract with your users and with Apple. Putting a button on the screen is the easy 10%; the remaining 90% is making sure StoreKit's response actually flips the entitlement flag your app reads. The single most useful thing you can do today is open your codebase, search for getAvailablePurchases (or getPurchaseHistoryAsync), and confirm the result is being written back to your local store. If it is not, an hour of work right now will save you a rejection later.