◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-05-04中級

Rork で「Restore Purchases」が動かないとリジェクトされる — App Store審査3.1.1の落とし穴と修正手順

Rorkで作ったサブスクリプションアプリがApp Store審査でリジェクトされる原因の上位に「Restore Purchasesボタンの不備」があります。Apple Guideline 3.1.1の正しい解釈と、expo-in-app-purchases / react-native-iap での実装パターンを解説します。

Rork585In-App PurchaseRestore PurchasesApp Store審査8Guideline 3.1.1expo-in-app-purchasesreact-native-iapサブスクリプション66

サブスクリプションアプリを Rork で作って TestFlight には通った、けれど審査リジェクトの通知が届いた。理由を読むと「Guideline 3.1.1 — In-App Purchase」と書いてあって、本文には「ユーザーがアカウントを復元する手段が見当たらない」というメッセージ。私はこのリジェクトを過去に3回受けたことがあって、毎回原因は同じところにあります。

意外なほど多くの個人開発者が「Restore Purchases ボタンを実装したつもり」で詰まっています。ボタンは画面に置いてあるのに、審査チームは「動いていない」と判定して返してくる。今回はその原因を、Rork で生成された React Native コードベースを前提に、最短で直す手順としてまとめます。

なぜ Restore Purchases が必須なのか

Apple Developer Program License Agreement と App Store Review Guideline 3.1.1 では、サブスクリプションや非消費型 IAP を販売するアプリに、購入を復元する手段の提供を義務付けています。これは法律上の保護というよりは、ユーザー体験の保護です。

具体的な利用シーンを想像すると分かりやすいです。ユーザーが iPhone を機種変更した、アプリを一度削除して再インストールした、あるいは家族で同じ Apple ID を共有していて別の端末で課金ステータスを引き継ぎたい。こうした場面で「もう一度お金を払ってください」と言われたら、誰でも怒ります。Apple は「すでに支払った購入を、ユーザーがいつでも追加課金なしに再有効化できるボタン」を要求しています。

ここで重要なのは、Apple にとっての「動いている」の定義です。ボタンを押すと StoreKit を呼び出して、Apple ID に紐付いたアクティブなトランザクションをアプリ側に渡し、そのトランザクションを根拠にアプリが会員権限を再付与します。この一連のフローが画面遷移なしに完結することを審査で確認します。ボタンが「Coming Soon」のアラートを出すだけでは、当然リジェクトです。

リジェクトされる4つの典型パターン

実際にリジェクトされたコードを見てきた経験から、原因はだいたい4種類に分けられます。

ボタンが画面のどこにもありません。 これは初心者によくあるパターンで、Stripe の決済 UI を React Native アプリに移植したまま IAP の仕様を読まずに公開すると起こります。Apple は「ボタンを設定画面または購入画面のどちらかに配置すること」を要求しています。

ボタンはあるが API を呼んでいありません。 Rork の AI に「Restore Purchases ボタンを作って」とだけ伝えると、見た目だけ整って onPress で Alert.alert("Restored!") を出すだけのコードを生成することがあります。これでは StoreKit と通信していません。

API は呼ぶが結果を反映していありません。 getAvailablePurchases() を呼んで配列を取得しているのに、取得したトランザクションを基にアプリ側の権限フラグ(例: AsyncStorage の is_premium)を更新していないケース。ユーザーから見ると「ボタンを押しても何も起きない」アプリです。

Sandbox 環境でしかテストしていありません。 TestFlight で動いているからといって、本番リリース後のレシート検証フローを通っていないことがあります。Sandbox レシートは sandbox.itunes.apple.com に送るのに対して、本番は buy.itunes.apple.com に送る必要があり、ここを切り替えていないと本番でだけ Restore が失敗します。

expo-in-app-purchases での実装

Rork で生成された Expo プロジェクトで IAP を扱う場合、選択肢は expo-in-app-purchases(Expo SDK 49 まで標準サポート、現在は extras 扱い)か react-native-iap(コミュニティ製、機能豊富)の2択になります。新規プロジェクトなら後者を推奨しますが、すでに expo-in-app-purchases で書かれているコードを修正する場合の最小実装を示します。

