RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-05-04Intermediate

Why Your Rork App Gets Rejected for Broken Restore Purchases — Fixing App Store Guideline 3.1.1

If your Rork-built subscription app is getting rejected for Guideline 3.1.1, the Restore Purchases button is almost always the cause. Learn how to wire it up correctly with expo-in-app-purchases or react-native-iap and pass review on the next try.

Rork515In-App Purchase7Restore PurchasesApp Store Review8Guideline 3.1.1expo-in-app-purchasesreact-native-iapSubscription23

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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-04-14
Purchase Status Not Updating in Rork — Debugging RevenueCat & StoreKit Sync Issues
Fix the frustrating issue of subscription status not reflecting after purchase in Rork apps. Step-by-step debugging guide covering RevenueCat customerInfo timing, Entitlement ID mismatches, Sandbox delays, and listener setup.
Dev Tools2026-06-14
When Your Rork App Gets ITMS-91053 — A Practical Guide to Privacy Manifests and Required Reason APIs
Submitting a Rork-generated Expo app to the App Store can trigger Privacy Manifest warnings even when you never wrote the offending code. Here is how to clear both Required Reason API and SDK manifest issues before you submit.
Dev Tools2026-05-27
Three-Layer StoreKit 2 Entitlement Sync for Rork Apps: Launch, Background Refresh, and Restore
When you wire StoreKit 2 subscriptions into a Rork-generated app, Transaction.updates alone leaves gaps. Here is the three-layer sync I run across six wallpaper apps — launch-time re-evaluation, Background App Refresh, and Restore Purchase — including measured refresh rates and the AdMob revenue I recovered.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →