💸React Native + Expo でアプリ内課金(IAP)を実装した実践まとめ〜 ストア設定・実装・Backend 検証〜
2026-02-17
azblob://2026/02/17/eyecatch/2026-02-17-ios-android-iap-000.jpg

📝はじめに

React Native + Expo でアプリを作っていると、  
アプリ内課金(In-App Purchase / IAP) は避けて通れない機能のひとつです。

ただ、実際にやってみると、

  • 購入イベントが二重で飛ぶ
  • Sandbox と本番のレシートで挙動が違う
  • finishTransaction() をいつ呼ぶのが正解か分からない
  • クライアント側だけで処理していいのか不安

など、実装・設計・運用すべてに落とし穴が多いのが正直なところです。

この記事では、  
Expo + react-native-iap を使った実アプリの実装をベースに、

  • モバイル側の実装(iOS / Android 両対応)
  • バックエンドでのレシート検証
  • 冪等性・セキュリティ・監査ログ
  • 実運用を見据えた設計判断

まで含めて、かなり踏み込んだ内容をまとめます。


✨全体アーキテクチャ

まずは全体像です。

採用技術

  • フロントエンド
    • Expo SDK 54
    • React Native
    • react-native-iap v14
  • バックエンド
    • Node.js + Fastify
    • Prisma + PostgreSQL
    • クリーンアーキテクチャ + DI
  • 課金モデル
    • 消耗型(Consumable)コイン商品

商品設計(コイン型 IAP)

今回のアプリでは、  
コインを購入し、アプリ内機能で消費する設計にしています。

コイン数iOS Product IDAndroid Product ID
100ios_coin_100android_coin_100
500ios_coin_500android_coin_500
1000ios_coin_1000android_coin_1000
3000ios_coin_3000android_coin_3000
  • すべて 消耗型(Consumable)
  • 残高は Backend で管理
  • クライアントは「どの商品を買ったか」だけを送信

この商品設計が、そのまま  
App Store Connect / Google Play Console の商品登録内容になります。


📱iOS:App Store Connect での商品登録

商品設計が決まったら、まず iOS 側のストア設定を行います。

ここが終わっていないと、  
getProducts()fetchProducts() は何も返しません。

設定手順

  1. App Store Connect にログイン
  2. 対象アプリを選択
  3. 「アプリ内課金」→「管理」
  4. 「+」から新規商品を作成
  5. 商品タイプは 消耗型(Consumable)
  6. Product ID / 価格 / 表示名を設定
  7. 日本語ローカリゼーションを追加

この時点で  
「商品が App Store 側に存在している」ことが目視できれば OKです。


📱Android:Google Play Console での商品登録

次に Android 側の設定です。

設定手順

  1. Google Play Console にログイン
  2. 対象アプリを選択
  3. 「収益化」→「商品」→「アプリ内アイテム」
  4. 商品を作成
  5. Product ID / 名前 / 価格を設定
  6. ステータスを有効化

※ Android は  
内部テストトラックにビルドをアップロードしていないと購入できません。  
ここはかなりハマりやすいポイントです。


🚨なぜ Backend 検証が必須なのか

IAP 実装で一番重要な考え方です。

❌ やってはいけない構成

購入成功 → クライアントでコイン付与

これだと、

  • アプリ改ざん
  • 偽レシート送信
  • 同一購入の多重付与

が簡単に成立します。

✅ 正しい構成

購入成功
  ↓
Backend にレシート送信
  ↓
Apple / Google API で検証
  ↓
OKなら権利付与

クライアントは検証しない  
この原則を守っています。


🌐バックエンド:(コアロジック)

バックエンドが処理の中心になります。

処理の流れ

  1. レシート / トークンを SHA-256 でハッシュ化
  2. 既存購入のチェック(冪等性)
  3. Apple / Google API で検証
  4. 監査ログを S3 に保存
  5. DB トランザクションで権利付与

