はじめに

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

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

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


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

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

Stripe + RevenueCat 共存アーキテクチャ Stripe + RevenueCat 共存アーキテクチャ Web Stripe Checkout iOS アプリ React Native / Expo Stripe Webhook 送信 RevenueCat Apple IAP 管理 Webhook 送信 サーバー(Next.js) /api/billing/webhook /api/iap/webhook getEntitlementsForUser() Stripe + IAP を統合判定 → free / premium を返す クライアントへ返す 通常リクエスト Webhook(非同期)
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) を呼ぶと、それまで匿名ユーザーが持っていた購入履歴が認証ユーザーに引き継がれます。「購入してからログインした」というケースでも購入が消えません。

購入フロー

// 購入可能なパッケージ一覧を取得
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. 匿名 ID をスキップ
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
  if (!UUID_REGEX.test(event.app_user_id)) {
    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 の外部キー制約でエラーになります。

解決策は Webhook ハンドラで UUID かどうかを正規表現でチェックして、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
if (!UUID_REGEX.test(event.app_user_id)) {
  return new Response('OK', { status: 200 }) // 匿名 ID はスキップ
}

スキップして問題ない理由は、ユーザーがログインしたとき Purchases.logIn(userId) が呼ばれ、RevenueCat が匿名ユーザーの購入履歴を認証ユーザーに移行して Webhook を UUID で再送するためです。匿名 ID の Webhook は「ログイン前の一時状態」で、実用上は処理不要です。

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 で公開中です。