はじめに

前回の記事では、App Store の審査でリジェクトされたこと、そして Stripe と RevenueCat を共存させる設計に至るまでの意思決定を書きました。

今回はその続きです。実際にどう実装したかを記録します。

コードを中心に書いていくので、同じような構成(Web 側 Stripe + iOS 側 RevenueCat)を検討している方の参考になれば幸いです。


前提:全体のアーキテクチャ

実装に入る前に、構成を整理しておきます。

         Web                            iOS アプリ
    Stripe Checkout               React Native / Expo
          │                                │
          │  Stripe Webhook 送信            │  RevenueCat
          │  (非同期)                     │  Apple IAP 管理
          │                                │  Webhook 送信(非同期)
          ▼                                ▼
    ┌──────────────────────────────────────────────────┐
    │                サーバー(Next.js)                │
    │                                                  │
    │   /api/billing/webhook    /api/iap/webhook        │
    │                                                  │
    │            getEntitlementsForUser()              │
    │     Stripe + IAP を統合判定 → free / premium     │
    └───────────────────────┬──────────────────────────┘
                            │
                            │  通常リクエスト(同期)
                            ▼
                      クライアントへ返す

Stripe と RevenueCat をサーバーで統合するアーキテクチャ

ポイントは 「どこで払ったかをサーバーが統合する」 設計です。クライアントは Stripe か RevenueCat かを意識せず、サーバーが返した権限情報だけを見ます。


1. RevenueCat のセットアップ

ダッシュボードの設定

RevenueCat のダッシュボードでやることは3つです。

  1. プロジェクトを作成 → iOS App を登録
  2. App Store Connect と連携 → API キーを登録(App Store Connect → ユーザーとアクセス → キー から発行)
  3. Products と Offering を設定 → App Store Connect で作った In-App Purchase の Product ID を登録し、Offering を作る

Offering は RevenueCat のダッシュボード上で管理されるので、アプリを再リリースしなくても「今売っているプラン」を変更できます。これが RevenueCat を使う実用上の大きなメリットのひとつです。

API キーの取得

ダッシュボードの Project Settings → API Keys から iOS 用の Public API Key を取得して環境変数に設定します。

REVENUECAT_API_KEY_IOS=appl_xxxxxxxxxxxx

2. iOS(React Native / Expo)側の実装

パッケージのインストール

npx expo install react-native-purchases

react-native-purchases が RevenueCat の React Native SDK です。Expo 管理ワークフローで使えます。

iap.ts ── RevenueCat の操作を1ファイルに集約

RevenueCat 関連の操作をすべて lib/iap.ts にまとめました。最初に「iOS かつ API キーが設定済みの場合だけ有効にする」ガード関数を作るのがポイントです。

import { Platform } from 'react-native'
import Purchases, { type PurchasesPackage } from 'react-native-purchases'
import { REVENUECAT_API_KEY_IOS } from './config'

// iOS かつ API キー設定済みの場合のみ有効にするガード
function isRevenueCatEnabled(): boolean {
  return Platform.OS === 'ios' && Boolean(REVENUECAT_API_KEY_IOS)
}

このガードを各関数の先頭で呼ぶことで、Android や開発環境(API キー未設定)での呼び出しを静かに no-op にできます。

初期化

アプリ起動時(_layout.tsx など)に一度だけ呼び出します。

export function configureRevenueCat(): void {
  if (!isRevenueCatEnabled()) return
  Purchases.configure({ apiKey: REVENUECAT_API_KEY_IOS })
}

appUserID を渡さない設計にしています。最初は匿名ユーザーとして起動し、ログイン後に認証ユーザーに紐付けます。

ログイン・ログアウトとの連携

// ログイン成功後に呼び出す
export async function loginToRevenueCat(userId: string): Promise {
  if (!isRevenueCatEnabled()) return
  await Purchases.logIn(userId)
}

// ログアウト時に匿名に戻す
export async function logoutFromRevenueCat(): Promise {
  if (!isRevenueCatEnabled()) return
  await Purchases.logOut()
}

Purchases.logIn(userId) は、現在の App User ID を認証ユーザーへ切り替えます。切り替え前が匿名 ID で、認証ユーザーの ID に別の匿名 alias がまだない場合は、2つの CustomerInfo が統合され、匿名状態で購入した権利も認証ユーザーから参照できます。一方、認証ユーザーの ID がすでに別の匿名 alias を持つ場合は、統合も購入移動も行われません。logIn() の返り値に含まれる CustomerInfo で entitlement を確認してください。

ただし、別の App User ID から購入を復元したときに権利が元のユーザーへ残るか、新しいユーザーへ移るかは、RevenueCat Dashboard の「Transferring purchases seen on multiple App User IDs」の設定でも変わります。

