アプリ内課金の実装は、個人開発でいちばん壊すのが怖い部分です。レシートの署名検証、返金されたときの巻き戻し、通知の再送で二重付与、機種変後の扱い — どれも「作りたかった機能」ではないのに、間違えるとユーザーのお金に直結します。

この記事では、自前のStoreKit実装を途中で捨ててRevenueCatに移した実例と、iOSアプリ2本での実際の組み方を公開します。

この記事で分かること

  • RevenueCatが具体的に何を代行してくれるのか
  • 自前実装を捨てたとき、実際に消えたコードと依存
  • 消費型チケットでの組み方(購入 → Webhook → サーバー台帳)
  • appUserIDの設定ミスという、最初に踏みやすい地雷
  • そのまま使える導入チェックリスト

結論: 代行してくれるのは「検証」まで。台帳は自分で持つ

  • RevenueCatの本質はレシート検証と購入イベント配信の代行です。ストアとの通信で一番間違えやすい部分を、まるごと外注できる
  • 個人開発で効くのは機能の多さより、捨てられる実装量。うちは署名検証・Appleのサーバーライブラリ依存・通知受信・返金検知が全部消えました
  • ただし、教えてもらえるのは「購入があった」というところまでで、その後は自分の責任です。残高やプラン状態を持つ台帳は、自分のサーバーに用意する必要がある
  • 料金は月間トラッキング収益$2,500まで無料、超過分1%(2026年8月時点)。売れてから払う形なので、個人開発とは相性がいい

RevenueCatは何を代行するのか

課金を自前で実装した場合と並べると、担当範囲がはっきりします。

必要な処理 自前でやると RevenueCatなら
レシート/署名の検証 Appleのサーバーライブラリを入れてJWS検証を実装 RCサーバーが実施
ストアからの通知受信 App Store Server Notificationsのエンドポイントを実装 RCが受けて整形
返金・キャンセルの検知 通知の種別を解析して自前で判定 CANCELLATIONイベントとして届く
複数ストアの差異 iOS/Androidで別実装 SDKとイベント形式が共通
購読状態の管理 有効期限・更新・猶予期間を自前で追跡 CustomerInfoで取得
数字の可視化 自前で集計 ダッシュボードが最初からある

逆にRevenueCatがやってくれないこともはっきりしています。「このユーザーはチケットを何枚持っているか」「どの機能を解放するか」は、自分のDBの仕事です。

実例①: 花庭 — 消費型チケット(Firebase構成)

花庭はAI画像生成を1回ごとにチケット消費する仕組みで、消費型IAPを3パック(10枚/30枚/60枚)用意しています。

途中まで自前で作っていた

もともとの設計はStoreKit 2 + 自前のJWS検証でした。購入トランザクションを監視し、署名を検証し、台帳に加算し、返金されたら巻き戻す — 全部書くつもりでいました。

結局それを捨ててRevenueCatに切り替えたのですが、そのとき実際に消えたものを並べるのがいちばん分かりやすいと思います。

  • サーバー側のJWS(署名)検証の実装
  • Appleのサーバー用ライブラリ依存(@apple/app-store-server-library)
  • App Store Server Notificationsの受信・解析処理
  • 返金の巻き戻し(B4として別仕様に切り出していた作業) → RCのCANCELLATIONイベントを処理するだけに縮小

「機能が増えた」ではなく「書く予定だったものが要らなくなった」のが導入効果でした。個人開発では、これがそのまま可処分時間になります。

全体の流れ

アプリ(purchases_flutter)
   ↓ 購入
Apple の購入シート
   ↓ レシート
RevenueCat(検証)
   ↓ Webhook(購入イベント)
Cloud Functions(revenuecatWebhook)
   ↓ 加算
Firestore のチケット台帳(サーバー権威)

ポイントは、クライアントは「買った」と自己申告しないことです。残高が増える経路はWebhook経由の1本だけ。付与枚数のマッピング(どの商品IDで何枚か)もサーバー側が真で、アプリから送られてきた枚数は一切信用しません。

最初に踏む地雷: appUserID

導入で最も大事な一行がこれです。

// 自分のユーザーIDを RevenueCat に教える
await Purchases.configure(
  PurchasesConfiguration(apiKey)..appUserID = uid,   // uid = 自前の認証ID
);

これを渡さないと、RevenueCatは匿名IDを自動で割り当てます($RCAnonymousID:で始まる文字列)。すると届いたWebhookのapp_user_idが自分のDBのどのユーザーとも一致せず、購入されたのに残高が増えないという事故になります。

うちではサーバー側にもガードを入れていて、匿名IDのイベントが届いたら加算せずに警告ログを残すようにしています。「本来起きないはずのこと」をログに残しておくと、設定ミスに気付けるので、ここは書いておく価値があります。

Webhookは同じイベントが2回来る前提で書く

この手のWebhookはat-least-once配送です。つまり同じ購入イベントが2回届く可能性があり、素直に書くとチケットが二重に付与されます。

対策はシンプルで、イベントIDを購入記録のキーにすること。

// RCの event.id をそのままドキュメントIDにして冪等化する
const purchaseRef = userRef.collection('purchases').doc(eventId)

