アプリ内課金とレシート検証
課金の売上を守る最後の砦がサーバー側レシート検証。StoreKitとGoogle Play Billingの仕組みを原理から押さえれば、不正購入と失効の取りこぼしを防げる。
- アプリ内課金はアプリが直接決済せず、必ずストア(App Store/Google Play)を経由する。端末が受け取るのは決済結果ではなく署名付きレシートで、真偽の判定はサーバー側で行う。
- 商品は消耗型・非消耗型・サブスクリプションに分かれ、後者は自動更新・猶予期間・払い戻しといった状態遷移をサーバーがサブスクリプションのライフサイクルとして追跡する必要がある。
- レシートはクライアントを信じず、サーバーでストア署名を検証する。更新や失効はApp Store Server Notificationsなどのサーバー間通知で受け取る。
なぜアプリは決済結果を信用できないのか
アプリ内課金(In-App Purchase)の出発点は、「アプリ自身は決済を処理しない」という制約です。クレジットカード情報を扱うのはApp StoreとGoogle Playであり、アプリが受け取るのは「この購入が成立した」という署名付きの証跡(レシート/購入トークン)だけです。ここで重要なのは、この証跡を端末上のコードが評価して権限を付与してはいけない、という点です。端末はユーザーの支配下にあり、ジェイルブレイクや改造クライアント、中間者的なプロキシによって「購入成功」という応答を偽造できます。したがって購入の真偽を確定できるのは、ストアのサーバーに問い合わせるか、ストアが署名した証跡をサーバー側で検証したときだけです。この非対称性が、アプリ内課金設計のすべての土台になります。
StoreKitやBilling Libraryのコールバックが「購入成功」を返しても、それだけで有料機能を恒久的に解放してはいけません。端末側の応答は偽造可能です。付与の最終判断は、必ずサーバーがストアの署名を検証した結果に基づかせます。オフライン時に一時的にローカルのキャッシュで機能を出すのは構いませんが、権限の正本(source of truth)はサーバー側に置きます。
商品タイプと状態モデル
課金商品は3種類に大別され、それぞれ「所有」の意味と状態管理の重さが異なります。
横にスクロール
| 商品タイプ | 意味 | 状態管理の要点 |
|---|---|---|
| 消耗型(Consumable) | 使い切りのアイテム。ゲーム内通貨、回数券など | 同じ商品を何度でも購入可。付与後は必ずconsume(消費確定)し、二重付与を冪等キーで防ぐ |
| 非消耗型(Non-consumable) | 一度買えば永続する機能。広告除去、追加コンテンツ | 所有は一度きり。復元(restore)で別端末にも権利を再付与できる必要がある |
| 自動更新サブスク(Auto-renewable) | 期間課金。定額サービス、プレミアム会員 | 更新・失効・猶予・払い戻しを継続追跡。有効期限をサーバーが保持し続ける |
消耗型で最も事故が起きやすいのは二重付与です。付与処理と「消費確定(iOSのfinishTransaction/AndroidのconsumeAsync)」の間でアプリが落ちると、再起動時に同じトランザクションが再送されます。これは異常ではなく、ストアが「消費確定されるまで購入を保持し続ける」仕様の帰結です。したがって付与はトランザクションIDを冪等キーにして「未処理なら付与、処理済みなら黙ってスキップ」と設計し、付与のコミット後に消費確定する順序を守ります。非消耗型は所有が一度きりである一方、機種変更や再インストールで権利を取り戻す復元フローが必須になります。
サブスクリプションのライフサイクル
自動更新サブスクリプションは、単なる「有効/無効」の二値ではなく、複数の状態を遷移する存在として扱う必要があります。ユーザーが操作しなくても、時間の経過と課金結果に応じてサーバー側の状態が勝手に変わっていくのが本質です。
[購入] → active(有効)
├─ 期限到来 & 課金成功 → active(自動更新)
├─ 期限到来 & 課金失敗 → billing_retry / grace_period(猶予)
│ ├─ 猶予中に回収成功 → active
│ └─ 猶予切れ → expired(失効)
├─ ユーザー解約 → active のまま期限まで → expired
└─ 払い戻し(refund) → revoked(即時失効)
ここで見落としやすいのが猶予期間(grace period)と課金リトライ(billing retry)です。カードの一時的な残高不足などで更新に失敗しても、ストアは即座に失効させず一定期間リトライします。この間、ユーザーにサービスを継続提供するか止めるかはアプリ側のポリシー判断であり、状態を正しく区別できていないと「支払っているのに使えない」あるいは「失効したのに使えたまま」という不整合が生じます。有効期限(expiry date)はクライアントが計算するのではなく、ストアが返す値をサーバーが権威データとして保持します。
「あと何日で切れるか」を端末のローカル時刻で計算すると、時刻改ざんで無期限化できてしまいます。有効期限はストアのレシート/通知に含まれる値をサーバーが保存し、機能提供の可否はサーバーが判定した現在の購読状態に基づかせます。端末側の表示はあくまでキャッシュとして扱います。
サーバー側レシート検証:2つのモデル
検証方式はプラットフォームで設計思想が分かれます。要点は「端末が申告するデータではなく、ストアが署名・保証するデータを、サーバーが独立に確かめる」ことです。
| 観点 | iOS(StoreKit / App Store Server API) | Android(Google Play Billing) |
|---|---|---|
| 端末が受け取るもの | 署名付きトランザクション(StoreKit 2はJWSでその場で検証可能) | 購入トークン(不透明なID。それ自体は状態を含まない) |
| サーバーの検証手段 | App Store Server APIにトランザクションIDで問い合わせ、または署名(JWS/x5c鎖)を検証 | Google Play Developer APIに購入トークンを渡し、購入状態を取得 |
| 更新・失効の受け取り | App Store Server Notifications V2(サーバー間Webhook) | Real-time Developer Notifications(Pub/Sub経由のWebhook) |
iOSは歴史的に、端末が受け取ったレシートblobをAppleの検証エンドポイントに丸ごと送る方式でしたが、StoreKit 2以降は各トランザクションがJWS(JSON Web Signature)として署名され、Appleの証明書チェーン(x5cヘッダの証明書鎖)をサーバーが検証すれば、Appleへ問い合わせずとも真正性を確認できます。加えてトランザクションIDをキーにApp Store Server APIへ問い合わせれば、失効や払い戻しを含む最新の権威状態を取得できます。Androidは購入トークン自体が不透明で、それ単体では何も分かりません。サーバーがGoogle Play Developer APIにトークンを渡して初めて、購入が有効か・acknowledgeされたか・サブスクの期限がいつかが返ります。
Google Play Billingでは、購入後の一定時間内(サブスクや非消耗型は購入の承認、消耗型はconsume)にacknowledge(承認)を行わないと、Googleは購入をユーザーに自動返金し、権利が取り消されます。付与処理をサーバー検証の後に確実に実行し、その成功をもってacknowledge/consumeする順序を組まないと、正規の購入が消えて売上とサポート問い合わせの両方を失います。
払い戻し通知と不正対策
購入時点の検証だけでは不十分です。購入は成立しても、その後に払い戻し(refund)やチャージバック、家族共有の解除、サブスクの解約が起こり、権利が遡って消えることがあるからです。これらのイベントはユーザーがアプリを開かないまま発生するため、アプリからのポーリングでは取りこぼします。そこで両プラットフォームは、状態変化をサーバーへ能動的に push するサーバー間通知を提供します。iOSのApp Store Server Notifications V2、AndroidのReal-time Developer Notifications(Cloud Pub/Sub経由)がそれで、更新成功・失効・払い戻し・返金・値上げ同意などの種別を含むイベントをWebhookで受け取り、サーバーの購読状態を更新するのが正しい運用です。
不正対策として実務で効くのは次の軸です。
- サーバー間検証を正本にする。前述のとおり、端末の申告ではなくストアAPI/署名検証の結果で権限を付与・剥奪する。
- 冪等性で二重付与を防ぐ。トランザクションIDや購入トークンを一意キーにして、同一購入の再送を安全に無視する。
- 通知で失効を即時反映する。払い戻し・チャージバック通知を受けたら該当ユーザーの権利を revoke し、共有アカウントでの継続利用を断つ。
- 異常検知を併用する。同一レシートが多数アカウントで使い回される、返金率が突出して高いといったパターンを監視し、リセラー詐欺やレシート共有を検出する。
「クライアントの購入成功だけで機能解放してよいか」には、偽造可能なので不可、サーバーがストアの署名/APIで検証した結果に基づく、と答えます。消耗型は冪等な付与と消費確定の順序、非消耗型は復元フロー、サブスクは有効期限・猶予期間・払い戻しを含むライフサイクル追跡が論点です。失効や返金は購入時検証では捕捉できず、App Store Server Notifications/Real-time Developer Notificationsのサーバー間通知で受けるのが定石、という点も定番です。
まとめ
アプリ内課金は、アプリが決済結果を信用できないという一点から設計が決まります。端末が受け取るのは署名付きの証跡に過ぎず、権限付与の正本はサーバーがストアの署名やAPIで検証した状態に置きます。消耗型は冪等な付与と消費確定、非消耗型は復元、サブスクリプションは自動更新・猶予期間・払い戻しを含むライフサイクルの追跡という、タイプごとの状態モデルを正しく持つことが要です。そして購入時の検証だけで終わらせず、App Store Server NotificationsやReal-time Developer Notificationsによるサーバー間通知で失効・返金を即時に反映することが、売上の取りこぼしと不正の両方を防ぐ近道になります。課金権限を安全に扱う土台は、/mobile-development/mobile-app-signing-provisioning/ の署名・配布の信頼モデルや、/mobile-development/mobile-app-sandbox-permissions/ の権限・セキュリティの原則と地続きです。オフライン時の一時的な機能提供とサーバー正本の整合は /mobile-development/mobile-offline-first-sync/ の同期設計、失効通知の受信は /mobile-development/mobile-push-notification-architecture/ と併せて理解すると全体像がつながります。
モバイル開発の記事ガイド
アプリ内課金とレシート検証を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
モバイル
比較で見る軸
難易度: advanced / カテゴリ: モバイル開発 / タグ数: 6
導入後に効く点
商品は消耗型・非消耗型・サブスクリプションに分かれ、後者は自動更新・猶予期間・払い戻しといった状態遷移をサーバーがサブスクリプションのライフサイクルとして追跡する必要がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- モバイル開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「モバイル / アプリ内課金」に近いか確認する。
- 強みである「アプリ内課金はアプリが直接決済せず、必ずストア(App Store/Google Play)を経由する。端末が受け取るのは決済結果ではなく署名付きレシートで、真偽の判定はサーバー側で行う。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。