ドキュメント
Part 1. 本編
モバイルアプリのアプリ内課金 実装・運用設計ドキュメント の「Part 1. 本編」をまとめたページです。
Part 1. 本編
1. 最初に押さえること
アプリ内課金を初めて実装するときは、細かい API 名より先に、次の 4 点を正しく理解しておくことが重要です。
1-1. ストアが真実の源泉である
Apple と Google の課金における真実の源泉は、ストア側の状態です。
自社 DB は、その状態を自社サービスで使いやすい形にした投影結果として扱います。
したがって、クライアントから届いた transactionId や purchaseToken をそのまま信用してはいけません。
必ずバックエンドからストア API または署名済みデータを使って再確認します。
1-2. 「決済がある」ことと「使わせてよい」ことは別である
課金の世界では、次のような状態が存在します。
- 購入は存在するが、まだ支払いが完了していない
- 決済失敗中だが、猶予期間中なので使わせる
- 自動更新はオフだが、期限までは使わせる
- 返金済みなので即時停止する
そのため、契約状態と利用可否は分けて持ちます。
Subscription: ストア上の契約状態Entitlement: 自社サービスの利用可否
1-3. 購入直後のクライアント通知だけに依存してはいけない
購入直後は、クライアントからサーバへ購入情報が送られてくるのが理想です。
しかし現実には、次のようなことが起こります。
- 購入後にアプリがクラッシュする
- 通信が切れてサーバ通知に失敗する
- Webhook が先に届く
- サーバ更新後にクライアントが結果を受け取れない
そのため、クライアント申告・サーバ通知・定期ジョブの 3 本柱で設計します。
1-4. 初回リリースでは「全部盛り」にしない
初学者が最も迷いやすいのは、価格変更や高度なオファーまで最初から実装しようとすることです。
初回リリースでは、まず次の 5 点だけを成立させます。
- 新規購入を正しく反映できる
- 更新・解約・返金を追従できる
- Google の acknowledgement を漏らさない
- 購入復元で誤付与しない
- テスト環境で再現できる
この章の一次情報
- App Store Server API
- Validating receipts with the App Store ※
verifyReceiptの deprecated の経緯を確認したいときの参考 - Google Play’s billing system
- Subscription lifecycle
2. このドキュメントのスコープ
本ドキュメントは、自動更新サブスクリプションを初めて実装する開発者向けに、最初のリリースで必要な設計を整理したものです。
2-1. 本ドキュメントが主に扱うもの
- Apple App Store / Google Play の自動更新サブスクリプション
- バックエンドでの検証
- Webhook / RTDN を使った状態反映
- 購入復元
- Google acknowledgement
- 決済失敗・猶予期間・返金
- サンドボックス / ライセンステスターでのテスト
- NestJS / Prisma で実装しやすいデータモデル
2-2. 本ドキュメントで対象外とするもの
次の項目は意図的に深掘りしません。
- 消耗型課金の在庫管理
- ファミリー共有の個別 UX 最適化
- 外部決済 / Web 決済とのポリシー差分の詳細
- 収益会計・税務処理の詳細
- 高度なプロモーションオファー戦略
- Play の prepaid plan を前提にした詳細実装(本ドキュメントでは参考情報にとどめる)
2-3. 設計上の前提
- 自社アカウントとストア購入をできる限り購入時点で結び付ける
- 自社 DB はスナップショットだけでなく履歴も保存する
- 処理は冪等である
- 失敗時に再実行できる設計にする
- 初回リリースでの基本経路:
- Apple: App Store Server API + App Store Server Notifications V2
- Google: purchases.subscriptionsv2.get + RTDN
3. 実装前の準備と要件整理
実装に入る前に、ストア側・開発環境側の準備と、課金機能として最低限満たすべき要件を先に整理します。
ここが曖昧だと、コード実装に入ってからコンソール設定・識別子設計・責務分担の不足で手戻りが発生します。
3-1. Apple 側の準備
| 項目 | 必須度 | 目的 |
|---|---|---|
| App Store Connect でアプリ作成 | MUST | IAP 設定の前提 |
| 自動更新サブスク作成 | MUST | 販売する商品を定義する |
| 価格・ローカライズ・利用可能地域の設定 | MUST | 商品取得・審査・販売の前提 |
| introductory offer / free trial の設定 | MUST | 初回契約時の無料期間をストア側で提供する |
| Billing Grace Period の設定 | MUST | 決済失敗時に猶予期間中の利用継続を可能にする |
| App Store Server Notifications URL 設定 | MUST | 更新・返金・解約・回復・猶予終了を自動反映する |
| App Store Server API 用の Issuer ID / Key ID / private key / bundleId の整理 | MUST | JWT を作りサーバから API を呼ぶ |
appAppleId の整理 | SHOULD | App Store signed data 検証で使うことがある(特に production 側) |
| Sandbox テスターの準備 | MUST | 実機テスト用 |
| TestFlight 配布の準備 | SHOULD | 実運用に近い QA |
Apple で初学者が特に迷いやすい点
verifyReceiptは現行の主経路ではない
Apple の新規実装では App Store Server API や Apple 署名済みの transaction / subscription 情報 を中心に扱います。背景は 9-2.verifyReceiptを主経路にしない理由 でまとめます。- App Store Server API 用の情報と、signed data 検証用の情報は少し役割が違う
API 呼び出しで中心になるのはIssuer ID / Key ID / private key / bundleIdです。
一方、appAppleIdは signed data の検証で使うことがある情報で、同じ「資格情報」でも用途が異なります。 appAccountTokenは UUID である
自社ユーザーとの紐付け用に、購入時に UUID を渡す仕組みです。- 無料期間はバックエンドが自動付与するのではなく、App Store Connect の introductory offer で設定する
初回契約時の free trial はストア機能です。1 人が redeem できる introductory offer は subscription group ごとに 1 回です。購入 UI では StoreKit の product / offer 情報を表示し、バックエンドはその結果を検証して反映します。詳細は 11-4. 無料トライアルと初回無料期間の考え方 を参照してください。 - Billing Grace Period は App Store Connect で app 単位に有効化する
App Store Connect では 3 / 16 / 28 日を選べます。対象は All Renewals または Only Paid to Paid Renewals、環境は Sandbox のみ / Production と Sandbox から選べます。
ただし、実際に適用される猶予期間は設定値とは異なる場合があります。weekly subscription では 3 日または 6 日、monthly 以上では 3 / 16 / 28 日 です。weekly subscription で 16 日または 28 日を選んでも、実効上の猶予は 6 日になります。 - 月額表示は StoreKit から取得する
Apple 側の表示価格はProduct.displayPriceを使います。月額プランならその価格をそのまま表示し、年額プランの「月あたり」は UI 側の換算値として扱います。詳細は 11-6. 表示価格はストア SDK から取得する を参照してください。 - TestFlight の IAP は sandbox で動く
本番課金にはなりません。
3-2. Google 側の準備
| 項目 | 必須度 | 目的 |
|---|---|---|
| Play Console でアプリ作成 | MUST | 課金設定の前提 |
| Google Play Billing 有効化 | MUST | 商品設定の前提 |
| サブスク商品・base plan の作成 | MUST | 販売商品を定義する |
| subscription offer / free trial の設定 | MUST | 初回契約時の無料期間や導入価格を提供する |
| grace period / account hold 設定 | MUST | 決済失敗時の利用継続期間と停止移行を制御する |
| Play Console に対象アプリが存在し、パッケージ名が一致していること | MUST | テスト購入の前提 |
| internal / closed などのテストトラック公開 | SHOULD | QA や配布しやすい形で検証する |
| ライセンステスター設定 | MUST | テスト購入を無料で行う |
| Google Play Developer API 利用設定 | MUST | サーバ検証と acknowledgement |
| サービスアカウント / 権限設定 | MUST | Developer API を安全に呼ぶ |
| Pub/Sub topic + RTDN 設定 | MUST | 更新・返金・決済失敗・回復・猶予終了を自動反映する |
| Pub/Sub subscription 方式の選定(push / pull) | MUST | RTDN の受信方式を決める |
| Push subscription 認証設定 または pull subscriber 実装準備 | MUST | 受信経路を保護し、継続的に処理できるようにする |
Google で初学者が特に迷いやすい点
-
トラック公開は便利だが、常に必須ではない
ライセンステスターは、Play Console に対象アプリが存在し、パッケージ名が一致していれば、debug build の sideload でも課金テストできます。
ただし、QA や実運用に近い検証では internal / closed track を使うほうが分かりやすいです。 -
RTDN だけでは状態は分からない
RTDN は「状態が変わった」という通知であって、完全な購買状態そのものではありません。
受信後に必ずpurchases.subscriptionsv2.getを呼びます。 -
RTDN の受信方式は push / pull のどちらでもよいが、本書は push を前提にする
Google Play の RTDN は Pub/Sub topic に publish され、その先の subscription は push / pull のどちらでも構成できます。
ただし、RTDN は大量メッセージ最適化が主目的ではないため、Google は迷う場合は push を推奨しています。
本書では、NestJS の Web API サーバで受けやすい push subscription + HTTPS endpoint を前提に説明します。
pull を採る場合は、/webhooks/google/rtdnの代わりに 常駐 worker / StreamingPull subscriber で同じ責務を実装します。 -
acknowledgement が必要なのは、new purchase token を伴う purchase である
Google では、初回購入だけでなく、plan change や re-signup のように新しい purchase token が発行される purchase も 3 日以内に acknowledge する必要があります。
一方で、renewal は acknowledge 不要です。 -
purchases.subscriptions.getは新規実装の主経路にしない
Google は新規統合ではpurchases.subscriptionsv2.getを使うよう案内しています。 -
無料期間は Play Console の base plan / offer で設定する
auto-renewing subscription の offer では free trial や introductory price を設定できます。offer の eligibility は Google Play が管理するか、アプリ実行時に判定できます。購入時はProductDetailsから選んだofferTokenを使います。詳細は 11-4. 無料トライアルと初回無料期間の考え方、trial 利用歴の考え方は 11-5. トライアル利用歴あり / なしの判定 を参照してください。 -
grace period / account hold は Play Console で設定する
現行の一次情報では、auto renewing base plan の grace period は default enabled と案内されています。設定可能な猶予日数は Play Console で調整または無効化できますが、0 daysにしても最低 24 時間の silent grace period は別途存在します。これは、Play Console 上で設定する grace period とは別概念です。また、account hold も既定で有効で、2025-12-01 以降の既定値は自動計算です。初期計算は
60 days - grace period durationで、既存の既定 30 日設定も自動計算へ切り替わります。したがって、固定日数を前提にせず、実際の状態はpurchases.subscriptionsv2.getの結果で判定してください。本プロジェクトでは、Google Play の grace period は 3 日で設定する前提とします。ただし、silent grace period はこれとは別に存在するため、実装では
3 days固定の前提だけで状態判定せず、常に最新のストア状態を再照会します。 -
月額表示は Billing Library の
ProductDetailsから取得する
queryProductDetailsAsync()で取得したProductDetailsを使って表示価格を組み立てます。年額プランの「月あたり」は UI 側の換算値として扱います。詳細は 11-6. 表示価格はストア SDK から取得する を参照してください。 -
Android の復元は専用 OS API というより既存購入の再同期で考える
queryPurchasesAsync()を接続成功時やonResume()で呼び、取得したpurchaseTokenをバックエンドへ送って再照合します。
ただし、queryPurchasesAsync()だけで復元や状態把握を完結させてはいけません。詳細な考え方は 12-9. クライアント側の復元導線 で整理します。
3-3. 課金機能に関する要件整理
レビュー観点になっている最低要件を、本書ではどのように解釈し、初回リリースで何を MUST / SHOULD として扱うかをまとめます。
ここでの MUST は、最低要件そのものへの直接対応と、それを実運用で安定して成立させるための土台を含みます。
一方、SHOULD は、初回リリースで未実装でも直ちに成立しなくなるわけではないものの、運用、CS 対応、障害復旧、品質保証の観点で早めに必要になる項目です。
また、旧来の「共通チェック」で扱っていた項目のうち、実質的に要件そのものになっているものは本節へ統合します。
とくに、ストア商品と内部 Plan の対応付け、クライアントからサーバへ渡す購入証跡の受け渡し仕様 は、単なる確認事項ではなく、初回設計時点で固めるべき必須要件として扱います。
| 優先度 | 区分 | 要件 | 本書での扱い / 具体的に必要なこと | 主な参照節 |
|---|---|---|---|---|
| MUST | 商品マッピング | ストア商品 ID と内部 Plan の対応を明示できること | Apple の productId、Google の productId / basePlanId / offerId と、自社の Plan を対応付ける。ストア ID を業務ロジックへ直結させない。 | 4 / 6 / 16 |
| MUST | 購入証跡連携 | クライアントからサーバへ購入証跡を送れること | Apple は transactionId、Google は purchaseToken をサーバへ送る。入力仕様はプラットフォームごとに分け、バックエンド再照会の入口を明確にする。 | 4 / 8-3 / 20 |
| MUST | 商品・価格 | ストアから価格を取得できること | 表示価格の真実の源泉はストア SDK。月額プランはその価格をそのまま使い、年額などの 月あたり は UI 側の換算値として扱う。 | 3-1 / 3-2 / 11-6 |
| MUST | 初回特典 | 初回契約時に無料期間を自動付与できること | バックエンドが独自に無料権利を作るのではなく、Apple の introductory offer / Google の free trial・offer を ストア設定 で有効化し、その結果を通常購入として検証・反映する。 | 3-1 / 3-2 / 11-4 |
| MUST | 利用履歴 | トライアル利用歴あり / なしを判定できること | 購入 UI の eligibility はストアを真実の源泉としつつ、自社 DB に hasStartedTrialAtLeastOnce などの永続的な履歴根拠を持つ。 | 11-5 / 16-10 |
| MUST | 購入復元 | 購入を復元できること | Apple は AppStore.sync()、Google は queryPurchasesAsync() を入口にしつつ、最終判断は必ずバックエンドの再照会で行う。 | 12 / 23 |
| MUST | ストア設定 | 猶予期間を設定・有効化できること | Apple は App Store Connect、Google は Play Console で設定する。Google は別概念として silent grace period もあるため、3日固定 を実装へ埋め込まない。 | 3-1 / 3-2 / 10-7 |
| MUST | 契約状態 | 正常 / 猶予期間中 / 猶予期間終了後を判別できること | Apple は IN_GRACE_PERIOD と BILLING_RETRY を区別し、Google は IN_GRACE_PERIOD と ON_HOLD を区別する。 | 11-1 / 11-2 / 24 |
| MUST | 決済異常検知 | 決済エラー発生を検知できること | 通知は再照会のきっかけとして使い、最終判断は常にストア再照会結果で行う。 | 8-5 / 8-6 / 24 |
| MUST | 回復検知 | 支払い情報の更新や再決済による回復を検知できること | 実装上は 支払い手段の編集事実そのもの ではなく、支払い問題からの回復検知 として扱う。 | 11-7 / 24 |
| MUST | 猶予期間終了検知 | 猶予期間の終わりを検知できること | Apple は GRACE_PERIOD_EXPIRED 後の billing retry 移行を、Google は silent grace period 後の最終状態を再照会で確定する。 | 10-7 / 11-2 / 24 |
| MUST | 返金・取消 | 返金、取消、チャージバックを検知できること | 購入成功後に無効化されるケースを拾えないと、権限が残り続ける。 | 11-3 / 24 |
| MUST | 権限反映 | 返金、取消、失効を利用権限へ反映できること | Subscription の状態更新だけでなく、Entitlement も確実に停止する。 | 11-1 / 11-3 / 24 |
| MUST | 状態確定 | ストア再照会を正本として最終状態を確定できること | クライアント通知や Webhook / RTDN だけで確定しない。ストアが真実の源泉である。 | 1-1 / 5-2 / 20 |
| MUST | 冪等性 | 同じ通知や購入イベントを複数回受けても壊れないこと | 再送や二重送信を前提に、同一イベントの重複処理に耐える。 | 5-2 / 21 |
| MUST | 順不同耐性 | 通知やクライアント送信の到着順が前後しても正しく処理できること | クライアント申告、通知、定期ジョブがどの順で来ても最終状態が一致する設計にする。 | 1-3 / 5-2 / 25 |
| MUST | 紐付け | ストア購入と自社アカウントを安定して紐付けできること | Apple は appAccountToken、Google は setObfuscatedAccountId 系を使う。 | 7 / 16-10 |
| MUST | 再紐付け | 再インストール、機種変更、再購読後でも既存ユーザーへ再紐付けできること | 復元時の誤付与防止を含む。自動紐付けできない場合は手動確認へ逃がす。 | 12 / 23 / 28 |
| MUST | 通知処理 | Apple 通知 / Google RTDN を受信して状態更新のきっかけにできること | 更新、返金、決済失敗、回復、猶予終了を自動追従するために必要。 | 9 / 10 / 21 / 24 |
| MUST | Google 固有 | acknowledgement を漏らさないこと | Google では new purchase token を伴う purchase に対して acknowledgement が必要で、renewal は不要。 | 3-2 / 10-6 / 22 |
| MUST | 再処理 | 一時失敗した通知や状態反映処理を再実行できること | 一過性障害で取りこぼさないための再試行経路が必要。 | 25 / 26 |
| MUST | 欠落補正 | 通知欠落時でも定期再照合や手動再照合で補正できること | Webhook / RTDN の欠落を前提に補正ジョブを持つ。 | 25 / 26 / 28 |
| MUST | 監査保存 | 通知 payload、再照会結果、投影結果を追跡できる形で保存できること | 問い合わせ、障害調査、誤付与調査のために必要。 | 14 / 29 |
| MUST | 履歴管理 | 最新状態だけでなく履歴を保存できること | BillingTransaction や受信イベント履歴を持ち、後追い検証できるようにする。 | 6 / 16 / 29 |
| MUST | 環境分離 | Sandbox / Test / Production の資格情報、通知経路、データを分離できること | 誤課金、誤検証、テストデータ混入を防ぐ。 | 3-1 / 3-2 / 13 |
| MUST | 利用可否提供 | クライアントがサーバ確定の利用可否を取得できること | クライアントがストア状態を直接解釈せず、サーバの read model を参照する。 | 5-4 / 8 / 19 |
| MUST | テスト | テスト環境で end to end を通せること | 購入、更新、復元、決済失敗、猶予、回復、返金まで主要経路を検証できる状態にする。 | 3 / 13 / 27 |
| SHOULD | UI / UX | 「購入済みだが未反映」の文言や導線を用意できること | 反映遅延時の混乱を減らす。 | 12 / 13 |
| SHOULD | 返金申請導線 | ストア所定の導線で返金申請を開始できること | 優先度は返金検知・権限反映より下である。 | 12 / 28 |
| SHOULD | 競合防止 | 別アカウントへの誤紐付けや二重紐付けを防止できること | とくに復元時・再購読時に重要である。 | 7 / 12 / 23 |
| SHOULD | 管理・調査 | transactionId / originalTransactionId / purchaseToken などで調査できること | CS や運用が個別事象を追えるようにする。 | 14 / 28 / 29 |
| SHOULD | 手動復旧 | 管理画面や運用 API から再照合・再投影できること | 障害時の救済手段として有効である。 | 26 / 28 |
| SHOULD | 監視 | 通知停止、反映遅延、未処理の異常状態を検知できること | 運用開始後の事故検知を早める。 | 14 / 25 / 29 |
補足:
- 返金・取消検知、権限反映、状態確定、冪等性、順不同耐性 は、最低要件の各項目を実運用で成立させるための土台である。
- 商品マッピング と 購入証跡連携 は、初回実装では見落とされやすい一方で、後から崩すと全面的な設計修正になりやすいため、最初から必須要件として明示する。
- Google 固有の acknowledgement は「Google だけの細かい話」ではなく、初回リリースで落としてはいけない必須要件である。
- 監査保存、管理・調査、手動復旧、監視 は運用開始後に重要度が急上昇しやすいため、初回リリース時点でも設計余地を残しておくのが望ましい。
この章の一次情報
4. 初回リリースの最短実装順
初学者にとっては、設計順ではなく実装順で見るほうが理解しやすいです。
以下の順で進めることを推奨します。
Phase 0. テスト環境と商品を先に成立させる
実装に入る前に、実際に購入できるテスト環境を先に作ります。課金は API を書き始めてから商品設定を考えると、原因切り分けが難しくなります。
- App Store Connect / Play Console で対象商品を作成する
- Apple Sandbox テスター / TestFlight、Google ライセンステスターで購入できる状態にする
- パッケージ名 / bundle ID / product ID / basePlanId / offerId の対応を確認する
- クライアントで商品一覧取得と購入画面表示まで先に疎通する
Phase 1. 商品と課金主体の設計を決める
最初に決めるのは DB ではなく、何を、誰に、どう結び付けるかです。
- 自社
Planを定義する - Apple の
productIdと Google のproductId/basePlanId/offerIdをPlanに対応付ける - 購入前の段階で、現在ログイン中のユーザー(以下、現在ユーザー)に対応する
BillingAccountを作成する BillingAccount作成時にaccountTokenを生成する- アプリは購入開始前までに現在ユーザーの
accountTokenを取得する - 購入開始 API 呼出し時に、同じ
accountTokenをストア SDK の引数へ埋め込む- Apple:
appAccountToken - Google:
setObfuscatedAccountId/setObfuscatedProfileId
- Apple:
Phase 2. クライアントからサーバへ購入証跡を送れるようにする
まずは購入直後の最短反映を作ります。
- Apple: 購入完了後に
transactionIdを送る - Google: 購入完了後に
purchaseTokenを送る - ingest API では
accountTokenを必須入力にせず、認証済みの現在ユーザーを基準に処理する - サーバ側エンドポイント
POST /billing/apple/ingestPOST /billing/google/ingest
Phase 3. サーバからストアを再照会する
ここが課金実装の中核です。
- Apple:
Get Transaction Info- 必要に応じて
Get All Subscription Statuses
- Google:
purchases.subscriptionsv2.get
Phase 4. Subscription / Entitlement / 履歴を更新する
最初のリリースで最低限必要なのは次の 3 つです。
Subscription: 最新状態Entitlement: 利用可否BillingTransaction: 履歴
Phase 5. Google acknowledgement を入れる
Google はここを後回しにしてはいけません。
entitlement 付与後に実行する反映処理の一部として実装します。対象は、new purchase token を伴う purchase です。
Phase 6. クライアントが利用可否を取得できるようにする
クライアントは個々のストア状態を直接解釈せず、
サーバが決めた利用可否を取りに行くのが安全です。
この導線は、専用の GET /me/entitlement でも、既存の画面取得 API に entitlement を含める形でも構いません。
重要なのは、クライアントが参照する利用可否の read model をサーバ側で一貫して提供することです。
Phase 7. Webhook / RTDN を入れる
新規購入だけでなく、次の自動反映に必要です。
- 更新
- 解約
- 返金
- 決済失敗
- 回復
Phase 8. 購入復元を入れる
新規購入が安定してから復元へ進みます。
復元は「既存のストア契約を、どの内部ユーザーへ結び直すか」の問題であり、
新規購入より難しいです。
Phase 9. 定期ジョブと補正処理を入れる
最後に、安全装置として次を入れます。
- 未 ack 購入の再試行
- 通知未達の補正
- 期限到来前後の再照合
- 監視アラート
4-1. なぜこの順番なのか
この順序にしている理由は明確です。
- 先に新規購入を通す
- 次にクライアントが利用可否を取得できる導線を作る
- その後で通知追従に広げる
- 最後に復元と補正で事故率を下げる
つまり、最初に成功体験を作り、その後で堅牢化するためです。
5. 全体アーキテクチャ
5-1. 全体像
flowchart LR
A[Client App] -->|purchase result / restore result| B[Backend ingest API]
C[App Store Server Notifications] --> D[Webhook Receiver]
E[Google RTDN via Pub/Sub] --> D
B --> F[Store Verification]
D --> F
G[Periodic Reconcile Job] --> F
F --> H[Projection Logic]
H --> I[(Subscription)]
H --> J[(Entitlement)]
H --> K[(BillingTransaction)]
H --> L[(InboundWebhookEvent)]
M[Client App] -->|screen API or GET /me/entitlement| N[Client-facing Read API]
N --> J5-2. 処理の原則
どの入口から入ってきても、最終的には同じ投影ロジックに流します。
- クライアント申告
- Apple 通知
- Google RTDN
- 定期ジョブ
これにより、実装が次のようになります。
- 検証ロジックが一箇所にまとまる
- 通知と手動再実行で結果がぶれない
- テストしやすい
- 冪等化しやすい
5-3. バックエンドの責務
バックエンドの責務は次の 5 つです。
- 購入証跡の真正性を確認する
- ストア状態を自社 DB に投影する
- Google の acknowledgement を期限内に行う
- 利用可否を API として返す
- 再送・順序逆転・部分失敗を吸収する
5-4. クライアントの責務
クライアントの責務は限定します。
- 購入開始時に正しい識別子を埋め込む
- 購入後に証跡をすみやかにサーバへ送る
- アプリ再起動後に未送信の証跡を再送する
- 商品価格や offer 文言は StoreKit / Google Play Billing Library から取得して表示する
- Apple では Restore Purchases 導線、Google では既存購入の再同期導線を用意する
- 利用可否の最終判断をサーバに委ねる
6. 最小ドメインモデル
この章では、初回リリースで最低限必要な概念だけに絞って整理します。
ここで扱うのは 概念モデル であり、DB テーブルへの具体的な分解は 16 章で扱います。
6-1. Plan
自社の販売プランです。
課金周期は汎用的な期間表現ではなく、販売する商品期間そのものを表す billingPeriod で持ちます。
例:
PRO_MONTHLY+MONTHLYPRO_YEARLY+YEARLY
ストア固有の ID を直接業務ロジックに埋め込まず、
内部プランコードと対応付ける形にします。
6-2. StoreProductMapping
各ストアの商品と内部 Plan の対応です。
| 項目 | 例 |
|---|---|
| provider | APPLE / GOOGLE |
| internalPlanCode | PRO_MONTHLY |
| appleProductId | pro_monthly |
| googleProductId | pro.monthly |
| googleBasePlanId | monthly |
| googleOfferId | trial7d |
6-3. BillingAccount
課金の帰属先です。
BillingAccount は provider ごとの概念ではなく、Apple / Google をまたいで同一人物の課金を束ねる単位として扱います。多くのサービスでは User と 1:1 でも構いませんが、将来を考えると別概念にしておくほうが安全です。
また、BillingAccount は初回購入時に作るのではなく、購入前の段階で先に作成しておきます。accountToken もその時点で生成し、Apple の appAccountToken と Google の obfuscatedExternalAccountId 系に同じ値を共通利用できるようにしておくと、provider をまたいだ照合方針を揃えやすくなります。
6-4. Subscription
ストア上の契約状態です。
最低限、次の状態を内部で扱えるようにします。
なお、Google では purchase token が差し替わることがあるため、DB 設計では Subscription に加えて token 履歴を別テーブルまたは同等の形で保持することがあります。
これは新しい業務概念を増やすというより、Google 固有の系列管理を安全に永続化するための分解です。
PENDINGTRIALINGACTIVEIN_GRACE_PERIODBILLING_RETRYON_HOLDPAUSEDCANCELEDEXPIREDREVOKED
TRIALINGは内部状態です。無料トライアル期間中で entitlement は有効だが、通常課金開始前であることを区別したい場合に用います。不要ならACTIVEに畳んでも構いませんが、本ドキュメントでは後続章と整合させるためTRIALINGを明示的に扱います。
BILLING_RETRYは内部正規化状態です。主に Apple で、Billing Grace Period 終了後または Billing Grace Period を有効化していない場合の支払い再試行中で entitlement なしを表すために用います。App Store Server API では billing retry state(status = 3)やisInBillingRetryPeriodなどの事実を保持し、内部状態ではBILLING_RETRYとして扱います。なお、Billing Grace Period 中は別状態でありstatus = 4です。
ON_HOLDは内部正規化状態です。Google の account hold を表すために用います。Apple には同名の公式状態はありません。Apple の billing retry をON_HOLDと同一視すると Google の account hold と混同しやすいため、本書では区別します。
6-5. Entitlement
自社サービスの利用可否です。
最低限ほしい項目:
billingAccountIdplanIdまたはそれに相当する内部 plan 識別子isActivestartsAtendsAtreason
6-6. BillingTransaction
履歴です。
「最新状態だけ持つ」設計は避けます。
最低限保存したい情報:
- provider
- transaction identifier / purchase token
- original transaction / linked token
- event type
- purchasedAt / expiresAt / revokedAt
- raw payload
- environment
6-7. InboundWebhookEvent
受信イベントの監査ログです。
- provider
- providerEventId
- providerBusinessKey
- verified
- environment
- payload
- firstReceivedAt
- processedAt
- processResult
6-8. 推奨する考え方
初学者は、まず次だけ守れば十分です。
Subscriptionは契約の最新像Entitlementは使わせるかどうかBillingTransactionは履歴InboundWebhookEventは監査ログ
なお、物理 DB 設計では、Google 固有の token 履歴管理のために、これらの概念を複数テーブルへ分解することがあります。
その具体例が 16 章の GooglePurchaseToken です。
7. 識別子と紐付けの考え方
課金実装で最も事故が起きやすいのは、決済 API の呼び出しそのものではなく、
誰の購入かを誤ることです。
7-1. Apple で主に使う識別子
| 識別子 | 用途 | 備考 |
|---|---|---|
transactionId | 個々の取引を識別 | 復元・更新でも変わる |
originalTransactionId | 同一購読系列の識別 | 継続判定に重要 |
appAccountToken | 自社課金主体と紐付け | BillingAccount 作成時に生成し、購入開始時に埋め込む UUID |
notificationUUID | 通知の冪等化 | Webhook の重複排除に使う |
Apple の一次情報では、appAccountToken は購入結果の App Store transaction に紐付く UUIDです。
したがって、これは「購入後にサーバへ送る補助値」ではなく、BillingAccount 作成時に生成しておき、購入開始 API 呼出し時に StoreKit へ埋め込む値として理解するのが正確です。購入時に設定すると、後で transaction information 側にも同じ値が返ってきます。
7-2. Google で主に使う識別子
初回実装でまず押さえるべき識別子は、次の 6 つです。
| 識別子 | 用途 | 備考 |
|---|---|---|
purchaseToken | 現在の購買系列の識別 | サーバ検証の主キー |
linkedPurchaseToken | 旧 purchase との接続 | plan change や re-signup で旧契約を辿るときに使う |
setObfuscatedAccountId(...) | 自社課金主体紐付け | BillingAccount.accountToken を購入開始時に設定する |
setObfuscatedProfileId(...) | 自社プロフィール紐付け | 複数プロフィールがある場合にクライアントで設定する |
externalAccountIdentifiers.obfuscatedExternalAccountId | Developer API 側で返るアカウント識別子 | 購入開始時に埋めた BillingAccount.accountToken が返る |
messageId | Pub/Sub メッセージ ID | RTDN 受信の冪等化に使う |
注意: クライアント SDK の setter 名は
setObfuscatedAccountId/setObfuscatedProfileIdですが、
purchases.subscriptionsv2.getで返る JSON のフィールド名は
externalAccountIdentifiers.obfuscatedExternalAccountId/externalAccountIdentifiers.obfuscatedExternalProfileIdです。
BillingAccountは provider ごとの概念ではないため、同じBillingAccount.accountTokenを Apple のappAccountTokenと Google のobfuscatedExternalAccountIdに共通利用して構いません。 同一ユーザーが Apple / Google の両方で課金しても、BillingAccount自体は 1 つでよい、という整理です。
補足:
purchases.subscriptions.acknowledgeの request body には、externalAccountIds.obfuscatedAccountId/obfuscatedProfileIdを載せられます。
ただし、これは resubscription purchase でのみ設定できる補助経路です。通常の初回購入では、クライアントの BillingFlow でsetObfuscatedAccountId(...)/setObfuscatedProfileId(...)を設定する経路を主に使います。
補足: 初回実装では後回しでもよい識別子
externalAccountIdentifiers.obfuscatedExternalProfileId
プロフィール単位の紐付けが必要な場合に使います。outOfAppPurchaseContext.expiredExternalAccountIdentifiers
前回購読が期限切れになった後に Google Play subscriptions center から再購読した purchase にだけ現れる補助手掛かりです。
acknowledgement 後に削除されるため、初回実装では「見つかれば使う」程度で十分です。
7-3. 紐付けの優先順位
初回リリースでは、認証済みの現在ユーザーと、ストア再照会結果から得た識別子・契約系列の両方を見て判定する方針を明示してください。
つまり、識別子が一致したから即採用ではなく、現在ユーザーに紐づく BillingAccount と既存の契約系列に矛盾がないかまで見て、紐付け可否を決めます。
Apple
- 現在ユーザーの
BillingAccount.accountTokenと、ストア再照会で得たappAccountTokenが一致する - その
BillingAccountが、同じoriginalTransactionId系列をすでに保持している - 既知の
transactionId系列から同じBillingAccountへ安全に到達できる - どれにも当てはまらなければ自動紐付けを行わず、手動確認する
- 現在ユーザーの
BillingAccount.accountTokenと、ストア再照会で得たexternalAccountIdentifiers.obfuscatedExternalAccountIdが一致する linkedPurchaseTokenから辿った既存契約系列が、同じBillingAccountにぶら下がっているoutOfAppPurchaseContext.expiredExternalAccountIdentifiersを補助手掛かりとして使う- どれにも当てはまらなければ自動紐付けを行わず、手動確認する
初回実装では、まず
externalAccountIdentifiers.obfuscatedExternalAccountIdとlinkedPurchaseTokenを確実に扱えれば十分です。
outOfAppPurchaseContextは、前回購読がすでに期限切れになった後の再購読でだけ使う補助情報として考えると理解しやすいです。
補足: accountToken を使うタイミング
accountToken は、購入完了後に ingest API へ送るための値ではありません。
購入前に BillingAccount と一緒に作成し、購入開始時にストア SDK へ埋め込んでおくための値です。購入完了後の ingest では、主に transactionId / purchaseToken を送り、バックエンドがストア再照会結果の識別子と現在ユーザーの BillingAccount を照合します。
sequenceDiagram
autonumber
actor User as User
participant App as Client App
participant API as Backend API
participant DB as App DB
participant Store as App Store / Google Play
User->>App: Sign in / open purchase screen
App->>API: Prepare billing identity
API->>DB: Create User / BillingAccount if needed
DB-->>API: billingAccountId, accountToken
API-->>App: accountToken
App->>Store: Start purchase with accountToken embedded
Store-->>App: transactionId or purchaseToken
App->>API: POST /billing/*/ingest (authenticated request + transactionId or purchaseToken)
API->>DB: Resolve current user's BillingAccount
API->>Store: Re-query store state
Store-->>API: purchase state + account identifier
API->>API: Compare returned identifier and existing contract lineage
API->>DB: Upsert Subscription / Entitlement / BillingTransaction
API-->>App: projection result7-4. やってはいけないこと
- メールアドレスをそのまま
obfuscatedAccountIdに入れる appAccountTokenを未設定のまま運用開始する- 復元時に「見つかった購入を全部いまのログインユーザーに付ける」
- 1 台の端末だけを根拠にユーザー帰属を決める
この章の一次情報
- appAccountToken
- appAccountToken(_:)
- Transaction.appAccountToken
- setObfuscatedAccountId
- setObfuscatedProfileId
- purchases.subscriptionsv2.get
8. API 設計
8-1. 推奨エンドポイント一覧
API名の付け方
本ドキュメントでは、API 名の語感を次のように揃えます。
POST /billing/*/ingestのようなクライアント起点で内部状態へ反映する入口は、〜購入情報反映APIと呼ぶPOST /webhooks/*/*のようなストア通知を受ける入口は、〜通知受信APIと呼ぶGET /me/*のようなクライアント参照用 read model は、〜取得APIと呼ぶPOST /admin/*/reconcileのような管理系の再実行入口は、〜再照合APIと呼ぶ
この命名により、API 名だけで 誰が呼ぶのか と 何をするのか が分かりやすくなります。
まず前提として、MUST なのは endpoint 名ではなく責務です。
課金実装では、クライアントが現在の entitlement を取得できること自体は必須ですが、その実現方法は次のどちらでも構いません。
- 専用の
GET /me/entitlementを設ける - 既存の
XXX画面取得APIや BFF のレスポンスに entitlement を含める
そのため、以下の表では 専用 endpoint として追加が必要なもの と、責務として満たすべきもの を分けて考えます。
8-1-1. MUST
初回リリースでも、少なくとも次の責務に対応する入口は必要です。
本書で主に扱う Apple 通知は Webhook、Google 通知は push subscription 前提では、対応する endpoint は次のとおりです。
| API名 | 目的 | Method | Path | 認証 | 位置づけ |
|---|---|---|---|---|---|
| Apple購入情報反映API | Apple 購入証跡登録 | POST | /billing/apple/ingest | Bearer | 購入直後 / 復元時の入口 |
| Google購入情報反映API | Google 購入証跡登録 | POST | /billing/google/ingest | Bearer | 購入直後 / 復元時の入口 |
| Apple通知受信API | Apple の通知受信 | POST | /webhooks/apple/notifications | 署名検証 | 通知ドリブン補正 |
| Google通知受信API | Google RTDN の通知受信 | POST | /webhooks/google/rtdn | Pub/Sub JWT 検証 | push subscription を採る場合の通知ドリブン補正 |
加えて、クライアントが現在の利用可否を取得できる read model を提供すること は MUST です。
ただし、それは専用の GET /me/entitlement を新設してもよいですし、既存の画面取得 API に entitlement を含める形でも構いません。
なお、Google 通知を pull 方式 で受ける場合は、POST /webhooks/google/rtdn という HTTP endpoint 自体は不要です。必要なのは通知受信の責務であり、その受け皿は subscriber worker でも構いません。
8-1-2. SHOULD
初回リリースでも強く推奨するが、業務要件によっては後追い実装も許容できるものです。
| API名 | 目的 | Method | Path | 認証 | 位置づけ |
|---|---|---|---|---|---|
| 利用可否取得API | 現在の利用可否を専用 endpoint として取得する | GET | /me/entitlement | Bearer | entitlement の再利用性が高い場合の専用 read model |
| 課金状態再照合API | 課金状態の手動再照合 | POST | /admin/billing/reconcile | Admin | CS / 運用の救済、障害復旧、手動補正 |
8-1-3. 条件付きで追加を検討してよいもの
要件によっては、次の endpoint を追加すると運用しやすくなります。
| 重要度 | API名 | 目的 | Method | Path | 追加を検討する条件 |
|---|---|---|---|---|---|
| SHOULD | 契約詳細取得API | 契約詳細表示用 read model | GET | /me/billing/subscription | 設定画面などで、専用 entitlement endpoint より詳しい契約情報を見せたい |
| 補足 | - | 復元専用 endpoint は原則不要 | - | - | 本ドキュメントでは /billing/*/ingest に action=restore を渡して共通化する |
8-1-4. 購入証跡の検証(レシート検証)とは
「購入証跡の検証」 は、クライアントから受け取った transactionId や purchaseToken をもとに、バックエンドからストア API や署名済みデータへ再照会して購入証跡を確認することを指します。
「レシート検証」 という語を使う場合も、本ドキュメントでは Apple の旧来の verifyReceipt を主軸とする実装ではなく、この購入証跡の検証を指します。verifyReceipt を主経路にしない理由は 9-2 で整理します。
1-1 節で述べたとおり、クライアントから届いた証跡をそのまま信用せず、必ずバックエンドからストアへ再照会します。具体的な API は次のとおりです。
- Apple:
transactionIdを使って App Store Server API や Apple 署名済みデータを確認する - Google:
purchaseTokenを使ってpurchases.subscriptionsv2.getを確認する
/billing/apple/ingest と /billing/google/ingest は、その入口となる API です。
ingest API 自体は accountToken を必須入力にせず、認証済みの現在ユーザーを基準に、transactionId / purchaseToken を主入力として扱います。
8-2. ingest API を provider ごとに分ける理由
Apple と Google は、クライアントから持ち込まれる証跡も、サーバ側で再照会する方法も異なります。
- Apple は
transactionIdを中心に扱う - Google は
purchaseTokenを中心に扱う - 復元時の識別子や再照合手順も provider ごとに異なる
そのため、初学者にとっては 1 本の endpoint に統合せず provider ごとに分ける ほうが理解しやすく、実装の責務も明確です。
8-3. Apple購入情報反映API の設計方針
API 名: Apple購入情報反映API
Apple で発生した購入や復元の入口です。
クライアントが持ち込んだ transactionId を起点に、サーバ側で Apple へ再照会し、内部状態へ反映します。
この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。
- Apple 専用 endpoint として分ける
actionはpurchase/restoreを受けられる形にする- ingest 入力の主軸は
transactionIdとし、現在ユーザーは認証済みコンテキストから特定する - クライアント申告だけで entitlement を付与しない
- ストア再照会で得た
appAccountTokenと既存originalTransactionId系列の両方を見て紐付け可否を判定する - 新規購入と復元で、検証と projection の本体を共通化する
- 初回購入の最短反映を担いつつ、通知到着前でも反映できる入口にする
リクエスト例、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-5. POST /billing/apple/ingest の詳細 にまとめます。
8-4. Google購入情報反映API の設計方針
API 名: Google購入情報反映API
Google Play の購入や復元の入口です。
クライアントが持ち込んだ purchaseToken を起点に、サーバ側で purchases.subscriptionsv2.get を呼び、内部状態へ反映します。現在ユーザーは認証済みコンテキストから特定し、obfuscatedExternalAccountId は購入開始時に埋め込まれてストア再照会結果から返ってくる値として扱います。
この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。
- Google 専用 endpoint として分ける
actionはpurchase/restoreを受けられる形にする- ingest 入力の主軸は
purchaseTokenとし、現在ユーザーは認証済みコンテキストから特定する - 公開 API の最小必須は
purchaseTokenを中心に考える externalAccountIdentifiers.obfuscatedExternalAccountIdとlinkedPurchaseToken系列の両方を見て紐付け可否を判定する- entitlement 判定はストア再照会結果で確定する
- new purchase token を伴う purchase では acknowledgement 要否判定を組み込む
リクエスト例、必須項目の考え方、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-6. POST /billing/google/ingest の詳細 にまとめます。
8-5. Apple通知受信API の設計方針
API 名: Apple通知受信API
Apple Server Notifications V2 の受信口です。
これは初回購入の主入口ではなく、状態変化の追従、通知ドリブン補正、未反映イベントの早期補正 のために置きます。
この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。
- 通知受信 API は ingest API と責務を分ける
- 通知本文をそのまま真実の源泉にせず、再照会のきっかけとして使う
- 署名検証、冪等化、監査保存を前提にする
- 重い処理は同期応答に載せず非同期化する
受信時の詳細手順、場面別の説明、シーケンス図は 21-7. Apple通知受信API の詳細 にまとめます。
8-6. Google通知受信API の設計方針
API 名: Google通知受信API
Google RTDN の受信口です。
これは Google 側の状態変化を早く取り込むための通知入口であり、通知本文だけで状態確定する API ではありません。
この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。
- RTDN は状態変化通知として扱い、最終状態は
purchases.subscriptionsv2.getで再照会する - push subscription を採る場合の HTTPS endpoint として設ける
- pull 方式を採る場合は、同じ責務を常駐 worker 側へ移す
- JWT 検証、冪等化、監査保存を前提にする
受信時の詳細手順、push / pull の読み替え、主な呼び出し場面、シーケンス図は 21-8. Google通知受信API の詳細 にまとめます。
8-7. 利用可否取得API を専用 endpoint として切り出す場合
クライアントが現在の利用可否を取得できること自体は MUST ですが、専用 endpoint を切るかどうか は設計判断です。
専用の GET /me/entitlement を設けてもよいですし、既存の画面取得 API に entitlement を含める形でも構いません。
この節で重要なのは、次の判断基準です。
- entitlement を複数画面や複数クライアントから再利用するなら、専用 endpoint が向く
- 既存の BFF や画面取得 API に自然に含められるなら、無理に分離しなくてもよい
- どちらを選んでも、クライアントが参照する利用可否の read model はサーバ側で一貫して提供する
最小レスポンス例、持たせないほうがよい責務、シーケンス図は 20-7. GET /me/entitlement の詳細 にまとめます。
8-8. 課金状態再照合API の設計方針
API 名: 課金状態再照合API
これは CS / 運用向けの手動再照合入口です。
初回リリースの MUST ではありませんが、障害復旧や個別問い合わせ対応では非常に有効です。
この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。
- 直接 DB を書き換える入口ではなく、ストア再照会 + 再投影 の入口にする
- クライアント向け API と分け、管理権限を前提にする
- 運用判断で局所的に再実行できる救済手段として位置付ける
入力の考え方、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-9. POST /admin/billing/reconcile の詳細 にまとめます。
また、運用上の位置付けは 26. 障害復旧とバックフィル や 28. 管理画面・CS・運用権限 と合わせて読むと整理しやすくなります。
8-9. 追加を検討してよい endpoint
8-9-1. GET /me/billing/subscription
設定画面などで entitlement より詳しい契約情報を見せたい場合は、契約詳細表示用の read model を別に持つと整理しやすくなります。
ただし、これも MUST ではなく、画面要件に応じた条件付き追加です。
この endpoint を追加するなら、次の方針が自然です。
- entitlement より詳しい契約情報を返す
- ストア生データをそのまま返さず、画面向け view model として整形する
- クライアントが契約状態の細部を直接解釈しなくて済むようにする
レスポンス例、返却候補、主な呼び出し場面、シーケンス図は 20-8. GET /me/billing/subscription の詳細 にまとめます。
8-9-2. 復元専用 endpoint を増やすべきか
本ドキュメントでは、復元は /billing/*/ingest に action=restore を渡して扱います。
そのため、初回リリースで POST /billing/apple/restore や POST /billing/google/restore を別に増やす必要はありません。復元時の紐付け判断そのものは 23. Restore / Account Linking 詳細 で扱います。
復元専用 endpoint を分けると、購入と復元で検証ロジックや projection ロジックが分岐しやすくなり、挙動差や実装漏れが起きやすくなります。
8-10. レスポンスで返すべきこと
初回リリースでは、次の 4 つがあれば十分です。
- 反映結果
- 現在の利用可否
- 反映完了か保留か
- ユーザー向け表示に使う最小限の理由
例:
{
"result": "PROJECTED",
"entitlement": {
"isActive": true,
"reason": "ACTIVE",
"effectiveUntil": "2026-03-31T12:00:00Z"
}
}
または保留中:
{
"result": "PENDING",
"entitlement": {
"isActive": false,
"reason": "PENDING"
}
}
8-11. HTTP ステータス設計の考え方
初回リリースでは、次の原則で十分です。
200: 正常反映、または冪等再実行202: 非同期で反映中400: 入力不正401: 認証不正409: 別アカウントに既に紐付いており自動復元不可422: ストア状態は取得できたが付与不可500: 内部障害
POST /billing/*/ingest の応答とアプリ側の基本動作
POST /billing/*/ingest は、初回購入・復元・再送の共通入口です。
クライアントは主に transactionId または purchaseToken を送り、現在ユーザーは認証済みコンテキストから特定します。accountToken 系識別子の役割分担は 7-3. accountToken とストア側識別子の関係 で整理したとおり、購入開始時にストアへ埋め込み、ingest 時はストア再照会結果との照合に使う ものとして扱います。
アプリ側の基本動作は、次の表で整理できます。
| 応答 | 主な意味 | アプリ側の基本動作 | 有料機能開放 | 再試行 | 証跡保持 | 課金状態DB更新 |
|---|---|---|---|---|---|---|
200 + isActive=true | 正常反映、または冪等再実行 | 成功として画面更新し、必要なら read model を再取得する | する | 不要 | 不要 | する |
200 + isActive=false | 正常応答だが PENDING などで未開放 | 確認中として表示し、後で再取得する | しない | 状況により可 | 推奨 | する場合がある |
202 | 非同期で反映中 | 確認中 UI を出し、後で再取得する | しない | 可 | 推奨 | まだしない想定 |
400 | 入力不正 | 実装不備やローカル不整合として扱う | しない | 不要 | 原則不要 | しない |
401 | 認証不正 | 再ログインを促し、復帰後に再送する | しない | 可 | 必要 | しない |
409 | 別アカウント競合により自動紐付け不可 | 自動再試行せず、別アカウント確認やサポート導線を案内する | しない | 不要 | 推奨 | しない |
422 | ストア状態は取得できたが、状態上いまは付与不可 | 状態に応じて未開放のまま案内する | しない | 原則不要 | 原則不要 | しない、または非アクティブ更新のみ |
500 | 内部障害 | 結果不明として扱い、後で再送する | しない | 可 | 必要 | 未確定 |
| 通信失敗 / タイムアウト | レスポンス未受信 | 結果不明として扱い、後で再送する | しない | 可 | 必要 | 未確定 |
補足:
- 初回購入で中心になるのは
200と202である。 409 Conflictは主に復元時の別アカウント競合で返す想定である。初回購入でも識別子競合があれば返しえるが、正常な初回購入フローでは発生頻度は低い。- 証跡を消してよいのは、少なくとも
200で成功し、アプリ側で最終状態を確認できた後である。 500と通信失敗は、サーバ側で途中まで進んでいる可能性があるため、DB 更新は未確定として扱うのが安全である。422は照合失敗ではなく、ストア状態は読めたが現時点では付与できないという意味で使う。
9. Apple 実装
9-1. Apple で採るべき基本方針
Apple では、次の方針を採ります。
verifyReceiptを主経路にしない- App Store Server API を中心にする
- App Store Server Notifications V2 を有効化する
appAccountTokenを購入時に必ず入れる- 通知欠落に備えて
Get Notification Historyを用意する
9-2. verifyReceipt を主経路にしない理由
8-1-4 節で述べたとおり、本ドキュメントでの「レシート検証」は App Store Server API を主経路とする検証を指します。
Apple は verifyReceipt を deprecated としており、サーバ検証では App Store Server API や Apple 署名済み transaction / subscription 情報 を使うよう案内しています。
初学者向けの結論としては、次で十分です。
- 新規実装の中心は App Store Server API
- 既存資産との互換や段階的移行が必要なときのみ receipt 系を補助的に使う
つまり、verifyReceipt を完全禁止の API として扱う必要はありませんが、新規設計の真実の源泉や主経路には置かない、という整理です。
9-3. 新規購入フロー
sequenceDiagram
participant App as iOS App
participant BE as Backend
participant Apple as App Store
App->>BE: Get current accountToken before purchase
BE-->>App: accountToken
App->>Apple: Purchase with appAccountToken
Apple-->>App: transaction result
App->>BE: POST /billing/apple/ingest (authenticated request + transactionId)
BE->>Apple: Get Transaction Info
BE->>Apple: Get All Subscription Statuses (if needed)
BE->>BE: Compare appAccountToken and contract lineage
BE->>BE: Project Subscription / Entitlement / History
BE-->>App: projected result9-4. 通知フロー
Apple の通知は V2 を前提にします。
V2 の通知ボディは signedPayload で届き、JWS を verify and decode したうえで扱う必要があります。
data.signedTransactionInfo と data.signedRenewalInfo も同様に、デコード済み JSON をそのまま信用せず、JWS として検証・デコードします。
Apple は App Store Server Library を提供しており、初回実装ではこれを使って検証処理を組むと安全です。
受信時の基本手順
signedPayloadを JWS として verify and decode するnotificationUUIDで冪等化するsignedTransactionInfo/signedRenewalInfoも必要に応じて verify and decode する- 必要に応じて App Store Server API へ再照会する
- DB に投影する
9-5. 環境の扱い
Apple は Sandbox と Production で環境が分かれます。
transactionId をパラメータに取るエンドポイントは、その transactionId を生成した環境と同じ環境で呼ぶ必要があります。
ただし、実装方針としては まず Production URL で呼び、4040010 TransactionIdNotFoundError を受けたら Sandbox で再試行する 形にしておくのが、Apple の一次情報に沿った安全なやり方です。
Sandbox の transactionId を Production に投げたり、その逆を固定的に決め打ちすると正常に取得できないため、本番コードでは production-first / sandbox fallback を採用するのが望ましいです。
ローカルや検証用途で環境が明確に分かっている場合だけ、明示的に Sandbox URL を使っても構いません。
9-5-1. transactionId だけで環境を決め打ちしない
クライアントから渡された transactionId だけを見て、最初から Sandbox / Production を決め打ちするのは避けます。
特に、購入直後の ingest や運用時の手動再照合では、どちらの環境で生成された取引かをサーバ側で安全に確定できるまで未知として扱うほうが安全です。
そのため、Apple 実装では production-first / sandbox fallback を標準にし、呼び出し結果から最終環境を確定する方針にしておくと、復元や障害調査でも挙動をそろえやすくなります。
9-5-2. signed data 検証と API 呼び出しで必要な材料は少し違う
App Store Server API の呼び出しでは、主に issuerId、keyId、privateKey、bundleId を使います。
一方、通知や transaction JWS の signed data 検証 では、Apple Root 証明書群、bundleId、環境、必要に応じて appAppleId などが関わります。
どちらも Apple 連携の資格情報ですが、API を呼ぶための材料 と 署名を検証するための材料 は責務が異なるため、設定や service の責務も分けて考えると実装が整理しやすくなります。
9-6. 通知欠落への備え
Apple は V2 を使っている場合、Get Notification History により
サーバが受け損ねた通知を取得できます。
Apple の案内では、この履歴は
- Production: 過去 180 日
- Sandbox: 過去 30 日
を対象に取得できます。
9-7. テスト通知
Apple には通知疎通確認用に、次の API があります。
Request a Test NotificationGet Test Notification Status
testNotificationToken は、一次情報上 最大 6 か月確認に使えます。
9-8. Apple の billing retry と grace period の考え方
Apple では、購読更新の支払いに失敗したとき、Billing Grace Period を有効化しているかどうかで利用可否の扱いが変わります。
- Billing Grace Period 中: 有料コンテンツへのアクセスを継続する
- Billing Retry だが Grace Period ではない: 初回リリースでは
Entitlement.isActiveをfalseとする - 復旧後: ストア再照会または通知反映で entitlement を戻す
初回リリースでは、Apple の支払い問題系をひとまとめにせず、grace period の有無で判定する方針にしておくと誤解が少なくなります。
本ドキュメントでは、保守的な既定方針として grace でない billing retry は false を採ります。
9-9. Apple で最低限保持したい識別子
transactionIdoriginalTransactionIdappAccountTokenwebOrderLineItemId(必要に応じて)environment
9-10. Apple の初回リリースでの実践的な結論
4 章の方針に沿い、初回リリースでは次を成立させます。
- 購入時に
appAccountTokenを設定する - クライアントから
transactionIdを送る - サーバで
Get Transaction Infoを呼ぶ - 必要に応じて
Get All Subscription Statusesで補う - 通知は V2 を受ける
- 通知欠落時は
Get Notification Historyで補正する
この章の一次情報
- Validating receipts with the App Store
- verifyReceipt
- Get Transaction Info
- Get All Subscription Statuses
- Receiving App Store Server Notifications
- Responding to App Store Server Notifications
- Transaction
- Get Notification History
- Request a Test Notification
- Get Test Notification Status
- App Store Server Library
10. Google 実装
10-1. Google で採るべき基本方針
Google では、次の方針を採ります。
- サーバ検証は
purchases.subscriptionsv2.getを主経路にする - RTDN はトリガとして受ける
- RTDN を受けたら必ず Developer API を呼ぶ
- new purchase token を伴う purchase は acknowledgement を必ず行う
- 購入時に
obfuscatedAccountIdを設定する
10-2. purchases.subscriptionsv2.get を主経路にする理由
Google の一次情報では、バックエンド状態更新のために
RTDN の purchase token を使って purchases.subscriptionsv2.get を呼ぶよう案内しています。
さらに、purchases.subscriptions.get は新規統合での主経路としては非推奨です。
10-3. 新規購入フロー
sequenceDiagram
participant App as Android App
participant Play as Google Play
participant BE as Backend
App->>BE: Get current accountToken before purchase
BE-->>App: accountToken
App->>Play: Launch billing flow with obfuscatedAccountId
Play-->>App: purchaseToken
App->>BE: POST /billing/google/ingest (authenticated request + purchaseToken)
BE->>Play: purchases.subscriptionsv2.get
BE->>BE: Compare obfuscatedExternalAccountId and contract lineage
BE->>BE: Project Subscription / Entitlement / History
BE->>Play: purchases.subscriptions.acknowledge
BE-->>App: projected result10-4. RTDN の理解
RTDN は「完全な purchase state」ではありません。
通知には、主に次のようなものが入ります。
packageNameeventTimeMillissubscriptionNotification.notificationTypepurchaseToken
ここに完全な状態は入っていません。
したがって、RTDN を受けた後は必ず purchases.subscriptionsv2.get を呼びます。
10-4-1. Pub/Sub は push / pull のどちらでもよい
Google Play の RTDN は Pub/Sub topic に publish され、その先の subscription は push / pull のどちらでも構成可能です。基本方針は 3-2. Google 側の準備 で述べたとおりで、本書では push subscription を前提に説明します。
pull subscription を採る場合は、サーバ側で常駐 worker を動かし、Pub/Sub からメッセージを取得して処理します。この場合、Webhook endpoint の代わりに subscriber worker が RTDN 受信口になります。
10-4-2. 本書が push 前提で説明する理由
RTDN の受信方式に迷う場合は、3-2. Google 側の準備 で述べたとおり push を前提にすると整理しやすいです。
そのため、本書でも初回リリース向けの分かりやすさを優先し、次の前提で説明します。
- Pub/Sub topic を作る
- push subscription を作る
/webhooks/google/rtdnで受ける- 認証済みリクエストとして正しく受け付けられることを確認する
purchaseTokenを使ってpurchases.subscriptionsv2.getを呼ぶ
10-5. purchase token の扱い
Google の一次情報では、purchaseToken は
購読開始から、失効後 60 日まで Developer API に使えると案内されています。
最低限は次の 3 点です。
- 現在の
purchaseTokenを保存する linkedPurchaseTokenがあれば旧契約と結び付ける- トークンは差し替わり得る前提で設計する
linkedPurchaseToken が重要になる代表例は、次のケースです。
- re-signup(canceled but non-lapsed subscription、つまり解約済みだが有効期限がまだ到来していない購読への再加入)
- upgrade
- downgrade
- auto-renewing と prepaid の相互変換
- prepaid top-up
一方で、前回購読がすでに期限切れになった後の再購読は linkedPurchaseToken ではなく、
outOfAppPurchaseContext.expiredPurchaseToken や
outOfAppPurchaseContext.expiredExternalAccountIdentifiers 側で扱います。
prepaid は本ドキュメントの主対象外です。ここでの記述は、将来拡張を見据えた参考情報です。
outOfAppPurchaseContextも、初回実装では「見つかれば補助手掛かりに使う」程度で十分です。
10-6. acknowledgement
Google で最重要の運用要件の 1 つです。
acknowledgement が必要な purchase
acknowledgement が必要なのは、new purchase token を伴う purchase です。
これには、少なくとも次が含まれます。
- 初回購入
- plan change
- re-signup
- prepaid top-up(本ドキュメントでは参考情報)
これらの purchase は、purchases.subscriptionsv2.get の結果として entitlement 付与可能な状態になってから 3 日以内に acknowledgement が必要です。
実装では、典型的には subscriptionState が SUBSCRIPTION_STATE_ACTIVE などの付与可能状態で、かつ acknowledgementState が ACKNOWLEDGEMENT_STATE_PENDING の purchase を ack 対象として扱います。
PENDING の間は acknowledge してはいけません。
Google Play の 3 日ルールは、保留中の購入が最終的に課金成立した時点で開始します。
怠ると、Google Play は自動返金・取り消しを行います。
更新
renewal は acknowledgement 不要です。
実行条件
Google では、purchases.subscriptionsv2.get の結果で entitlement 付与可能な subscriptionState であり、かつその purchase の acknowledgementState が ACKNOWLEDGEMENT_STATE_PENDING の場合に acknowledgement を行います。
実装上の原則
- entitlement 付与と acknowledgement は同じユースケース内で扱う
- acknowledgement は entitlement 付与後に行う
- ただし片方だけ失敗する可能性を前提に再試行手段を持つ
- 未 ack を検知するジョブを持つ
10-7. 支払い問題と猶予期間
Google では、状態遷移を次のように扱います。
SUBSCRIPTION_STATE_ACTIVESUBSCRIPTION_STATE_IN_GRACE_PERIOD- account hold 相当
SUBSCRIPTION_STATE_EXPIRED
一次情報では、grace period 中は entitlement を維持すべきとされています。
一方で account hold に入った後は、利用を止める前提で設計します。
加えて、現行仕様では grace period は default enabled、account hold も default enabled です。account hold の既定長は 2025-12-01 以降は自動計算となり、初期値は 60 days - grace period duration です。したがって、実装では「30 日固定」などの前提を埋め込まず、purchases.subscriptionsv2.get の返却状態を真実の源泉にします。
10-8. テスト購入の識別
purchases.subscriptionsv2.get のレスポンスには testPurchase が含まれ得ます。
テスト環境では、これを監査ログに残すようにしておくと原因調査がしやすくなります。
10-9. RTDN のテスト通知
RTDN には testNotification があります。
これは Google Play Console から送られる疎通確認用通知です。
10-10. Google の初回リリースでの実践的な結論
4 章の方針に沿い、Google 側では次の 6 点を成立させます。
- 購入時に
setObfuscatedAccountIdを設定する - クライアントから
purchaseTokenを送る - サーバで
purchases.subscriptionsv2.getを呼ぶ - new purchase token を伴う未 acknowledgement purchase なら acknowledgement する
- RTDN を受ける
- RTDN 受信後にも
purchases.subscriptionsv2.getを呼ぶ
この章の一次情報
- Subscription lifecycle
- About subscriptions
- Integrate the Google Play Billing Library into your app
- purchases.subscriptionsv2.get
- purchases.subscriptions.acknowledge
- Real-time developer notifications reference guide
- Test your Google Play Billing Library integration
11. 状態管理と Entitlement 判定
11-0. Subscription と Entitlement の責務分離
課金設計では、ストア上の契約事実と自社サービスでの利用可否を同じ列や同じテーブルで表そうとすると、意味がすぐにぶれます。
本ドキュメントで Subscription と Entitlement を分けているのは、テーブルを増やすこと自体が目的ではありません。ストア事実と利用判定の責務を混ぜないためです。
Subscriptionは、ストア上の契約状態の最新像を保持するテーブルである。Apple / Google が返す契約事実を内部正規化して保存する責務を持つ。Entitlementは、自社サービスで今そのユーザーにそのプランを使わせてよいかを保持するテーブルである。クライアントや画面 API が参照する利用可否の read model / projectionとして扱う。
この分離が必要なのは、契約状態とアクセス可否が常に一致するわけではないからです。一次情報上も、Apple の Billing Grace Period 中は購読者に有料コンテンツへのアクセスを継続させる前提で説明されており、Google でも Cancelled は期限まではアクセス継続、In grace period はアクセス継続、On hold はアクセス停止として案内されています。
したがって、Subscription.status だけで利用可否まで表そうとすると、ストアの事実と自社サービスの最終判定が 1 つの概念に混ざります。
本書では、この混線を避けるために役割を明確に分けます。
Subscriptionは 契約状態の保持Entitlementは 利用可否の提供
Subscription に isActive / reason を統合しない理由
Subscription に Entitlement.isActive と Entitlement.reason をそのまま統合すると、一見テーブルを減らせるように見えます。
ただし、それは単なるテーブル統合ではなく、契約事実テーブルと利用判定テーブルの責務混在です。
isActiveとreasonは、ストアが返す生の契約事実ではなく、契約状態を自社サービスの利用可否へ投影した結果である。SubscriptionにisActive/reasonを入れると、1 レコードの中に- ストア状態の記録
- 利用可否の最終判定
- クライアント向けの説明理由 が混在し、テーブルの意味が不安定になります。
- その結果、
statusを見るべきか、isActiveを見るべきか、reasonを見るべきかが曖昧になり、実装や運用で解釈がぶれやすくなる。 - とくに、
GET /me/entitlementのような read model と、Subscriptionのような契約状態保存テーブルの責務が混線する。 reasonはEntitlementReasonとして利用可否の説明用に定義するものであり、Subscription.statusと同列の概念ではない。
補足: Subscription と Entitlement は厳密な 1:1 前提にしない
初回リリースの実データでは、Subscription と Entitlement が見かけ上ほぼ 1:1 に近く見えることはあります。
ただし、概念上も将来拡張上も、これを厳密な 1:1として固定する説明は避けます。
- Google の
purchases.subscriptionsv2はlineItems[]を持ち、purchase 単位と item 単位が分かれている。 - さらに Google の subscription with add-ons では、1 回の purchase に複数 item を含められる。
- Apple では auto-renewable subscription に Family Sharing を有効化でき、1 つの購読から複数家族メンバーへアクセス権が広がりうる構成がある。
もちろん、本ドキュメントの主対象は単一ユーザー向けの自動更新サブスクの基本設計であり、Family Sharing 最適化や add-ons 詳細を本文の主対象にはしません。
それでも、モデルの説明としては 「MVP では 1:1 に近く見えるが、概念上は 1:1 と断定しない」 という書き方にしておくほうが、一次情報や将来拡張と齟齬を起こしにくくなります。
参考一次情報:
- Enable billing grace period for auto-renewable subscriptions
- Integrate the Google Play Billing Library into your app
- purchases.subscriptionsv2
- Subscription with add-ons
- Turn on Family Sharing for In-App Purchases
11-1. まず internal state を決める
初回リリースでは、Apple / Google を無理に完全統一しようとせず、
次の内部状態を定義すると理解しやすくなります。
| Internal State | 意味 | Entitlement |
|---|---|---|
PENDING | 購入保留 / 支払い未確定 | 原則 false |
ACTIVE | 正常利用中 | true |
IN_GRACE_PERIOD | 支払い問題だが猶予中 | true |
BILLING_RETRY | 主に Apple の支払い再試行中。grace period は終わっている、または有効ではない | false |
ON_HOLD | Google の account hold による停止 | false |
PAUSED | 一時停止中 | false |
CANCELED | 自動更新オフ、ただし有効期限前 | true |
EXPIRED | 期限切れ | false |
REVOKED | 返金・取り消し | false |
BILLING_RETRYは内部正規化状態です。Apple では App Store Server API の billing retry state(status = 3)やisInBillingRetryPeriodに対応する停止側の状態として使います。Billing Grace Period(status = 4)とは別状態です。
ON_HOLDは内部正規化状態です。Google では account hold に対応します。Apple にはON_HOLDという公式状態名はありません。本書では、Apple の billing retry と Google の account hold は別状態として扱う方針を採ります。
Apple の公式状態や通知名をより生の形で保存したい場合は、statusReasonや補助フラグを別に持つ設計でも構いません。
11-2. 初回リリースでの既定方針
迷った場合は、次の既定方針を採ります。
| ケース | 利用可否 | 理由 |
|---|---|---|
| 新規購入直後で検証成功 | true | 付与してよい |
Google PENDING | false | 決済未確定 |
| Google grace period | true | 一次情報に沿う |
| Google account hold | false | ON_HOLD として扱い、支払い未回復のため停止する |
| 自動更新オフだが期限前 | true | まだ契約期間内 |
| 返金 / revoke | false | 直ちに停止 |
| Apple Billing Grace Period 中 | true | 一次情報に沿って利用継続とする |
| Apple billing retry(grace 終了後 / grace 無効) | false | BILLING_RETRY として扱い、利用停止とする |
11-3. 返金の考え方
返金は「将来更新しない」ではなく、
すでに付与した権利を止めるべきイベントです。
そのため、CANCELED と REVOKED は分けます。
CANCELED: 期限までは有効REVOKED: 即時停止もあり得る
11-4. 無料トライアルと初回無料期間の考え方
初回契約時に無料期間を自動付与する とは、バックエンドが任意に無料権利を作ることではありません。
Apple では App Store Connect の introductory offer、Google では Play Console の subscription offer / free trial を設定し、ストアが無料期間付き purchase として成立させた結果を通常どおり検証・反映します。
そのため初回リリースでは次を押さえます。
- 無料期間の付与条件は ストア設定で表現する
- クライアントは ストアから取得した offer 情報を表示し、選択された offer で購入を開始する
- バックエンドは 無料購入だから特別扱いするのではなく、通常の purchase / transaction と同じ入口で検証し、
TRIALINGまたはACTIVEに投影する
Apple では introductory offer は subscription product ごとに設定できますが、1 人が redeem できる introductory offer は subscription group ごとに 1 回です。Google では auto-renewing subscription の offers で free trial や introductory price を設定でき、eligibility は Google Play 管理またはアプリ実行時判定を使えます。
11-5. トライアル利用歴あり / なしの判定
要件として トライアル利用歴あり / なし が必要なら、現在トライアル中かとは別に、過去に一度でも trial を開始したことがあるかを管理します。
実務上は次の 2 層で考えるのが安全です。
- 購入 UI での eligibility 判定
ストアが真実の源泉です。Apple ではProduct.SubscriptionInfo.isEligibleForIntroOfferを使えます。Google では Play 管理 eligibility、または app 実行時 eligibility を使い、購入可能な offer をProductDetailsから選びます。
これは UI 上でどの offer を見せるかの判断材料 です。購入可否や entitlement の最終判断そのものを、自社だけで確定するための値ではありません。 - 自社要件としての履歴管理
CS、分析、A/B テスト、既存会員向け文言分岐のために、自社 DB にhasStartedTrialAtLeastOnceやfirstTrialStartedAtを持つ価値があります。
実装上は、初めて無料トライアル付き購読を正しく検証できた時点で、自社側の trial 利用履歴を true にします。
ただし、購入可否の最終判断は常にストア側を優先してください。自社 DB が false でも、ストアが non-eligible と判定したら trial は出してはいけません。
11-6. 月額をどこから取得するか
課金画面に表示する価格は、クライアントがストア SDK から取得するのを基本にします。
バックエンド DB の Plan や StoreProductMapping は商品対応表であり、ローカライズ済み表示価格の真実の源泉にはしません。
- Apple: StoreKit の
Product.displayPrice - Google:
queryProductDetailsAsync()で取得したProductDetailsのsubscriptionOfferDetails/PricingPhase.getFormattedPrice()/getBillingPeriod()
バックエンド API が価格文字列を返す場合も、それは表示用キャッシュや補助情報にとどめ、真実の源泉はあくまでストア SDK から取得した価格 とします。
月額プランであれば、Apple / Google ともに取得した価格をそのまま 月額 として扱えます。
一方、年額や 3 か月プランの 月あたり は、billingPeriod と価格から UI 側で換算した参考値であり、ストアが「月額そのもの」を返すわけではありません。実際の請求単位と金額を分かる形で併記してください。
また、通常価格の源泉と、free trial / introductory offer / offer の表示は別の関心事です。購入 UI では、ストア SDK から取得した通常価格に加えて、選択中の offer 内容も分かるように表示してください。
11-7. 支払い情報更新要件を実装上どう扱うか
要件でいう 支払い情報の更新を検知する は、実装上は
支払い情報が更新された事実そのものより、更新の結果として購読が回復したことを検知する
と定義して扱うのが安全です。
- Apple: billing retry / Billing Grace Period 中にユーザーが支払い情報を更新して回復したことは、
DID_RECOVERまたはDID_RENEW(subtype:BILLING_RECOVERY)を受けたうえで、App Store Server API の再照会結果とあわせて把握する。どちらか一方だけを唯一の正解として決め打ちせず、回復系通知は両方を処理対象にする - Google: ユーザーが支払い方法を修正して回復した場合、grace period 中の回復は
SUBSCRIPTION_RENEWEDとして扱われることがある。また、account hold / pause からの回復ではSUBSCRIPTION_RECOVEREDを受ける。RTDN のイベント名を 1 つに決め打ちせず、purchases.subscriptionsv2.getの結果を再投影する
つまり、payment method edited のような支払い手段の編集事実そのものを直接保証する専用イベントを待つのではなく、回復イベントと最新状態再照会で要件を満たすのが現実的です。要件定義や要件表にも、可能なら 支払い情報更新そのものの検知 ではなく 支払い問題からの回復検知 と記載しておくと、実装と仕様の齟齬が起きにくくなります。
また、Google は In-App Messaging API で支払い修正導線を出せるため、grace / hold 中の離脱抑制にも使えます。
12. 購入復元と例外ケース
復元は「ボタン 1 つ」では済みません。
本質は、既存のストア契約をどの内部ユーザーへ安全に結び付けるかです。
12-1. 復元の位置づけ
復元は、毎回の通常利用で必須となる操作ではありません。
通常利用時の正規ルートは、ログイン中ユーザーに紐づく Entitlement をサーバから取得することです。
このとき Entitlement が表しているのは、
- この端末が購入したかどうか
- このストアアカウントが今この端末に存在するかどうか
ではなく、
- この内部ユーザーが現在使ってよいかどうか
です。
そのため、ある端末で購入が行われ、その後に購入情報反映 API や Webhook によって自社 DB の Subscription / Entitlement が正しく更新されていれば、別端末で復元していなくても、同じ内部ユーザーとしてログインした時点で利用可否を取得できてよいです。
つまり、「復元しなくても使えてしまう」のではなく、「その購入がすでにそのユーザーへ反映済みなら、復元なしで使えるのが正しい」 と考えます。
12-2. 復元時に購入情報反映 API を呼ぶ理由
復元時にも購入情報反映 API を呼ぶのは、単にアプリが購入証跡を見つけただけでは不十分で、バックエンド側で再照合と状態反映が必要だからです。
主な理由は次のとおりです。
- アプリが取得した購入証跡をそのまま信用せず、サーバ側でストア再照会するため
- その購入を現在ログイン中の内部ユーザーに結び付けてよいかを判定するため
SubscriptionやEntitlementなどの DB 状態を正しく更新するため- 初回購入と復元で検証ロジックを分けず、共通の入口として扱うため
つまり、復元時の購入情報反映 API は、ストア上の購入事実を自社システム上の利用権へ変換するための処理です。
12-3. 復元が必要なケースと不要なケース
復元が必要になるのは、主にストア上には購入があるのに、自社 DB にまだ正しく反映されていない場合です。
たとえば次のようなケースです。
- 購入直後に購入情報反映 API の呼び出しが失敗した
- Webhook 通知が未到達、未処理、欠落した
- 再インストール後にストア上の既存購入を再検出したい
- 以前の購入が内部ユーザーへまだ安全に紐付いていない
一方で、次のような場合は、復元を毎回要求する必要はありません。
- すでに自社 DB 上でそのユーザーの
Entitlementが有効になっている - アプリ起動時や画面表示時にサーバから利用可否を取得できている
- その結果として、別端末でも同じ内部ユーザーとして自然に有料機能を使える
つまり復元は、有料機能を使うための毎回必須操作ではなく、未反映・未紐付け・取りこぼしを救済するための再同期導線です。
12-4. 復元の基本方針
- 復元専用の別ロジックは作らず、
/billing/*/ingestにaction=restoreを渡す - 検証・投影の本体は新規購入と共通にする
- 違うのは紐付けルールだけにする
12-5. 復元時の判断表
| 状況 | 推奨動作 |
|---|---|
| 購入時の識別子と現在ログイン中ユーザーが一致 | 自動復元 |
| 既知の元契約と同じ内部アカウントへ到達できる | 自動復元 |
| 別の内部アカウントに既に紐付いている | 自動復元しない |
| 自動判定に十分な根拠がない | 手動確認 |
| 未ログイン | 仮保持のみ |
12-6. 未ログイン購入
未ログインでも購入できる UX にする場合、バックエンド設計を明示してください。
推奨:
- 一時的な
UnclaimedPurchaseを持つ - ここには store 側識別子だけを保存する
- ログイン完了時に確定紐付けする
- 確定できるまで
Entitlementを付与しないか、限定的に付与する方針を決める
12-7. 別アカウント誤復元を防ぐ
最も危険なのは、端末を共有しているケースや、
ログインし直した別ユーザーに既存購入を付けてしまうケースです。
防止策:
appAccountToken/obfuscatedAccountIdを必須化する- 復元時は既存系列との一致を優先する
- あいまいな場合は 409 で止める
- CS / 管理画面の手動救済フローを用意する
12-8. 返すべきエラー例
{
"code": "RESTORE_REQUIRES_MANUAL_REVIEW",
"message": "This purchase is already associated with another account."
}
12-9. クライアント側の復元導線
復元要件は、サーバの restore 設計とクライアントの導線の両方があって初めて満たせます。
-
Apple
アプリには Restore Purchases の導線を置き、StoreKit 2 ではAppStore.sync()を呼びます。これにより App Store と購入状態を同期し、取得できた transaction / entitlement を通常どおりサーバへ送って再照合します。 -
Google
専用のrestorePurchases()のような OS API というより、BillingClient.queryPurchasesAsync()で既存購入を再同期する考え方です。接続成功時、アプリ起動時、foreground 復帰時、必要ならユーザーの明示操作時にも呼び、返ってきたpurchaseTokenをサーバへ送って再照合します。ただし、クライアント再同期だけで復元や現在状態の把握が完結するわけではありません。現行の Billing Library では、suspended subscription は
includeSuspendedSubscriptionsを有効にした場合に取得できることがありますが、呼び方や状態に依存します。さらに、canceled but not expired の購読が取得されることもあるため、クライアント結果だけで entitlement を確定してはいけません。Google では、ユーザーが期限前に Play の subscriptions center などから購読を再開すると
SUBSCRIPTION_RESTARTEDが通知されることがあり、この場合は既存のpurchaseTokenが継続して使われます。したがって、Google 側の最終状態は、RTDN を受けたうえでバックエンドがpurchases.subscriptionsv2.getを再照会して判断します。復元導線の前提は 3-2、通知経由の追従は 10-4 も参照してください。
どちらのプラットフォームでも、クライアントが見つけた購入をそのまま信用して権利を付与しないことが重要です。最終判断は必ずバックエンドで再照会して行います。
13. テスト戦略
初学者にとっては、テストを層ごとではなく目的ごとに理解したほうが分かりやすいです。
13-1. 実装時の動作確認ガイド
この節は、CI や自動テストの設計ではなく、実装中のバックエンドを手元で確かめるための手順をまとめたものです。
「コードを書いたらまず何を叩いて確認するか」を迷わないように、Apple / Google 共通で最小限の確認手順を整理します。
13-1-1. 実課金は発生するか
通常の実装・動作確認では、次の環境を使います。
| ストア | テスト環境 | 実課金 | 備考 |
|---|---|---|---|
| Apple | Sandbox | なし | Sandbox テスターで購入しても実際の決済は行われない |
| Apple | StoreKit Testing in Xcode | なし | ローカル完結。ストアサーバ疎通の確認には使えない |
| ライセンステスター | なし | テスト用支払い方法で確認できる | |
| Play Billing Lab で real payment を有効化した場合 | あり | 通常の実装確認では使わない |
したがって、通常の実装段階では Apple Sandbox と Google ライセンステスター を前提に動作確認します。
13-1-2. 購入証跡の検証エンドポイントの動作確認
まず確認したいのは、/billing/apple/ingest と /billing/google/ingest が、実際の Sandbox / ライセンステスター由来の証跡で正しく動くことです。
Apple の場合
- Sandbox テスターでアプリから購入する
- クライアントが受け取った
transactionIdを控える /billing/apple/ingestを curl / Postman で呼ぶ
curl -X POST http://localhost:3000/billing/apple/ingest \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <user-access-token>" \
-H "Idempotency-Key: apple-test-$(date +%s)" \
-d '{
"action": "purchase",
"transactionId": "<sandbox-transaction-id>"
}'
Google の場合
- ライセンステスターでアプリから購入する
- クライアントが受け取った
purchaseTokenを控える /billing/google/ingestを curl / Postman で呼ぶ
curl -X POST http://localhost:3000/billing/google/ingest \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <user-access-token>" \
-H "Idempotency-Key: google-test-$(date +%s)" \
-d '{
"action": "purchase",
"purchaseToken": "<license-tester-purchase-token>"
}'
この確認では、ダミーの token / ID ではなく、実際にストアから払い出された証跡を使うことが重要です。
13-1-3. ストア API の直接呼び出し確認
バックエンド実装の初期段階では、アプリや自社 API を挟まず、ストア API 自体を直接呼んで疎通確認すると切り分けが楽になります。
Apple App Store Server API の確認例
curl -X GET \
-H "Authorization: Bearer <app-store-server-api-jwt>" \
"https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/<transactionId>"
Google purchases.subscriptionsv2.get の確認例
curl -X GET \
-H "Authorization: Bearer <oauth-access-token>" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/<packageName>/purchases/subscriptionsv2/tokens/<purchaseToken>"
ここで確認したいのは、主に次の 3 点です。
- 認証情報の生成方法が正しいか
- 対象環境(Sandbox / Production、テスト用 packageName など)が正しいか
- レスポンスを自分の想定どおりに解釈できるか
13-1-4. Webhook / RTDN の動作確認
Webhook / RTDN は、ローカルでの疑似 POST と 実通知の受信確認 を分けて考えると分かりやすいです。
Apple 通知のローカル確認例
curl -X POST http://localhost:3000/webhooks/apple/notifications \
-H "Content-Type: application/json" \
-d '{"signedPayload":"<test-or-fixture-jws>"}'
Google RTDN のローカル確認例
curl -X POST http://localhost:3000/webhooks/google/rtdn \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <test-or-bypass-token>" \
-d '{
"message": {
"data": "<base64-encoded-rtdn-json>",
"messageId": "test-message-001"
},
"subscription": "projects/example/subscriptions/rtdn-sub"
}'
この Authorization はローカル確認用の例です。
本番相当環境では、Pub/Sub が付与する JWT を Authorization ヘッダで検証する前提で確認します。
そのうえで、外部公開環境では次も確認します。
- Apple の
Request a Test NotificationとGet Test Notification Status - Sandbox / TestFlight 購入時の実通知受信
- Pub/Sub push subscription からの実 RTDN 受信
13-1-5. 実装中の注意事項
- 自作のダミー token / ID ではなく、実際の Sandbox / ライセンステスター由来の証跡で確認する
- Apple の通知は Apple の秘密鍵で署名されるため、自作 JWS を本番用の署名検証ロジックに通すことはできない
- 署名検証バイパスや認証バイパスを許すとしても、ローカル確認限定にし、共有の開発 / ステージング環境では有効化しない
- Google のライセンステスター購入は、未 acknowledgement のままだと約 3 分で返金される
- ingest と Webhook / RTDN は、どちらが先に届いても最終状態が壊れないことを早い段階で手動確認する
- 同じ
Idempotency-Key、同じnotificationUUID、同じmessageIdを 2 回送って、冪等性が壊れないことを確認する
13-1-6. 無料トライアル・復元・猶予期間設定の確認
要件に次が含まれるなら、通常の購入成功テストとは別に確認します。
- 無料トライアル
Apple では introductory offer が対象ユーザーに出るか、Google では対象 offer が購入 UI に出るかを確認します。無料期間付き購入が成立したら、バックエンドでTRIALINGまたは同等状態に投影できること、自社側のtrial historyが初回だけ更新されることを確認します。 - 購入復元
Apple は Restore Purchases 導線からAppStore.sync()を呼び、Google はqueryPurchasesAsync()で既存購入を拾ってサーバ再照合できることを確認します。Google 側の確認観点は 12-9. クライアント側の復元導線 で整理したとおり、queryPurchasesAsync()の結果だけに依存せず、RTDN とpurchases.subscriptionsv2.get再照会でも状態追従できることまで含めます。別アカウント誤復元が起きないことも要確認です。 - 猶予期間
Apple は App Store Connect で Billing Grace Period をまず Sandbox で有効化してから試験し、DID_FAIL_TO_RENEW(subtype:GRACE_PERIOD)で猶予期間入りを確認し、必要に応じてGRACE_PERIOD_EXPIRED後に billing retry へ移行したことを再照会で確認します。支払い修正後の回復はDID_RECOVERまたはDID_RENEW(subtype:BILLING_RECOVERY)と再照会結果で確認します。Google は Play Console の grace period / account hold 設定を確認してから試験し、grace period 中はSUBSCRIPTION_IN_GRACE_PERIOD、silent grace period 終了後はSUBSCRIPTION_ON_HOLD/SUBSCRIPTION_CANCELED/SUBSCRIPTION_EXPIRED/SUBSCRIPTION_RENEWEDなどの結果状態が見えることを確認します。
13-2. テストは 4 段階で考える
| 段階 | 何を見るか |
|---|---|
| 単体テスト | 状態マッピングと冪等性 |
| ストア結合テスト | 実際の購入・更新・復元 |
| 通知結合テスト | Webhook / RTDN の受信と再照会 |
| 補正テスト | 再送・順序逆転・失敗回復 |
13-3. Apple の最小 E2E 手順
- Sandbox テスターを準備する
- 実機または TestFlight で商品取得を確認する
- 購入時に
appAccountTokenを渡す - 購入する
transactionIdを/billing/apple/ingestへ送るSubscription/Entitlementが更新されることを確認する- 通知を受け取れることを確認する
- 復元を実行し、二重付与されないことを確認する
Apple テストで覚えておくべきこと
- Sandbox では本番課金は発生しない
- TestFlight の IAP も sandbox で動く
- Sandbox アカウントの subscription renewal rate によって、更新間隔だけでなく Billing Retry と Billing Grace Period の長さも変わる
- そのため、支払い失敗や猶予期間を試すときは、Sandbox Apple Account の設定を先に確認する
- TestFlight ではサブスク更新は加速され、各期間のサブスクは日次で最大 6 回更新される
- 通知疎通は
Request a Test NotificationとGet Test Notification Statusで確認できる
13-4. Google の最小 E2E 手順
- ライセンステスターを設定する
- 必要に応じて internal / closed などのテストトラックへ出す
- 商品が publish 済みであることを確認する
- 購入時に
setObfuscatedAccountIdを設定する - 購入する
purchaseTokenを/billing/google/ingestへ送るpurchases.subscriptionsv2.getの結果で反映されることを確認する- acknowledgement が成功することを確認する
- RTDN を受け取れることを確認する
Google テストで覚えておくべきこと
- ライセンステスターは無料でテスト購入できる
- Play Console に対象アプリが存在し、パッケージ名が一致している必要がある
- ライセンステスターは sideload でもテストできるが、QA には internal / closed track が便利
- pending purchase は entitlement を付けてはいけない
- ライセンステスターの購入は、未 acknowledgement のままだと約 3 分で返金される
- そのため、acknowledgement 実装の確認は通常購入テストとは別に必ず行う
- 支払い回復の確認では、grace period 中の回復で
SUBSCRIPTION_RENEWEDが観測されうることを確認する - さらに、account hold / pause からの回復で
SUBSCRIPTION_RECOVEREDが観測されうることを確認する - 期限前に Play の subscriptions center などから再開した場合、
SUBSCRIPTION_RESTARTEDが観測されうることを確認する - いずれのケースでも、RTDN のイベント名だけで確定せず、
purchases.subscriptionsv2.getの最新状態を最終判断に使う - Play Billing Lab を使うと price change などのテストがしやすい
13-5. 必ずやるべき異常系テスト
- クライアント送信前に通知が先着する
- 同じ ingest を 2 回送る
- 同じ RTDN / Apple 通知が再送される
- DB 更新前に acknowledgement だけ成功する
- DB 更新後に acknowledgement が失敗する
- 復元対象が別アカウントに紐付いている
- pending purchase が最終的に成功 / 失敗に変わる
- grace period 中の利用継続
- revoke 後の即時停止
13-6. 環境分離マトリクス
| 項目 | ローカル | 開発 / ステージング | 本番 |
|---|---|---|---|
| Apple API 資格情報 | sandbox 中心 | sandbox 中心 | production |
| Apple signed data verifier 設定 | sandbox 想定 | sandbox 想定 | production 想定 |
| Apple 通知 URL | テスト用 | staging | production |
| Google API 資格情報 | テスト用プロジェクト | staging | production |
| RTDN topic / push sub | 分離 | 分離 | 分離 |
| 署名検証バイパス | 条件付きで許容(ローカル確認限定) | 禁止 | 禁止 |
| テスト購入判定の監査ログ | 有効 | 有効 | 有効 |
13-7. 単体テストで固定すべきもの
初回リリースで最も価値が高い単体テストは次です。
- ストア状態 → internal state の変換
- internal state → entitlement の変換
- 同一イベント再処理時の冪等性
- linked token / original transaction の紐付け分岐
- 復元時の 409 / 手動確認分岐
この章の一次情報
- Testing In-App Purchases with sandbox
- Testing subscriptions and In-App Purchases in TestFlight
- Request a Test Notification
- Get Test Notification Status
- Test your Google Play Billing Library integration
- Test in-app billing with application licensing
14. 運用・監視
課金は「作って終わり」ではありません。
正常時より、失敗時にどう戻すか と 異常をどう早く検知するか が重要です。
補正ジョブや再照合の詳細は 26 章、管理画面の権限設計は 29 章、ログ・監査・保持方針は 30 章で扱います。
この章では、運用として最低限見える状態にしておくべきもの を、概要レベルで整理します。
14-1. 最低限見るべき運用観点
- Apple 通知 / Google RTDN を継続受信できていること
- 受信後の非同期投影が滞留していないこと
- 定期ジョブが予定どおり動いていること
PENDING/IN_GRACE_PERIOD/BILLING_RETRY/ON_HOLDが異常に滞留していないこと- CS や運用者が必要時に手動再照合できること
14-2. 監視すべきメトリクス
- 新規購入数
- 復元成功数
PENDING滞留数IN_GRACE_PERIOD滞留数BILLING_RETRY件数ON_HOLD件数- Apple の通知受信数 / 失敗数
- Google RTDN の通知受信数 / 失敗数
- 未 ack 件数
- 反映遅延時間
- 409 復元エラー件数
- 失敗イベント再処理の滞留数
- 補正ジョブの最終成功時刻
14-3. アラート条件の例
- 未 ack 件数 > 0 が継続
- Webhook 5xx 増加
- RTDN 受信が急減
- 購入は増えているのに Entitlement 有効化が減少
- 返金 / revoke 件数が急増
- 409 復元エラーが急増
- バッチの最終成功時刻がしきい値を超えて古い
processingErrorが一定件数を超えて滞留
14-4. ログに残すべき情報
詳細な保持項目や監査対象は 30 章で扱います。ここでは、最低限次が追えることを重視します。
- どの provider / environment のイベントか
- どの transactionId / purchaseToken / originalTransactionId に関する処理か
- どの notificationUUID / messageId / request id に対応するか
- どの internal user / billing account に投影されたか
- projection / acknowledgement / 再試行の結果がどうだったか
- raw payload の保存先参照と最終エラーが何か
14-5. 管理画面で最低限見たい情報
詳細な表示項目や権限制御は 29 章で扱います。ここでは、最低限次が確認できることを重視します。
- 現在の
Subscription/Entitlement状態 - 元のストア識別子と直近イベント
- 直近ジョブ実行結果と最終再照合時刻
- 再照合や再投影の実行導線
- 手動判断が必要なケースを見分けるための履歴
15. 初回リリース時の推奨事項
1 章〜14 章で扱った内容を、初回リリースで守るべき点に絞ってまとめます。
今回の MUST は、実装の好みではなく、本ドキュメントで扱う最低要件を満たすために外せない条件 を指します。
一方、SHOULD は品質・運用性・実装の見通しを大きく改善するが、同じ要件を別の手段でも満たし得る項目 を指します。
MUST
- ストアを真実の源泉とする
SubscriptionとEntitlementを分ける- 履歴テーブルを持つ
- Apple は
appAccountTokenを入れる - Google は
setObfuscatedAccountIdを入れる - Apple では introductory offer / free trial を、Google では subscription offer / free trial を要件に応じて設定する
- Apple では Billing Grace Period を、Google では grace period / account hold を要件に応じて設定する
- 本プロジェクトでは Google の grace period を 3 日で設定する
- Apple は App Store Server Notifications V2 を使う
- Google は RTDN を使う
- Google の RTDN 受信方式として push / pull のどちらを採るかを決める
- Google の RTDN 受信経路を本番運用できる状態にする
- Google の new purchase token を伴う未 acknowledgement purchase を acknowledgement する
- Webhook / RTDN を冪等に処理する
- 通知欠落補正ジョブを持つ
- クライアントでストア価格を取得して表示できるようにする
- クライアントで復元導線を提供する
- トライアル利用歴を判定できる永続的根拠を持つ
- 復元時に別アカウント誤付与を防ぐ
- テスト環境で end-to-end を通す
SHOULD
- 監査ログを保存する
- クライアントが参照する利用可否 read model を 1 か所に寄せる
- テスト通知を CI / 運用確認に組み込む
初回リリースでは後回しにしてよいもの
- 高度な price change UX
- 複雑な offer 戦略
- 会計リコンシリエーションの自動化
- Web 決済との高度な併用
- 管理画面の細かい運用補助機能
最終結論
購入証跡をサーバで再検証し、ストア状態を Subscription / Entitlement / 履歴へ冪等に投影し、通知と補正ジョブで追従できること。