実装(抜粋)

Javascriptconst receiptHash = hashReceipt(receiptOrToken);

const existing = await purchaseRepo.findByReceiptOrToken(receiptHash);
if (existing) {
  return {
    verified: true,
    purchaseId: String(existing.purchaseId),
    message: 'Already processed'
  };
}
  • 同じレシート = 同じハッシュ
  • 二重処理は Backend 側で完全防止
  • クライアントは Already processed を成功扱いする

✅Apple レシート検証(iOS)

Javascriptconst result = await this.callAppleAPI(this.productionUrl, receiptData);

if (result.status === 21007) {
  // Sandbox レシートは Sandbox URL で再検証
  result = await this.callAppleAPI(this.sandboxUrl, receiptData);
}

if (result.status !== 0) {
  return { isValid: false };
}

ポイント:

  • 本番 → Sandbox の自動フォールバック
  • タイムアウト必須
  • shared secret をログに出さない

✅Google レシート検証(Android)

Javascriptif (data.purchaseState !== 0) {
  return { isValid: false };
}

return {
  isValid: true,
  storeProductId,
  orderId: data.orderId,
};

ポイント:

  • サービスアカウントで API 認証
  • purchaseState === 0 のみ有効
  • Product ID は必須

🔍️モバイル側:商品ID管理

iOS / Android の違いは 最初に吸収します。

Javascriptexport const PRODUCT_IDS =
  Platform.OS === 'ios' ? IOS_PRODUCT_IDS : ANDROID_PRODUCT_IDS;

export const ALL_PRODUCT_IDS = Object.values(PRODUCT_IDS);
  • UI / ロジックは coin_100 のようなキーだけを見る
  • ストア差分は constants に閉じ込める

📌IAP 初期化と商品取得

Javascriptawait RNIap.initConnection();

const products = await RNIap.fetchProducts({
  skus: ALL_PRODUCT_IDS,
});
  • 起動時に商品を取得して保持

🔀購入フロー(イベントドリブン)

購入リクエスト

Javascriptawait RNIap.requestPurchase({
  request: Platform.OS === 'ios'
    ? { ios: { sku: productId } }
    : { android: { skus: [productId] } },
  type: 'in-app',
});

購入イベント受信

JavascriptRNIap.purchaseUpdatedListener(async (purchase) => {
  await processTransaction(purchase);
});

多重処理防止(必須)

Javascriptif (processedTransactions.has(transactionId)) return;
processedTransactions.add(transactionId);
  • 同じ購入イベントが複数回飛ぶことがある
  • 対策しないと二重付与される

Backend 検証 → finishTransaction

Javascriptconst result = await iapService.verifyReceipt(purchase);

if (result.verified) {
  await RNIap.finishTransaction({
    purchase,
    isConsumable: true,
  });
}

なぜこの順番か

  • 検証前に finish → 危険
  • 検証失敗時は あえて呼ばない
    • トランザクションは OS に残る
    • 次回起動時に再処理できる

Xcodeで確認する場所

ビルド前(Xcode)

  • Xcode > Signing & Capabilities > +Capabilities
    • In-App Purchaseが追加されているかを確認

iOS Sandbox テスト

  • 設定 > App Store > Sandbox アカウント
  • 本番 Apple ID は必ずサインアウト

購入時:


セキュリティ設計まとめ

項目対策
レシート保存SHA-256 ハッシュのみ DB 保存
冪等性ハッシュで重複検知
検証Apple / Google 公式 API
監査S3 に暗号化保存(10年)
ログ秘密情報を出さない
原子性DB トランザクション
クライアントBackend 成功後のみ完了

まとめ

  • IAP は イベントドリブン + 冪等性
  • クライアント検証はしない
  • finishTransaction() は Backend 成功後
  • 二重処理対策は必須

実装量は多いですが、  
この構成にしておくと 安心して運用できます