目次
- はじめに
- 前提:我々のサービスは何が特殊なのか
- 守りたいこと(設計原則)
- 全体像
- 工夫1:決済「前」に購入インテントを記録する
- 工夫2:Webhookを待たずに同期パスで権限を付与する
- 工夫3:Webhookは「検算と復旧」の係にする
- 工夫4:日次の取引データ突合(最後の砦)
- ハマったポイント
- おわりに
- 参考リンク
はじめに
既存サービスにアプリ内課金(IAP)を後付けする案件で、RevenueCatを使って決済ロジックを設計・実装しました。初めてのIAP導入で「決済は成功したのにコンテンツが見られない」という問い合わせだけは絶対に出したくない。そう考えて設計した内容をまとめます。
この記事で伝えたいこと(結論)
先に結論を書きます。
- 決済「前」に購入の意図をDBに記録し、決済後は取引IDを最初に単独でコミットして、復旧の起点を必ず残す。
- Webhookを待たず同期パスで即時に権限を付与する。Webhookは「答え合わせと復旧」の係にする。
- Webhookが届かないケースに備えて、日次で取引データを突合する最後の砦を置く。
- 自動で救えないケースをなくそうとするより「必ず検知される」状態を作る。
扱う範囲
この記事は決済ロジックに絞ります。
| 扱う | 扱わない |
|---|---|
| 決済開始からWebhook受信、日次の取引データ突合までの処理設計 | ストア商品(App Store Connect / Google Play Console)の登録方法 |
| サーバーが落ちた・Webhookが重複した・届かなかったときの挙動 | RevenueCat SDKの導入手順やUI実装 |
| 消耗型(単発購入)商品 | サブスクリプション(今後対応予定) |
想定読者
- ストアの決済結果を自社サーバーで受け取り、自社DBで権限を管理する必要がある人
- 決済まわりの障害時の挙動を設計したい人
- 決済ロジックの監視・運用まで含めて考えたい人
用語
記事内で使う言葉を先に定義します。
| 用語 | 意味 |
|---|---|
| 購入インテント | 「誰が・どのアプリで・どの商品を買おうとしているか」を決済前に記録したレコード |
| 取引 | ストアが発行する取引IDと、自社の購入関連データを紐付けるレコード |
| エンタイトルメント | 「この購入に対してストア側の契約が有効か」を表す、権限付与の判断材料。RevenueCatのEntitlement機能とは別物 |
| 購入関連データ | 1件の購入を売上として管理するために自社DBへ記録する一連のレコードの総称。Web決済とIAPで共通の形をとる |
| 同期パス | アプリからのリクエストを受けて、その場で権限付与まで完了させる処理経路 |
| 突合 | ストア側の取引一覧と自社DBを1件ずつ突き合わせて不整合を探すこと |
| 冪等 | 同じリクエストを何度処理しても結果が変わらないこと |
前提:我々のサービスは何が特殊なのか
ここは他社と条件が違う可能性が高い部分です。「RevenueCatのEntitlement機能だけで権限管理すれば済む」サービスなら、この記事の設計は過剰です。
商品数が多く、権限管理はすべて自社DBで行う
我々のサービスは動画講座を中心に千単位の商品を扱っています。すでにWeb側でカード決済などの販売を行っており、閲覧権限は「誰が・どの商品を・いつからいつまで見られるか」を自社DBの権限テーブルに展開しています。アプリもWebもこの権限テーブルを読んで表示を切り替えます。
そのためIAPを導入するにあたって、次の制約がありました。
- 権限の管理(Source of Truth)は自社DBであり続ける
- IAPの購入も、Web決済と同じ形の購入関連データとして記録する(売上集計や返金対応を既存の運用に乗せるため)
- RevenueCatのEntitlement機能で権限をまるごと管理する一般的な形は取れない。RevenueCatのEntitlementは「ある商品群へのアクセス権を持つか」を管理する仕組みであり、数千の商品を個別に扱い、Web購入など他の経路の権限とあわせて判定する必要がある我々には合わない
そのため、RevenueCatには ストアとの通信の仲介・イベント通知(Webhook)・購入状態の照会API・取引データのエクスポート を担ってもらい、権限そのものは持たせていません。
多数の商品を「価格ティア」としてストアに登録している
商品ごとにストア商品を登録するのではなく「同じ価格帯の商品は同じストア商品で購入する」形にしています。たとえば5,400円の商品はすべて「5,400円ティア」というストア商品を使います。つまりストア商品と自社商品は多対1です。
登録方法の話なので詳細は割愛しますが、この構造が「自動復旧できないケース」を生みます。
既存の「権限を導出する仕組み」に乗る
自社DBには「購入や解約の情報から権限テーブルを再計算する処理」がすでにあります。IAPでもこの仕組みに乗り、権限判定のロジックを二重に持たないようにしました。IAP側でエンタイトルメントとして新設したのは「権限を付与すべきかの判断材料」であって、権限テーブルそのものではありません。
守りたいこと(設計原則)
実装に入る前に決めた原則です。以降の工夫はすべてここから導いています。
- 「払ったのに見られない」を絶対に作らない。 金銭が絡む以上これが最優先。
- 「払っていないのに見られる」も作らない。 返金されたら権限を落とす。
- 二重課金させない。 すでに持っている商品はストア決済に進ませない。
- ストア側を正とする。 自社DBは「ストア(RevenueCat経由)で確定した事実」を写し取る側。アプリから送られてくる値はあくまで参考とする。全てはストア(RevenueCat)側データを正とする。
- すべての通知を冪等に処理する。 同じイベントが何度届いても結果が変わらない。
- 多層防御。 同期パス・Webhook・日次突合の3層で、どこか1層が壊れても不整合が「検知されずに残る」ことをなくす。
全体像
まず処理の流れを1枚の図にまとめます。細かい説明はあとの各章で行います。
3層の役割分担は次のとおりです。
| 層 | タイミング | 役割 |
|---|---|---|
| 同期パス | 決済直後(即時) | ユーザーを待たせずに権限を付与する |
| Webhook | 決済後60秒以内 | 同期パスの結果を検算し、失敗していれば復旧する |
| 日次突合 | 翌日 | ストア側の全取引と自社DBを突き合わせ、取りこぼしを検知する |
工夫1:決済「前」に購入インテントを記録する
なぜ決済「前」なのか
アプリ内課金の最大の特徴は、決済の主体がストアであることです。カード決済のように「自社サーバーが決済を実行し、その結果をDBに書く」のではなく、ストアで決済が完了したあとにアプリが結果を自社サーバーへ伝えます。
つまり「ストアで決済完了 → アプリが自社サーバーへ通知 → 自社DBにデータ作成」の間には必ず隙間があります。この隙間でサーバーが落ちたり通信が切れたりすると、お金だけ取られてデータが何もない 状態になります。取引IDすら自社側に残っていなければ「誰が何を買ったのか」を辿る手がかりがありません。
何をしたか
購入ボタンが押された直後、ストア決済に進む前に自社サーバーへ「決済前通知」を送ります。サーバーは購入インテント(誰が・どのアプリで・どの自社商品を・どのストア商品で買おうとしているか)をレコードとして残します。この時点で取引IDはまだ存在しないので空のままです。
ストア決済が完了すると、アプリは「決済完了通知」で取引IDを送ってきます。サーバー側の処理は次の順序で進みます。
- 重複購入チェック。決済前にも行っていますが、念のため再チェックします。購入済みならここで終了します。
- 購入インテントに取引IDと完了フラグを書き込み、それだけを単独でコミット します。
- RevenueCat APIで取引を照会し、実在する取引であることを確認します。
- 購入関連データ・取引・エンタイトルメントを1トランザクションで作成します。
- 権限テーブルを再計算します。この時点でユーザーはコンテンツを閲覧できます。
2の時点でコミットしておくことで、3以降のどこで失敗しても「取引IDと購入意図が自社DBに残っている」状態を保証できます。また4でユニーク制約違反が起きた場合は、Webhookによる復旧が先に完了していると判断し、成功扱いで終了します。
取引IDを「単独でコミットする」意味
購入関連データの作成と同じトランザクションに入れると、途中で失敗したときにロールバックされて取引IDも消えます。あえてトランザクションを分けることで「取引IDと購入意図が自社DBに残っている」状態を確実に作り、これをWebhook側の復旧の起点にしています。
重複購入チェックは決済「前」に行う
すでに所有している商品に対して、ストアで決済させてから「購入済みです」と返すのは最悪の体験です。返金対応も発生します。そのため重複購入チェックは決済前通知の時点で行い、購入済みならストア決済に進ませずアプリ側で購入済み表示に切り替えます。
工夫2:Webhookを待たずに同期パスで権限を付与する
Webhookを待つと何が起きるか
RevenueCatのWebhookは決済完了から60秒以内に届きます。数秒で届くこともあれば、数十秒かかることもあります。これを待って権限を付与すると、ユーザーは「購入したのに再生ボタンが押せない」時間を過ごすことになり、問い合わせが増える原因になります。
そこで決済完了通知の同期パスの中で権限付与まで完了させます。Webhookは「あとから答え合わせをする係」です。
アプリの値を信じず、RevenueCat APIで確定する
同期パスではアプリから送られてくる取引IDをキーにRevenueCatのAPIで購入データを照会し、実在する取引であることと購入日時を確認してからデータを作成します。
これにより、ストア上に存在しない購入を自社DBに記録しないことを担保しています。
購入関連データは1トランザクション、重複はユニーク制約で弾く
購入関連データ・取引・エンタイトルメントの一連のレコードは1トランザクションで作成します。そして取引テーブルに 「アプリID+取引ID」の複合ユニーク制約 を張り、同じ取引が二重に作成されようとしたらDBが弾く形にしました。アプリケーション側は「ユニーク違反=作成済み」として成功扱いにするだけです。
複合ユニークにしているのは、Apple/Googleの取引IDがアプリをまたいでグローバルに一意であることが公式に保証されていないためです。
「エンタイトルメント」と「閲覧権限」を分ける
我々の事情に強く依存する部分ですが、設計上のこだわりでもあります。
- エンタイトルメント:「この購入に対してストア側の契約が有効か」を表す判断材料。RevenueCatの購入状態(owned / refunded)を写し取る。
- 閲覧権限:既存の権限導出処理が、購入・解約・エンタイトルメントなどを材料に再計算して権限テーブルに展開したもの。アプリやWebはこれだけを読む。
エンタイトルメントを「権限そのもの」にしなかったのは、既存のWeb決済と権限の導出ロジックを一本化したかったからです。IAP固有の判断はエンタイトルメントに閉じ込め、権限の計算は既存の仕組みに委ねます。表示側は権限テーブルを読むだけで済みます。
工夫3:Webhookは「検算と復旧」の係にする
Webhookは決済フローの主役ではありません。同期パスの結果を検算し、失敗していたら復旧する係です。正常系では「すでに整合している」ことを確認して何もせず終わります。
処理フロー
冪等性:イベントIDをユニーク保存し、状態を持つ
RevenueCatは同じイベントをまれに重複送信し、2xx以外を返すとリトライしてきます。そこでWebhook受信履歴用のテーブルを用意し、イベントID(リトライ時も同じ値)をユニークキーとして保存します。あわせて処理状態(PROCESSING / COMPLETED / FAILED)とペイロード全体を持たせます。
- COMPLETED または PROCESSING の重複 → 何もせず200
- FAILED の重複(前回失敗したリトライ)→ PROCESSING に戻して再処理
- 新規 → 保存して処理へ
ペイロード全体を保存しているのは、障害時に手動で再処理・デバッグするためです。
HTTPステータスは「リトライさせたいか」で決める
Webhookのレスポンスコードは「処理が成功したか」ではなく 「RevenueCatにもう一度送ってほしいか」 で決めます。
ここを取り違えると「重複に400を返して無限リトライ」や「接続障害に200を返して取りこぼし」が起きます。ユニーク違反と接続エラーをエラー種別で確実に区別してください。
| ケース | ステータス | 意図 |
|---|---|---|
| 認証不一致 | 401 | 不正リクエスト。リトライ不要 |
| ペイロード不正(冪等キーが取れない) | 400 | リトライさせる |
| イベントIDが重複(処理済み・処理中) | 200 | 冪等。再処理しない |
| DB接続エラー・RevenueCat API失敗 | 400 | FAILEDにしてリトライさせる |
| 自社に取引も取引ID付きインテントもない | 400 | 同期パスがまだ走っていない可能性があるのでリトライさせる |
| 整合済み・復旧成功・返金処理成功 | 200 | 完了 |
復旧は同期パスと「同じ処理」を再利用する
自社DBに取引がない、つまり同期パスが途中で落ちたケースでは、取引ID付きの購入インテントを起点に同期パスとまったく同じデータ作成処理を呼びます。
復旧専用のロジックを別に書くと、正常系と復旧系で作られるデータがズレる事故が起きえます。処理は1本に寄せ、入力(アプリから来た値か、インテントに保存された値か)だけを変えています。同期パスと復旧が同時に走っても、前述のユニーク制約で片方は「作成済み」として冪等に成功します。
Webhookが同期パスより先に届いたら
ネットワーク状況によっては、アプリからの決済完了通知より先にWebhookが届くことがあります。この時点ではインテントに取引IDがまだないため復旧できません。
ここで200を返すと「Webhookによる検算」が機能しなくなります。そこで あえて400を返してリトライさせ、次回の到着時に検算し直す ようにしました。RevenueCatのリトライは間隔を空けて繰り返されるので、その間に同期パスが完了していれば「整合済み」で終わります。
工夫4:日次の取引データ突合(最後の砦)
なぜWebhookだけでは足りないのか
Webhookが届かない・取りこぼす・処理が失敗し続ける。この事態はゼロにできません。Webhookは「イベント駆動の補正」であって網羅性は保証されないため、日次で「ストア側の全取引」と「自社DB」を突き合わせる最後の砦を用意しました。
仕組み
- RevenueCatのScheduled Data Exportsを有効化し、S3バケットへ日次でCSVを出力させる
- 日次バッチが前日分のファイルをすべて読み込み、取引ごとに自社DBのエンタイトルメントと突合する
- 不整合が1件でもあればエラーログ(取引ID・商品識別子・不整合理由)を出す。ログには事前に作成しておいた対応手順書へのリンクを含める
自動修復しない理由
同期パスとWebhookが正しく動いていれば、データの整合性は取れているはずです。それでも不整合が起きているなら、実装時に気づけなかった根本的な問題があり、自動処理では安全に復旧できない状態だと考えられます。
そのため「なぜ起きたか」「どうすれば直るか」を人が確認したうえで対応するのが最良だと判断しました。
ハマったポイント
設計どおりに作れたわけではなく、実装中に何度か考え直しました。同じところで悩む人のために残しておきます。
Webhookが同期パスより先に届く
当初は「自社に取引もインテントもない=想定外」としてエラーログを出し200を返す設計でした。しかし実機で試すと、アプリからの決済完了通知より先にWebhookが届くことがあるとわかりました。200を返すとその後の検算が行われないため、400でリトライさせる設計に変えました。
RevenueCatのイベント種別を全部処理しようとした
顧客統合(TRANSFER)やダッシュボードからのテストイベントを通常イベントと同じ経路に流すと、取引IDがないため後段でエラーになります。自社の権限管理に影響しないイベントは受信時点で200を返して終わる、と割り切りました。
おわりに
アプリ内課金の決済ロジックは「正常系を作る」よりも「異常系でもユーザーが購入した商品を閲覧できる状態にしておくこと」に工数の大半がかかりました。振り返ると効いたのは次の3点です。
- 決済「前」に意図を記録し、取引IDを最初に単独でコミットして復旧の起点を必ず残す
- Webhookを主役にせず、同期パスで即時反映しつつWebhookと日次突合で多層に検算する
- サーバー落ちなど自動で救えないケースをゼロにするのではなく、限りなくゼロに近づけたうえで必ず検知される状態を作る
実装してみて感じたのは、RevenueCatは「決済の面倒を全部引き受けてくれるサービス」ではないということです。
複雑な商品構造を持つサービスにとっては「ストアとの通信とイベントを整えてくれるサービス」と捉えるのが適切だと思いました。
既存のデータ構造にどう合わせるか、壊れないロジックをどう作るかを考えるにはかなりの工数を要しました。しかしそこさえ決めてしまえば、RevenueCatのWebhookやAPI、データエクスポートは非常に頼りになりました。
「権限をRevenueCatに任せられない」という我々の事情は少数派かもしれません。しかし既存の販売基盤にIAPを後付けする場面では同じ壁にぶつかる人も多いはずです。少しでも参考になれば幸いです。