await db.runTransaction(async (tx) => {
  if ((await tx.get(purchaseRef)).exists) return false   // 再送 → 何もしない
  tx.set(purchaseRef, { productId, tickets: count, createdAt: FieldValue.serverTimestamp() })
  tx.set(userRef, { tickets: ledger.tickets + count }, { merge: true })
  return true
})

Postgres(Supabase)なら、event_idUNIQUE制約を張るだけで同じことができます。

Webhookの認証

RevenueCatのWebhookは、任意のAuthorizationヘッダを設定して送れます。サーバー側ではその値をシークレットと照合し、一致しなければ拒否する。エンドポイントは公開URLなので、ここを省くと誰でも残高を増やせてしまいます

また、処理できない種別のイベントも200で返すのが実務的です。エラーを返すとRevenueCat側がリトライを繰り返すので、「受け取ったが何もしなかった」を正常応答で伝えます。

実例②: Morphica — Supabase構成 + バックフィル

もう1本のアプリMorphicaでもRevenueCatを使っていますが、受け口はSupabase側です。同じサービスがバックエンドを選ばないのは、地味に効くメリットです。

そしてこちらには、花庭には無い仕組みが1つあります。バックフィル(定期照合)です。

定期実行 → RevenueCatのREST API で購入状態を取得
        → 自分のDBと突き合わせて、ズレていたら直す

理由は単純で、Webhookはいつか取りこぼすからです。デプロイ中の一瞬、関数のコールドスタート、想定外のエラー — 恒久的に100%届く保証はどこにもありません。

そこで「イベント駆動(Webhook)で普段は即時反映し、定期照合で最終的な整合を取る」という二段構えにしています。課金は「だいたい合っている」では済まない領域なので、同期の経路を2つ持っておくのは個人開発でも十分ペイする保険です。

導入手順(iOS・消費型の場合)

実際にやる順番はこうなります。

  1. App Store Connectで商品を登録する(消費型/非消費型/サブスクの種別と価格)
  2. RevenueCatダッシュボードでプロジェクトを作り、App Store Connect APIキーを連携する
  3. アプリにpurchases_flutterを入れ、appUserIDに自前のユーザーIDを渡してconfigure
  4. 商品を取得して購入シートを出す(getProductspurchaseStoreProduct)
  5. RCダッシュボードでWebhook URLとAuthorizationヘッダを設定する
  6. サーバー側でWebhookを受け、商品ID→付与内容のマッピング(サーバーが真)で台帳を更新する
  7. 起動時に未完了トランザクションの同期を呼ぶ(syncPurchases相当)

ダッシュボード作業(1・2・5)は人間の手が必要ですが、コード側はSDK呼び出し数行 + Webhookハンドラ1本です。

ハマりどころ・注意点

  • appUserIDの渡し忘れ — 前述の通り、購入と自分のユーザーが紐づかなくなる。最初に必ず確認する
  • Sandboxイベントの扱い — テスト購入もWebhookで届きます。イベントに環境フラグが付くので、TestFlight検証中は許容し、本番後は弾くなどの方針を決めておく
  • 消費型に「復元」は無い — 非消費型やサブスクと違い、消費型は購入の復元という概念が弱いです。残高の真はサーバー台帳と割り切り、機種変対応は「同じアカウントでログインすれば残高が残る」形で解く
  • 反映まで数秒かかる — Webhook経由なので即時ではありません。購入直後のUIには「反映中」の表示を用意しておくと、ユーザーが不安になりません
  • プライバシー表記の更新 — RevenueCatは購入データの処理者になります。プライバシーポリシーとApp Privacyラベルへの反映を忘れずに
  • 料金は「売れてから」 — 月間トラッキング収益$2,500まで無料、超過分1%(2026年8月時点)。売上ゼロのうちはコストもゼロです

導入チェックリスト

実装したあと、これが全部通れば安心して出せます。

  • 同じWebhookイベントが2回届いても、付与は1回ぶんに留まる
  • Authorizationヘッダが不正なリクエストは拒否され、台帳が変化しない
  • 未知の商品IDのイベントでは加算されず、ログに記録される
  • 購入シートでキャンセルしたとき、エラー表示なしで元の画面に戻り、台帳が変化しない
  • 機内モードで購入ボタンを押したとき、通信が必要である旨が表示される
  • 匿名IDで購入 → ログインで昇格した後も、同じ残高が維持されている
  • 購入した権利の消費が失敗したとき、払い戻しが行われる
  • テストはフェイク実装で書けている(実際のストア通信に依存しない)

まとめ

  • RevenueCatが代行するのはレシート検証と購入イベントの配信。個人開発での価値は機能追加ではなく、書かずに済むコードの量
  • 自前のStoreKit実装から移して消えたのは、署名検証・Appleのサーバーライブラリ依存・通知受信・返金検知。返金対応はイベントを1種類処理するだけになった
  • ただし残高やプラン状態の台帳は自分の責任。クライアントの申告を信じず、サーバー側を真にする
  • 最初の地雷はappUserIDの渡し忘れ。自前のユーザーIDを渡さないと、購入とユーザーが繋がらない
  • Webhookは再送される前提で冪等に。さらに定期照合(バックフィル)を用意すると、取りこぼしにも耐えられる

課金は個人開発で唯一「バグがユーザーのお金に直結する」領域です。ここを外注できるなら、迷わず外注していいと思っています。