// 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 {
    // StoreKit との接続を確認
    await InAppPurchases.connectAsync();
 
    // Apple ID に紐付いた購入履歴を取得
    const { results, responseCode } = await InAppPurchases.getPurchaseHistoryAsync({
      useGooglePlayCache: false, // iOS では無視されるが明示的に設定
    });
 
    if (responseCode !== InAppPurchases.IAPResponseCode.OK) {
      throw new Error(`Restore failed with code ${responseCode}`);
    }
 
    // アクティブなサブスクリプションを抽出
    const activeSubscription = results?.find(
      (purchase) =>
        purchase.productId === 'com.example.app.premium_monthly' &&
        purchase.transactionReceipt &&
        !purchase.acknowledged === false
    );
 
    if (activeSubscription) {
      // 権限フラグを更新(最も忘れられやすい部分)
      await AsyncStorage.setItem('is_premium', 'true');
      await AsyncStorage.setItem('premium_restored_at', new Date().toISOString());
      Alert.alert('復元完了', 'サブスクリプションを復元しました。');
      return true;
    } else {
      Alert.alert('復元する購入なし', 'この Apple ID で購入された有効なサブスクリプションが見つかりませんでした。');
      return false;
    }
  } catch (error) {
    console.error('[restorePurchases] error:', error);
    Alert.alert('エラー', '購入の復元に失敗しました。ネットワークを確認してもう一度お試しください。');
    return false;
  } finally {
    await InAppPurchases.disconnectAsync();
  }
}

このコードのポイントは、AsyncStorage.setItem('is_premium', 'true') の行が必ず含まれていることです。getPurchaseHistoryAsync が成功しても、アプリ内で会員権限のフラグを更新しない限り、ユーザーから見ると「ボタンを押しても何も変わらない」状態のままです。

react-native-iap での実装

新規プロジェクトや、より細かい制御が必要な場合は react-native-iap を使います。こちらの方が現在は活発にメンテナンスされていて、StoreKit 2 の async/await にも対応しています。

// 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();
 
    // Apple ID 配下のすべての有効な購入を取得
    const purchases: Purchase[] = await getAvailablePurchases();
 
    // サブスクリプション系の productId に絞り込み
    const activePurchases = purchases.filter((p) =>
      Object.values(PRODUCT_IDS).includes(p.productId)
    );
 
    if (activePurchases.length === 0) {
      Alert.alert('復元なし', 'この Apple ID で購入された有効なサブスクリプションが見つかりませんでした。');
      return false;
    }
 
    // 最新の購入を権限の根拠にする
    const latestPurchase = activePurchases.sort(
      (a, b) => (b.transactionDate ?? 0) - (a.transactionDate ?? 0)
    )[0];
 
    // サーバー側でレシート検証する場合はここで送信
    // const verified = await verifyReceiptOnServer(latestPurchase.transactionReceipt);
 
    // アプリ内の権限フラグを更新
    await AsyncStorage.setItem('is_premium', 'true');
    await AsyncStorage.setItem('premium_product_id', latestPurchase.productId);
 
    // トランザクションを完了状態にする(重要: これを忘れると同じ購入が何度も復元イベントとして来る)
    await finishTransaction({ purchase: latestPurchase, isConsumable: false });
 
    Alert.alert('復元完了', 'プレミアム機能をご利用いただけます。');
    return true;
  } catch (error: any) {
    console.error('[restorePurchases] error:', error);
    Alert.alert('エラー', error?.message ?? '購入の復元に失敗しました。');
    return false;
  } finally {
    await endConnection();
  }
}

finishTransaction の呼び出しを忘れると、次回起動時にも同じトランザクションが「未処理」として降ってきて、ユーザーが Restore を押すたびに同じ購入を再認識する挙動になります。これは見た目の不具合だけでなく、Apple の審査チームがランダムに何度も Restore を押して挙動を確認するため、リジェクト理由になることがあります。

設定画面に必ずボタンを置く

実装ができたら、ユーザーがすぐ見つけられる場所に Restore Purchases ボタンを配置します。Apple が推奨する位置は2つあります。

ひとつはサブスクリプション購入画面の中。新規ユーザーが購入を検討するときに「すでに購入済みの場合はこちら」という選択肢を見せます。もうひとつは設定画面のサブスクリプション関連項目。これが審査でチェックされる主要な場所です。

// 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}>サブスクリプション</Text>
 
      <TouchableOpacity
        style={styles.row}
        onPress={() => restorePurchases()}
        accessibilityLabel="購入を復元する"
        accessibilityRole="button"
      >
        <Text style={styles.label}>購入を復元 (Restore Purchases)</Text>
      </TouchableOpacity>
    </View>
  );
}