実装前にログアウト、再インストール、アカウント切替、購入復元後の動きを比較したい場合は、EntitleSim で5つのケースを確認できます。実際の RevenueCat プロジェクトは検査しない教育用モデルなので、最後は Sandbox で確認してください。

購入フロー

// 購入可能なパッケージ一覧を取得
export async function getOfferings(): Promise {
  if (!isRevenueCatEnabled()) return []
  const offerings = await Purchases.getOfferings()
  return offerings.current?.availablePackages ? []
}

// 購入する
export async function purchasePremium(
  pkg: PurchasesPackage,
): Promise<{ success: boolean }> {
  if (!isRevenueCatEnabled()) return { success: false }
  try {
    await Purchases.purchasePackage(pkg)
    return { success: true }
  } catch (error: unknown) {
    // キャンセルはエラーではなく正常ケース
    if (
      typeof error === 'object' &&
      error !== null &&
      'userCancelled' in error &&
      (error as { userCancelled: boolean }).userCancelled
    ) {
      return { success: false }
    }
    throw error
  }
}

ユーザーが購入画面をキャンセルしたとき、RevenueCat SDK はエラーとして throw します。ただしこれは正常な操作なので、userCancelled フラグを確認して { success: false } として返しています。エラーログが汚れずに済みます。

購入復元

export async function restorePurchases(): Promise {
  if (!isRevenueCatEnabled()) return
  await Purchases.restorePurchases()
}

機種変更や再インストール後のために、設定画面に「購入を復元」ボタンを置いています。App Review の要件でもあります。


3. Webhook の設定とサーバー連携

RevenueCat ダッシュボードで Webhook を登録

Project Settings → Integrations → Webhooks から受信エンドポイントを登録します。

https://your-domain.com/api/iap/webhook

Authorization ヘッダーに使うトークンもここで設定します。サーバー側の環境変数と同じ値を入れます。

REVENUECAT_WEBHOOK_AUTH_TOKEN=your-secret-token

サーバー側の Webhook ハンドラ

Next.js の Route Handler として実装しました(/api/iap/webhook/route.ts)。