accessibilityLabel と accessibilityRole を必ず指定してください。Apple の審査チームは VoiceOver でアプリを操作することがあり、ラベルがない要素は「機能していない」と判定されることがあります。

Sandbox での確認手順

実装が終わったら、本物の課金が走らない Sandbox 環境でテストします。手順は次の通りです。

まず App Store Connect の「ユーザーとアクセス」から Sandbox テスター用のメールアドレスを作成します。実機の「設定 → App Store → サンドボックスアカウント」にこのアカウントでサインインしておきます。本来の Apple ID とは別のアカウントが必要なので、Gmail のエイリアス機能(yourname+sandbox1@gmail.com のような書き方)を使うと管理しやすくなります。

次に TestFlight ではなく、Xcode から直接実機にビルドします。TestFlight ビルドだと一部のエッジケース(特に解約後の挙動)がテストしづらいためです。アプリを起動して購入フローを通し、一度購入したあとアプリを削除して再インストール、Restore Purchases ボタンを押して権限が戻ることを確認します。

ここで重要な確認ポイントがあります。Sandbox では月額サブスクリプションが3分で1ヶ月分の更新サイクルとして動きます。短時間で「更新 → 解約 → 復元」のシナリオを再現できるので、エッジケースを潰すには絶好のタイミングです。

リジェクト後の再申請メッセージ

もし運悪くリジェクトされた場合、再申請時のレビューノート(Resolution Center への返信)に次のメッセージを添えると、再審査がスムーズに進みやすくなります。

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 will call StoreKit's getAvailablePurchases() and restore active subscriptions tied to the user's Apple ID. We have tested this with sandbox accounts and confirmed that the user's premium status is correctly re-activated after a fresh install.

具体的な配置場所と動作内容を明記することで、審査担当者が迷わずに再確認できます。「修正しました」だけでは再リジェクトの確率が上がるので、「どこに」「何を」実装したかを必ず添えてください。

関連記事

実装後に他のリジェクト要因も潰しておきたい方は、Rork アプリが App Store 審査で3回リジェクトされた理由と対処法を併せて読むと、よくあるリジェクトパターン全般を把握できます。Privacy Manifest 関連のリジェクト対応はPrivacy Manifest API Declaration リジェクト修正ガイドに詳しくまとめてあります。

StoreKit 2 で本格的にサブスクリプション基盤を作る場合は、Rork Max で StoreKit 2 の In-App Purchase を実装する完全ガイドが、Family Sharing の挙動を含めた本番運用はFamily Sharing で IAP が消える4つのトリガーが参考になります。

書籍で体系的に

全体を振り返って

Restore Purchases は「ボタンを置く」だけでは終わらず、StoreKit から取得した購入情報を根拠にアプリ内の権限フラグを更新するところまでが最低条件です。今日からできる一歩としては、現在のコードで getAvailablePurchases の返り値を AsyncStorage に反映する処理が入っているかを確認してみてください。入っていなければ、上記の実装を参考に1時間以内に修正できます。次の審査リジェクトを未然に防ぐ、最も投資対効果の高い修正のひとつです。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-05-23
Rork 生成 Expo アプリの Privacy Manifest を SDK 連鎖まで監査する — 提出前1日で洗い切る個人開発者の手順
Rork が生成する Expo アプリの依存 SDK を Privacy Manifest 観点で全数監査する手順です。Required Reason API の事前検出、ITMS-91053 と ITMS-91061 の切り分け、npm では見えない Pod を Podfile.lock から拾う照合まで、個人開発の運用としてまとめました。
⬡ 開発ツール2026-04-28
Family Sharing で IAP が消える4つのトリガー — Rork で子ども向けアプリの課金を安全に設計する
Family Sharing 経由の購入で IAP 収益が突然消える原因と、Rork で実装する StoreKit 2・Server Notifications V2 を組み合わせた安全な課金設計を、実コードとテスト手順まで網羅的に解説します。
⬡ 開発ツール2026-10-15
EXPO_PUBLIC_ の値が、開発では読めて本番だけ undefined になる書き方を、変換結果で見分けます
API の URL が開発中は読めるのに、ビルドした途端に undefined になる。原因は process.env の書き方でした。babel-preset-expo の変換結果を開発・本番で並べ、置き換わる書き方と残ってしまう書き方を表にしました。