export async function POST(request: Request) {
  // 1. Authorization ヘッダー検証
  const expectedToken = process.env.REVENUECAT_WEBHOOK_AUTH_TOKEN
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${expectedToken}`) {
    return new Response('Unauthorized', { status: 401 })
  }

  // 2. JSON パース + バリデーション
  const payload = await request.json() as RevenueCatWebhookPayload
  const { event } = payload

  // 3. Subscriber identity から内部 UUID を解決
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
  const candidateUserIds = [
    event.app_user_id,
    event.original_app_user_id,
    ...(event.aliases ? []),
  ]
  const userId = candidateUserIds.find(
    (id): id is string => typeof id === 'string' && UUID_REGEX.test(id),
  )
  if (!userId) {
    return new Response('OK', { status: 200 })
  }

  // 4. 冪等性制御
  const claimed = await claimEvent(event.id)
  if (!claimed) return new Response('OK', { status: 200 })

  // 5. iap_subscriptions を upsert
  await db.insert(iapSubscriptions)
    .values({ userId, status, currentPeriodEnd, ... })
    .onConflictDoUpdate({ target: iapSubscriptions.originalTransactionId, set: { status, currentPeriodEnd } })

  // 6. Stripe + IAP を統合して users.plan を更新
  const entitlements = await getEntitlementsForUser(userId)
  await db.update(users).set({ plan: entitlements.plan }).where(eq(users.id, userId))

  return new Response('OK', { status: 200 })
}

event_type → status のマッピング

RevenueCat が送ってくるイベントタイプを自前の status に変換します。

const EVENT_STATUS_MAP: Record = {
  INITIAL_PURCHASE: 'active',
  RENEWAL:          'active',
  CANCELLATION:     'active',   // 解約しても期限まで有効
  UNCANCELLATION:   'active',
  EXPIRATION:       'expired',
  BILLING_ISSUE:    'in_billing_retry',
}

CANCELLATIONactive にしているのは意図的です。Apple の仕様では、サブスクを解約しても現在の期間終了まで利用できます。EXPIRATION が来て初めて expired に変わります。


4. Stripe との entitlement 統合

ここが設計の核心です。getEntitlementsForUser() が Stripe と IAP の両テーブルを確認して、権限を返します。

export async function getEntitlementsForUser(userId: string): Promise {
  // Stripe と IAP を並行取得
  const [stripeSub, iapSub] = await Promise.all([
    db.query.subscriptions.findFirst({ where: eq(subscriptions.userId, userId) }),
    db.query.iapSubscriptions.findFirst({
      where: and(
        eq(iapSubscriptions.userId, userId),
        inArray(iapSubscriptions.status, ['active', 'in_billing_retry', 'expired']),
      ),
      orderBy: [sql`${iapSubscriptions.currentPeriodEnd} DESC NULLS LAST`],
    }),
  ])

  const stripeActive = isPremiumActive(stripeSub?.status, stripeSub?.currentPeriodEnd)
  const iapActive    = isIAPPremiumActive(iapSub?.status, iapSub?.currentPeriodEnd)

  // どちらか有効なら premium
  if (!stripeActive && !iapActive) return buildEntitlements('free', null, null)

  // 両方有効な場合は currentPeriodEnd が長い方を source にする
  if (stripeActive && iapActive) {
    const source = iapEnd >= stripeEnd  'apple' : 'stripe'
    return buildEntitlements('premium', source, longerPeriodEnd)
  }

  if (iapActive) return buildEntitlements('premium', 'apple', iapSub!.currentPeriodEnd)
  return buildEntitlements('premium', 'stripe', stripeSub!.currentPeriodEnd)
}

Stripe と IAP では status の体系が違うので判定関数を分けています。

// Stripe: 'active' | 'trialing' → Premium
//         'past_due' | 'unpaid' → Free(即時)
//         'canceled' → currentPeriodEnd が未来なら猶予 Premium

// IAP: 'active' → Premium
//      'in_billing_retry' | 'expired' → currentPeriodEnd が未来なら猶予 Premium

両方有効な場合のルールcurrentPeriodEnd が長い方を source にしました。表示上の意味があります。source === 'apple' なら「Apple の管理画面でキャンセルしてください」、source === 'stripe' なら「Stripe のポータルでキャンセルしてください」という案内を出せます。


実装で詰まったところ

匿名ユーザー ID が UUID ではない

RevenueCat はログイン前のユーザーに $RCAnonymousID:xxxx という形式の ID を割り当てます。サーバーは UUID でユーザーを管理しているため、そのまま処理しようとすると DB の外部キー制約でエラーになります。

解決策は event.app_user_id だけで判断せず、Webhook の original_app_user_idaliases も含めて、サーバーの内部 UUID を探すことです。

const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const candidateUserIds = [
  event.app_user_id,
  event.original_app_user_id,
  ...(event.aliases ? []),
]
const userId = candidateUserIds.find(
  (id): id is string => typeof id === 'string' && UUID_REGEX.test(id),
)
if (!userId) {
  return new Response('OK', { status: 200 })
}

logIn() による CustomerInfo の統合は条件付きで、購入イベントが UUID に差し替わって再送される保証もありません。候補に内部 UUID がない場合、この Webhook だけではサーバー側ユーザーへ関連付けられないため、DB は更新しません。匿名購入を許可する設計では、ログイン後に logIn() が返した CustomerInfo を使う同期 API など、別の照合経路を用意してください。

Webhook の冪等性

RevenueCat はネットワークエラー時などにリトライします。同じイベントが複数回届く可能性があります。

iapWebhookEvents という専用テーブルを作り、INSERT ... ON CONFLICT DO NOTHING で原子的にクレームする実装にしました。処理中にエラーが起きた場合はクレームを解放して RevenueCat にリトライさせます。

購入直後の反映遅延

購入完了直後は Webhook がまだサーバーに届いていません。画面をリロードしても「まだ Free」と表示されてしまいます。

対策として、購入成功後に RevenueCat SDK から直接 getCustomerInfo() を呼び出して即時確認するフォールバックを入れています。

export async function getCustomerInfo(): Promise<{ isPremium: boolean }> {
  if (!isRevenueCatEnabled()) return { isPremium: false }
  const customerInfo = await Purchases.getCustomerInfo()
  const isPremium = customerInfo.activeSubscriptions.length > 0
  return { isPremium }
}

Webhook が届いた後は DB の状態が正になるので、フォールバックが使われるのは購入直後の短時間だけです。


まとめ

実装してみての感想は、RevenueCat 自体の難易度は高くない、という点です。SDK のインターフェースがシンプルで、Webhook のドキュメントも整備されています。

難しいのは Stripe と共存させるときの設計です。「どこで払ったかに関係なく、サーバーが権限の唯一の正とする」という方針を最初に決めておくと、実装が整理しやすくなります。

  • 決済手段(Stripe / RevenueCat)と権限判定(getEntitlementsForUser)を分離する
  • Webhook は冪等に処理する
  • 購入直後のフォールバックを用意する

この3点が Stripe 共存構成での IAP 実装のポイントでした。


Paleotribes(パレトラ)は習慣・ウェルネス管理のモバイルアプリです。現在 iOS 版を App Store で公開中です。