ドキュメント
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 はverifyReceiptを deprecated としており、サーバ検証では App Store Server API や Apple 署名済みの transaction / subscription 情報 を使うよう案内しています。- 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 から取得する
ローカライズ済み表示価格はProduct.displayPriceを使います。月額プランならそのまま月額として扱えます。年額プランの「月あたり」は UI 側での換算値であり、ストアが月額そのものを返すわけではありません。 - 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 と案内されています。設定可能な grace period の長さは 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から、subscriptionOfferDetailsとPricingPhase.getFormattedPrice()/getBillingPeriod()を使って表示価格を組み立てます。年額プランの「月あたり」は UI 側での換算値です。 - Android の復元は専用 OS API というより既存購入の再同期で考える
queryPurchasesAsync()を接続成功時やonResume()で呼び、取得したpurchaseTokenをバックエンドへ送って再照合します。
ただし、queryPurchasesAsync()だけで復元や状態把握を完結させてはいけません。現行の Billing Library では、suspended subscription はincludeSuspendedSubscriptionsを有効にした場合に取得できることがありますが、呼び方や状態に依存します。さらに、canceled but not expired の購読が取得されることもあるため、クライアント結果だけで entitlement を確定してはいけません。最終判断は常にバックエンドのpurchases.subscriptionsv2.get再照会と RTDN 追従で行います。
3-3. 共通チェック
| 項目 | 必須度 | 備考 |
|---|---|---|
| 商品 ID と内部 Plan の対応表 | MUST | ストア ID を業務ロジックに直結させない |
| クライアントからサーバへ送る購入証跡の仕様 | MUST | Apple / Google で分ける |
| クライアントでストア価格を取得して表示できること | MUST | 月額表示やプラン比較の前提にする |
| クライアントで復元導線を提供できること | MUST | Apple の AppStore.sync() や Google の既存購入再同期につなげる |
| トライアル利用歴を判定できる永続的根拠 | MUST | ストアの eligibility 判定だけに依存し切らない(詳細は 11-5) |
| サーバの冪等キー設計 | MUST | 再送や二重送信に耐える |
| 監査ログ保存先 | MUST | トラブル時の原因調査に使う |
| ステージング / 本番の資格情報分離 | MUST | 誤課金・誤検証防止 |
| 「未反映だが購入済み」の UI 文言 | SHOULD | 反映遅延を吸収する |
この章の一次情報
- Overview for configuring In-App Purchases
- Testing In-App Purchases with sandbox
- Testing subscriptions and In-App Purchases in TestFlight
- Getting ready
- Test in-app billing with application licensing
- ProductDetails.PricingPhase
- Create and manage subscriptions
- Understand product types and catalog considerations
- Push subscriptions
- Pull subscriptions
- Choose a subscription type
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に対応付ける - 購入時に埋め込む識別子を決める
- Apple:
appAccountToken - Google:
setObfuscatedAccountId/setObfuscatedProfileId
- Apple:
Phase 2. クライアントからサーバへ購入証跡を送れるようにする
まずは購入直後の最短反映を作ります。
- Apple:
transactionIdを送る - Google:
purchaseTokenを送る - サーバ側エンドポイント
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. 最小ドメインモデル
初回リリースで最低限必要な概念だけに絞ります。
6-1. Plan
自社の販売プランです。
例:
PRO_MONTHLYPRO_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
課金の帰属先です。
多くのサービスでは User と 1:1 でも構いませんが、将来を考えると別概念にしておくほうが安全です。
6-4. Subscription
ストア上の契約状態です。
最低限、次の状態を内部で扱えるようにします。
PENDINGTRIALINGACTIVEIN_GRACE_PERIODON_HOLDPAUSEDCANCELEDEXPIREDREVOKED
TRIALINGは内部状態です。無料トライアル期間中で entitlement は有効だが、通常課金開始前であることを区別したい場合に用います。不要ならACTIVEに畳んでも構いませんが、本ドキュメントでは後続章と整合させるためTRIALINGを明示的に扱います。
ON_HOLDは内部正規化状態です。Google の account hold を表すために用います。
Apple には同名の公式状態はありません。Apple 側では billing retry や期限切れ関連の事実を保持しつつ、内部で必要な場合に限ってON_HOLD相当へ正規化する、という整理で扱います。
6-5. Entitlement
自社サービスの利用可否です。
最低限ほしい項目:
billingAccountIdplanCodeisActiveeffectiveFromeffectiveUntilreason
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は監査ログ
7. 識別子と紐付けの考え方
課金実装で最も事故が起きやすいのは、決済 API そのものではなく、
誰の購入かを誤ることです。
7-1. Apple で主に使う識別子
| 識別子 | 用途 | 備考 |
|---|---|---|
transactionId | 個々の取引を識別 | 復元・更新でも変わる |
originalTransactionId | 同一購読系列の識別 | 継続判定に重要 |
appAccountToken | 自社ユーザーと紐付け | 購入時に渡す UUID |
notificationUUID | 通知の冪等化 | Webhook の重複排除に使う |
Apple の一次情報では、appAccountToken は購入結果の App Store transaction に紐付く UUIDです。
購入時に設定すると、後で transaction information 側にも同じ値が返ってきます。
7-2. Google で主に使う識別子
初回実装でまず押さえるべき識別子は、次の 6 つです。
| 識別子 | 用途 | 備考 |
|---|---|---|
purchaseToken | 現在の購買系列の識別 | サーバ検証の主キー |
linkedPurchaseToken | 旧 purchase との接続 | plan change や re-signup で旧契約を辿るときに使う |
setObfuscatedAccountId(...) | 自社アカウント紐付け | 購入時にクライアントで設定する |
setObfuscatedProfileId(...) | 自社プロフィール紐付け | 複数プロフィールがある場合にクライアントで設定する |
externalAccountIdentifiers.obfuscatedExternalAccountId | Developer API 側で返るアカウント識別子 | 購入時に埋めた値が返る |
messageId | Pub/Sub メッセージ ID | RTDN 受信の冪等化に使う |
注意: クライアント SDK の setter 名は
setObfuscatedAccountId/setObfuscatedProfileIdですが、
purchases.subscriptionsv2.getで返る JSON のフィールド名は
externalAccountIdentifiers.obfuscatedExternalAccountId/externalAccountIdentifiers.obfuscatedExternalProfileIdです。
補足:
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. 紐付けの優先順位
初回リリースでは、次の優先順位を明示してください。
Apple
appAccountTokenの一致- 既知の
originalTransactionIdの一致 - 既知の
transactionId系列からの到達 - 自動紐付けを行わず、手動確認
externalAccountIdentifiers.obfuscatedExternalAccountIdの一致linkedPurchaseTokenから既存契約を辿るoutOfAppPurchaseContext.expiredExternalAccountIdentifiersを補助手掛かりとして使う- 自動紐付けを行わず、手動確認
初回実装では、まず
externalAccountIdentifiers.obfuscatedExternalAccountIdとlinkedPurchaseTokenを確実に扱えれば十分です。
outOfAppPurchaseContextは、前回購読がすでに期限切れになった後の再購読でだけ使う補助情報として考えると理解しやすいです。
7-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
初回リリースでも、少なくとも次の 4 つの 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 を含める形でも構いません。
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 を主軸とする実装ではなく、この購入証跡の検証を指します。
1-1 節で述べたとおり、クライアントから届いた証跡をそのまま信用せず、必ずバックエンドからストアへ再照会します。具体的な API は次のとおりです。
- Apple:
transactionIdを使って App Store Server API や Apple 署名済みデータを確認する - Google:
purchaseTokenを使ってpurchases.subscriptionsv2.getを確認する
/billing/apple/ingest と /billing/google/ingest は、その入口となる API です。
8-2. ingest API を provider ごとに分ける理由
Apple と Google は、クライアントから持ち込まれる証跡も、サーバ側で再照会する方法も異なります。
- Apple は
transactionIdを中心に扱う - Google は
purchaseTokenを中心に扱う - 復元時の識別子や再照合手順も provider ごとに異なる
そのため、初学者にとっては 1 本の endpoint に統合せず provider ごとに分ける ほうが理解しやすく、実装の責務も明確です。
8-3. POST /billing/apple/ingest
API 名: Apple購入情報反映API
8-3-1. 役割
Apple で発生した購入や復元の入口です。
クライアントが transactionId を持ってきたときに、その値をそのまま信用せず、サーバ側で Apple へ再照会して内部状態へ反映します。
8-3-2. いつ呼ばれるか
- 購入完了直後
- 復元導線を踏んだ直後
- クライアントが再同期を要求したとき
8-3-3. 推奨リクエスト
{
"action": "purchase",
"transactionId": "2000001234567890"
}
または復元時:
{
"action": "restore",
"transactionId": "2000001234567890"
}
8-3-4. サーバ側の基本動作
- 認証済みユーザーを確定する
actionがpurchaseかrestoreかを確認する- 冪等性チェックを行う
transactionIdを使って App Store Server API を呼ぶ- 必要に応じて
Get All Subscription Statusesなどで補足確認する- 主要 endpoint は、保存済みの transaction identifier を path parameter として利用できる
- Apple 側識別子と内部ユーザーの紐付け可否を判定する
Subscription/Entitlement/ 履歴を更新する- クライアント向けの最終利用可否を返す
8-3-5. この endpoint に持たせるべき責務
- やること
- Apple への再照会
- 内部状態への projection
- 復元時の紐付け判定
- やらないこと
- クライアント申告だけで entitlement を付ける
- Apple 通知待ちだけで初回反映を完了させる
- UI 向けの詳細表示情報を過剰に返す
8-3-6. 主な呼び出し場面
場面A. 購入完了直後の最短反映
StoreKit で購入成功後、クライアントは transactionId を取得してこの API を呼びます。
サーバは Apple へ再照会し、購入直後に Subscription と Entitlement を更新します。
この経路があることで、Apple 通知を待たずに有料機能を開放できます。
場面B. 復元ボタン押下後の再同期
ユーザーが別端末や再インストール後に「購入を復元」を実行したとき、クライアントは復元対象の transactionId を送ってこの API を呼びます。
サーバは Apple 側証跡と現在ログイン中のユーザーを照合し、紐付け可能であれば状態を反映します。
場面C. 購入直後の応答取りこぼしからの再試行
クライアントで購入自体は完了したものの、アプリの再起動や通信断で前回の反映結果を受け取れなかった場合、クライアントは再度この API を呼びます。
サーバ側は冪等に処理し、既に反映済みなら同じ最終状態を返します。
8-3-7. シーケンス図
sequenceDiagram
autonumber
actor User as User
participant App as iOS App
participant API as POST /billing/apple/ingest
participant Auth as Auth Guard
participant Idem as Idempotency Check
participant Queue as Job Queue
participant Worker as Billing Worker
participant Apple as App Store Server API
participant Projector as Projection Service
participant DB as App DB
User->>App: Complete purchase or tap restore
App->>API: action, transactionId
API->>Auth: Authenticate current user
Auth-->>API: userId
API->>API: Validate request payload
API->>Idem: Check duplicate by userId + action + transactionId
alt Already completed recently
Idem-->>API: existing result
API-->>App: current entitlement / projection result
else Needs processing
Idem-->>API: continue
API->>DB: Save ingest request audit log
API->>Queue: Enqueue apple ingest job
Queue-->>API: accepted
API->>Worker: Start job
Worker->>Apple: Get Transaction Info(transactionId)
Apple-->>Worker: transaction data
opt Additional confirmation is needed
Worker->>Apple: Get All Subscription Statuses(originalTransactionId or transactionId)
Apple-->>Worker: latest subscription statuses
end
Worker->>Projector: Map store state to internal state
Projector->>DB: Upsert Subscription
Projector->>DB: Upsert Entitlement
Projector->>DB: Insert BillingTransaction / audit history
DB-->>Projector: committed
Projector-->>Worker: final entitlement
Worker-->>API: final result
API-->>App: projection result + entitlement
end
App-->>User: Unlock premium features or show current state8-3-8. 補足
復元専用の別 endpoint を切るより、action=restore を受ける形にして、検証と projection の本体を新規購入と共通化するほうが安全です。
8-4. POST /billing/google/ingest
API 名: Google購入情報反映API
8-4-1. 役割
Google Play の購入や復元の入口です。
クライアントが持ち込んだ purchaseToken を起点に、サーバ側で purchases.subscriptionsv2.get を呼び、内部状態へ反映します。
8-4-2. いつ呼ばれるか
- 購入完了直後
- 復元導線を踏んだ直後
- pending purchase が purchased へ進んだあとに再同期するとき
8-4-3. 推奨リクエスト
{
"action": "purchase",
"purchaseToken": "example_purchase_token"
}
または復元時:
{
"action": "restore",
"purchaseToken": "example_purchase_token"
}
8-4-4. 最小必須項目の考え方
Google の ingest API は、公開 API の最小構成では purchaseToken を必須にしておけば十分です。
ただし、バックエンドが purchases.subscriptionsv2.get を呼ぶ際には、purchaseToken に加えて packageName も必要です。packageName は、対象アプリのサーバ設定、認証済みクライアントの文脈、またはクライアントから任意で受け取った申告値から補完します。productId などの追加情報はストア応答から確認できます。
ただし、実運用では次の目的で packageName や productId を 任意で受け取る 設計も有効です。
- 想定外のアプリや商品に対する token を早期に弾く
- 監査ログにクライアント申告値を残す
- クライアント実装とサーバ実装の不整合を検知する
したがって、本ドキュメントでは purchaseToken を最小必須、packageName / productId は 追加検証用の任意項目 として扱います。
8-4-5. サーバ側の基本動作
- 認証済みユーザーを確定する
actionがpurchaseかrestoreかを確認する- 冪等性チェックを行う
purchaseTokenと補完済みのpackageNameを使ってpurchases.subscriptionsv2.getを呼ぶ- 状態、帰属先、line item を確認する
Subscription/Entitlement/ 履歴を更新する- entitlement 付与可能で、かつ new purchase token を伴う未 acknowledgement purchase なら acknowledgement を実行する
- クライアント向けの最終利用可否を返す
8-4-6. この endpoint に持たせるべき責務
- やること
- Google への再照会
- pending / active / expired などの内部状態化
- acknowledgement 要否の判定
- やらないこと
purchaseTokenの文字列だけで有効判定する- クライアント申告だけで entitlement を付ける
- renewal ごとに毎回 acknowledgement する
8-4-7. 主な呼び出し場面
場面A. 購入完了直後の最短反映
Billing Library で購入成功後、クライアントは purchaseToken を取得してこの API を呼びます。
サーバは Google API へ再照会し、必要なら acknowledgement まで行ったうえで Subscription と Entitlement を反映します。
場面B. 復元導線からの再同期
ユーザーが機種変更後や再ログイン後に既存の契約を同期したい場合、クライアントは復元対象の purchaseToken を送ってこの API を呼びます。
サーバは Google 側状態とユーザー帰属を確認し、反映可能なものだけを内部状態へ反映します。
場面C. pending から purchased への遷移後の再反映
Google では pending purchase が後から purchased に進むことがあります。
このときクライアントが再同期を行い、同じ purchaseToken でこの API を再度呼ぶことで、サーバは entitlement 付与と acknowledgement を完了できます。
8-4-8. シーケンス図
sequenceDiagram
autonumber
actor User as User
participant App as Android App
participant API as POST /billing/google/ingest
participant Auth as Auth Guard
participant Idem as Idempotency Check
participant Queue as Job Queue
participant Worker as Billing Worker
participant Google as Google Play Developer API
participant Projector as Projection Service
participant DB as App DB
User->>App: Complete purchase / restore / retry after pending
App->>API: action, purchaseToken, optional packageName/productId
API->>Auth: Authenticate current user
Auth-->>API: userId
API->>API: Validate request payload
API->>Idem: Check duplicate by userId + action + purchaseToken
alt Already completed recently
Idem-->>API: existing result
API-->>App: current entitlement / projection result
else Needs processing
Idem-->>API: continue
API->>DB: Save ingest request audit log
API->>Queue: Enqueue google ingest job
Queue-->>API: accepted
API->>Worker: Start job
Worker->>Google: purchases.subscriptionsv2.get(purchaseToken)
Google-->>Worker: subscription resource
Worker->>Worker: Determine latest line item and entitlement state
alt New purchase token and acknowledgement required
Worker->>Google: purchases.subscriptions.acknowledge
Google-->>Worker: acknowledged
else Renewal or already acknowledged
Worker->>Worker: Skip acknowledgement
end
Worker->>Projector: Map store state to internal state
Projector->>DB: Upsert Subscription
Projector->>DB: Upsert Entitlement
Projector->>DB: Insert BillingTransaction / audit history
DB-->>Projector: committed
Projector-->>Worker: final entitlement
Worker-->>API: final result
API-->>App: projection result + entitlement
end
App-->>User: Unlock premium features or show current state8-4-9. 補足
renewal は通常この API の主経路ではありません。
自動更新、解約、支払い問題の追従は、後述する RTDN と再照合ジョブを主入口にします。
8-5. POST /webhooks/apple/notifications
API 名: Apple通知受信API
8-5-1. 役割
Apple Server Notifications V2 の受信口です。
初回購入の入口ではなく、状態変化の追従、通知ドリブン補正、取りこぼしの早期回収 のために置きます。
8-5-2. いつ必要か
- 更新、解約、失効、返金などの変化を早く反映したいとき
- クライアント起動に依存せず状態を追従したいとき
- 補正ジョブの負荷を下げたいとき
8-5-3. 受信時の基本動作
- Apple 署名を検証する
notificationUUIDなどで冪等性を担保する- 受信 payload を監査用に保存する
- 通知本文をそのまま真実の源泉にせず、必要な識別子を抽出する
- App Store Server API を再照会し、最新状態へ projection する
- すぐ返答し、重い処理は非同期化する
8-5-4. この endpoint の注意点
- 通知そのものを直接 entitlement 判定の唯一根拠にしない
- 署名検証前に業務処理へ進めない
- 再送や重複到着を前提に設計する
8-5-5. 主な呼び出し場面
場面A. 自動更新の追従
Apple 側でサブスクが自動更新されると、クライアントが何もしなくても Apple からこの API に通知が届きます。
サーバは通知を受けて最新状態を再照会し、Subscription.expiresAt などを延長します。
場面B. 解約・失効・返金の早期反映
ユーザーがサブスクを解約した、支払い問題で失効した、返金が発生した、といった変化はクライアント起点で届かないことがあります。
この API はそのような変化を早く取り込み、利用可否の取りこぼしを減らします。
場面C. クライアント申告の補完
購入直後にクライアントからの ingest が失敗した場合でも、Apple 通知経由で後から状態を補足できます。
そのため、この API は初回購入の主経路ではなくても、整合回復の重要な入口になります。
8-5-6. シーケンス図
sequenceDiagram
autonumber
participant Apple as App Store Notifications V2
participant API as POST /webhooks/apple/notifications
participant Verify as Signature Verifier
participant Idem as Dedupe Check
participant DB as App DB
participant Queue as Job Queue
participant Worker as Billing Worker
participant AppleAPI as App Store Server API
participant Projector as Projection Service
Apple->>API: signedPayload
API->>Verify: Verify JWS signature and bundle/environment
Verify-->>API: verified payload
API->>Idem: Check duplicate by notificationUUID
alt Duplicate notification
Idem-->>API: duplicate
API-->>Apple: 200 OK
else First arrival
Idem-->>API: continue
API->>DB: Save InboundWebhookEvent(raw payload, metadata)
API->>Queue: Enqueue apple notification job
Queue-->>API: accepted
API-->>Apple: 200 OK
Worker->>DB: Load inbound event
Worker->>Worker: Extract transactionId / originalTransactionId / subtype
Worker->>AppleAPI: Re-fetch latest transaction / subscription status
AppleAPI-->>Worker: latest store state
Worker->>Projector: Recompute Subscription / Entitlement
Projector->>DB: Upsert Subscription
Projector->>DB: Upsert Entitlement
Projector->>DB: Insert BillingTransaction / mark event processed
DB-->>Projector: committed
end8-5-7. 補足
この API は通知を受けた瞬間に同期的に全反映を終える場ではなく、署名検証・受信記録・非同期再照会の起点として設計するのが安全です。
8-6. POST /webhooks/google/rtdn
API 名: Google通知受信API
8-6-1. 役割
Google Play の Real time developer notifications の受信口です。
Apple 通知と同様に、状態変化の追従と補正を早く行うための入口 です。
8-6-2. いつ必要か
- 期限切れ、解約、支払い問題、renewal などを素早く反映したいとき
- クライアント再訪を待たずに内部状態を更新したいとき
- purchase token 単位の補正を自動で回したいとき
本書では Pub/Sub push subscription を採る場合の受信 endpoint としてこの API を説明します。
pull subscription を採る場合は、この HTTP endpoint 自体は不要で、代わりに subscriber worker が同じ責務を担います。
8-6-3. 受信時の基本動作
- Pub/Sub push の JWT を検証する
- envelope と
message.dataを復号・解析する - event id や publish 時刻などで冪等性を担保する
- 受信データを監査用に保存する
- 通知だけで確定せず、
purchaseTokenを使って Google API へ再照会する - 最新状態へ projection する
- すぐ返答し、重い処理は非同期化する
8-6-4. この endpoint の注意点
- JWT 検証を通していない push を信用しない
notificationTypeだけで entitlement を決めない- Pub/Sub 再送や順不同到着を前提にする
8-6-5. 主な呼び出し場面
場面A. 自動更新の追従
Google Play でサブスクが自動更新されると、RTDN が Pub/Sub に publish され、push subscription を採っている場合はこの API に通知が届きます。
サーバは通知を受けて purchaseToken を再照会し、期限延長や状態更新を反映します。
場面B. 支払い問題・猶予期間・保留の追従
Google では grace period や account hold のような状態遷移が発生します。
この API は、その変化をクライアント再訪より先に捕捉し、利用可否の切り替えを早く行うために呼ばれます。
場面C. pull 方式を採らない構成での通知受信口
NestJS の Web API サーバを中心に構成する場合は、subscriber worker を別途常駐させる代わりに、この API を Pub/Sub push の受信口として置くと分かりやすくなります。
反対に pull 方式を採る場合は、この場面自体が worker 側処理に置き換わります。
8-6-6. シーケンス図
sequenceDiagram
autonumber
participant PubSub as Google Pub/Sub Push
participant API as POST /webhooks/google/rtdn
participant Verify as JWT Verifier
participant Idem as Dedupe Check
participant DB as App DB
participant Queue as Job Queue
participant Worker as Billing Worker
participant Google as Google Play Developer API
participant Projector as Projection Service
PubSub->>API: Push message(envelope + JWT)
API->>Verify: Verify Authorization JWT(audience / service account)
Verify-->>API: verified
API->>API: Decode message.data
API->>Idem: Check duplicate by messageId / publishTime / event key
alt Duplicate delivery
Idem-->>API: duplicate
API-->>PubSub: 200 OK
else First arrival
Idem-->>API: continue
API->>DB: Save InboundWebhookEvent(raw payload, metadata)
API->>Queue: Enqueue RTDN job
Queue-->>API: accepted
API-->>PubSub: 200 OK
Worker->>DB: Load inbound event
Worker->>Worker: Extract purchaseToken and notificationType
Worker->>Google: purchases.subscriptionsv2.get(purchaseToken)
Google-->>Worker: latest subscription resource
Worker->>Projector: Recompute Subscription / Entitlement
Projector->>DB: Upsert Subscription
Projector->>DB: Upsert Entitlement
Projector->>DB: Insert BillingTransaction / mark event processed
DB-->>Projector: committed
end8-6-7. pull 方式を採る場合の読み替え
pull subscription を採る場合は、上図の POST /webhooks/google/rtdn を subscriber worker に読み替えます。
つまり、通知を受ける責務そのものは必要ですが、HTTP endpoint としての実装は不要になります。
8-7. GET /me/entitlement を切り出す場合
API 名: 利用可否取得API
8-7-1. 役割
GET /me/entitlement は、クライアントが最終的な利用可否を確認するための専用 read modelです。
アプリ側はストア API の生レスポンスを解釈せず、この endpoint の結果だけを見て有料機能の開閉を判断する ことができます。
ただし、専用 endpoint を別途用意すること自体は MUST ではありません。
既存の XXX画面取得API や BFF のレスポンスに entitlement を含める設計でも問題ありません。
8-7-2. この endpoint が向いている場合
- 複数画面で同じ entitlement 情報を横断利用する
- 購入直後や復元直後に entitlement だけを軽く再取得したい
- クライアント実装として「利用可否はこの API を見ればよい」と単純化したい
このような要件がある場合は、専用の GET /me/entitlement を切り出す価値があります。
8-7-3. 既存画面 API に含める場合の考え方
既存の XXX画面取得API に entitlement を含める設計でも構いません。
その場合でも、次の 3 点は崩さないようにします。
- entitlement 判定ロジックを画面ごとに分散させない
isActiveやreasonなどの意味を API ごとにぶらさない- ストア状態の最終判断は必ずサーバ側で行う
つまり、外部 API は分かれていても、内部では同じ EntitlementQueryService や read model を参照する 形が望ましいです。
8-7-4. 返す情報の最小構成
例:
{
"planCode": "PRO_MONTHLY",
"isActive": true,
"reason": "ACTIVE",
"effectiveUntil": "2026-03-31T12:00:00Z"
}
最低限、次を返せば十分です。
- 現在有効か
- どの理由で有効 / 無効か
- いつまで有効か
- 必要なら plan の識別子
8-7-5. この endpoint に持たせないほうがよいもの
- ストア生レスポンスそのもの
- 管理画面向けの監査情報
- 復元候補一覧のような複雑な運用情報
8-7-6. 主な呼び出し場面
場面A. アプリ起動時や画面表示時の利用可否確認
クライアントは、ホーム画面や設定画面を開く前に現在の利用可否だけを知りたいことがあります。
その場合、この API を呼んで isActive や effectiveUntil を取得し、UI の表示可否を決めます。
場面B. 購入直後・復元直後の反映確認
/billing/*/ingest の完了後に、クライアントが別途最新の read model を取り直したい場合があります。
この API を用意しておくと、購入反映と表示取得を役割分担しやすくなります。
場面C. 既存画面 API ではなく共通 read model が欲しい場合
複数画面から同じ利用可否を参照する構成では、画面 API ごとに entitlement を埋め込むより、この API を共通 read model として呼ぶほうが分かりやすいことがあります。
8-7-7. シーケンス図
sequenceDiagram
autonumber
actor User as User
participant App as Mobile App
participant API as GET /me/entitlement
participant Auth as Auth Guard
participant Query as EntitlementQueryService
participant DB as App DB
User->>App: Open premium-related screen
App->>API: GET /me/entitlement
API->>Auth: Authenticate current user
Auth-->>API: userId
API->>Query: Build entitlement view model for userId
Query->>DB: Read current Entitlement
DB-->>Query: entitlement row
opt Additional plan summary is needed
Query->>DB: Read Plan / StoreProductMapping
DB-->>Query: plan metadata
end
Query->>Query: Normalize response fields(isActive, reason, effectiveUntil)
Query-->>API: entitlement response
API-->>App: 200 OK + entitlement
App-->>User: Show or hide premium UI8-7-8. 既存画面 API に含める場合のシーケンス図
sequenceDiagram
autonumber
actor User as User
participant App as Mobile App
participant ScreenAPI as GET /settings
participant Auth as Auth Guard
participant ScreenQuery as SettingsQueryService
participant EntQuery as EntitlementQueryService
participant DB as App DB
User->>App: Open settings screen
App->>ScreenAPI: GET /settings
ScreenAPI->>Auth: Authenticate current user
Auth-->>ScreenAPI: userId
par Build screen data
ScreenAPI->>ScreenQuery: Load settings view model
ScreenQuery->>DB: Read settings-related data
DB-->>ScreenQuery: settings data
and Build entitlement summary
ScreenAPI->>EntQuery: Load entitlement summary
EntQuery->>DB: Read Entitlement
DB-->>EntQuery: entitlement row
opt Additional billing label is needed
EntQuery->>DB: Read Plan metadata
DB-->>EntQuery: plan label
end
EntQuery-->>ScreenAPI: entitlement summary
end
ScreenQuery-->>ScreenAPI: settings view model
ScreenAPI->>ScreenAPI: Compose single response payload
ScreenAPI-->>App: settings data + entitlement
App-->>User: Render billing status in screen8-8. POST /admin/billing/reconcile
API 名: 課金状態再照合API
8-8-1. 役割
運用者や管理者が、特定契約を手動で再照合するための endpoint です。
通知欠落、障害復旧、CS 問い合わせ対応、誤反映の再投影で使います。
8-8-2. なぜ SHOULD なのか
理想は初回から入れることですが、一般ユーザー向けの課金フローを成立させるうえでの最小要件ではありません。
ただし、本番運用まで見据えるなら、かなり早い段階で入れるべき管理系 endpoint です。
8-8-3. 入力の考え方
最初から何でも受けられる万能 API にせず、どのキーで再照合するかを明示 したほうが安全です。
例:
{
"provider": "google",
"purchaseToken": "example_purchase_token"
}
または:
{
"provider": "apple",
"transactionId": "2000001234567890"
}
8-8-4. サーバ側の基本動作
- 管理権限を確認する
- 入力キーに応じてストア再照会を行う
- 現在の内部状態との差分を確認する
- 必要なら projection を再実行する
- 監査ログを残す
8-8-5. この endpoint の注意点
- 直接 DB を書き換える入口にしない
- 手動付与 API と混同しない
- 誰が何を再照合したかを必ず残す
8-8-6. 主な呼び出し場面
場面A. CS 問い合わせ時の状態再確認
ユーザーから「購入済みなのに使えない」「解約したのに使えてしまう」といった問い合わせが来た場合、運用者は対象の transactionId や purchaseToken を指定してこの API を呼びます。
これにより、現在のストア状態と内部状態の差分をその場で再確認できます。
場面B. 通知欠落や障害復旧後の手動補正
Webhook や RTDN の受信障害があった場合、対象契約だけを再照合して投影し直したいことがあります。
そのようなときに、この API を使って局所的に再反映します。
場面C. 定期ジョブではなく運用者判断で即時再実行したい場合
補正ジョブを待たずに、特定契約だけいま反映し直したいことがあります。
その際の運用用入口がこの API です。
8-8-7. シーケンス図
sequenceDiagram
autonumber
actor Admin as Admin / CS
participant API as POST /admin/billing/reconcile
participant Auth as Admin Auth Guard
participant Store as Store API
participant Projector as Projection Service
participant DB as App DB
Admin->>API: provider + transactionId or purchaseToken
API->>Auth: Verify admin permission
Auth-->>API: authorized
API->>DB: Save reconcile request audit log
alt provider = apple
API->>Store: Re-fetch transaction / subscription status from Apple
Store-->>API: latest Apple state
else provider = google
API->>Store: Re-fetch subscription state from Google
Store-->>API: latest Google state
end
API->>Projector: Re-run projection with fetched store state
Projector->>DB: Upsert Subscription
Projector->>DB: Upsert Entitlement
Projector->>DB: Insert BillingTransaction / operator audit log
DB-->>Projector: committed
Projector-->>API: diff summary + final entitlement
API-->>Admin: reconciled result + audit summary8-9. 追加を検討してよい endpoint
8-9-1. GET /me/billing/subscription
API 名: 契約詳細取得API
設定画面や契約詳細画面で、既存の画面 API や GET /me/entitlement より多い情報を返したい場合に追加を検討します。
返却候補:
planCodestorestatuswillReneweffectiveUntilinTrialinGracePeriod
これは 利用可否判定の中核 ではなく、表示や説明責務のための read model です。
そのため、初回リリースでは無理に追加せず、必要になったら分けて増やすほうが安全です。
主な呼び出し場面
- 設定画面で「次回更新日」「自動更新の有無」「試用中かどうか」まで表示したいとき
- 課金管理画面で、単なる
isActive以上の契約説明が必要なとき - CS 向けではなく、一般ユーザー向けの表示責務として契約詳細を返したいとき
シーケンス図
sequenceDiagram
autonumber
actor User as User
participant App as Mobile App
participant API as GET /me/billing/subscription
participant Auth as Auth Guard
participant Query as SubscriptionReadModelService
participant DB as App DB
User->>App: Open billing detail screen
App->>API: GET /me/billing/subscription
API->>Auth: Authenticate current user
Auth-->>API: userId
API->>Query: Build subscription detail view model
Query->>DB: Read current Subscription
DB-->>Query: subscription row
Query->>DB: Read Entitlement and related Plan metadata
DB-->>Query: entitlement + plan data
Query->>Query: Derive status, willRenew, inTrial, inGracePeriod
Query-->>API: detail view model
API-->>App: planCode, status, willRenew, effectiveUntil...
App-->>User: Render contract detail screen8-9-2. 復元専用 endpoint を増やすべきか
本ドキュメントでは、復元は /billing/*/ingest に action=restore を渡して扱います。
そのため、初回リリースで POST /billing/apple/restore や POST /billing/google/restore を別に増やす必要はありません。
復元専用 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: 内部障害
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->>Apple: Purchase with appAccountToken
Apple-->>App: transaction result
App->>BE: POST /billing/apple/ingest (transactionId)
BE->>Apple: Get Transaction Info
BE->>Apple: Get All Subscription Statuses (if needed)
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-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 を 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->>Play: Launch billing flow with obfuscatedAccountId
Play-->>App: purchaseToken
App->>BE: POST /billing/google/ingest (purchaseToken)
BE->>Play: purchases.subscriptionsv2.get
BE->>BE: Verify state and account binding
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 のどちらでも構成可能です。
本書で主に扱うのは push subscription で、Pub/Sub から HTTPS endpoint に通知を届ける構成です。
一方、pull subscription を採る場合は、サーバ側で常駐 worker を動かし、Pub/Sub からメッセージを取得して処理します。
この場合、Webhook endpoint の代わりに subscriber worker が RTDN 受信口になります。
10-4-2. 本書が push 前提で説明する理由
RTDN は、一般に大量メッセージの高スループット処理よりも、課金状態の変化をなるべく早く安全に受けること が主目的です。
Google も、push / pull で迷う場合は push を使うことを推奨しています。
そのため、本書では初回リリース向けの分かりやすさを優先し、次の前提で説明します。
- Pub/Sub topic を作る
- push subscription を作る
/webhooks/google/rtdnで受けるAuthorizationヘッダの JWT を検証する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-1. まず internal state を決める
初回リリースでは、Apple / Google を無理に完全統一しようとせず、
次の内部状態を定義すると理解しやすくなります。
| Internal State | 意味 | Entitlement |
|---|---|---|
PENDING | 購入保留 / 支払い未確定 | 原則 false |
ACTIVE | 正常利用中 | true |
IN_GRACE_PERIOD | 支払い問題だが猶予中 | true |
ON_HOLD | 支払い問題で停止中 | false |
PAUSED | 一時停止中 | false |
CANCELED | 自動更新オフ、ただし有効期限前 | true |
EXPIRED | 期限切れ | false |
REVOKED | 返金・取り消し | false |
ON_HOLDは内部正規化状態です。Google では account hold に対応します。
Apple にはON_HOLDという公式状態名はありません。本書では、Billing Grace Period 終了後に billing retry へ入った事実や期限切れ関連情報を保持しつつ、内部で必要な場合に限ってON_HOLD相当へ正規化する方針を採ります。Apple の公式状態や通知名をそのまま保存したい場合は、statusReasonや補助フラグを別に持つ設計でも構いません。
11-2. 初回リリースでの既定方針
迷った場合は、次の既定方針を採ります。
| ケース | 利用可否 | 理由 |
|---|---|---|
| 新規購入直後で検証成功 | true | 付与してよい |
Google PENDING | false | 決済未確定 |
| Google grace period | true | 一次情報に沿う |
| account hold | false | 支払い未回復 |
| 自動更新オフだが期限前 | true | まだ契約期間内 |
| 返金 / revoke | false | 直ちに停止 |
| Apple Billing Grace Period 中 | true | 一次情報に沿って利用継続とする |
| Apple billing retry(grace ではない) | false | 初回リリースでは保守的に停止する |
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 側で換算した参考値であり、ストアが「月額そのもの」を返すわけではありません。実際の請求単位と金額を分かる形で併記してください。
11-7. 支払い情報更新要件を実装上どう扱うか
要件でいう 支払い情報の更新を検知する は、実装上は
支払い情報が更新された事実そのものより、更新の結果として購読が回復したことを検知する
と定義して扱うのが安全です。
- Apple: billing retry / Billing Grace Period 中にユーザーが支払い情報を更新して回復したことは、
DID_RECOVERまたはDID_RENEW(subtype:BILLING_RECOVERY)を受けたうえで、App Store Server API の再照会結果とあわせて把握する。どちらか一方だけを唯一の正解として決め打ちせず、回復系通知は両方を処理対象にする - Google: ユーザーが支払い方法を修正して回復すると
SUBSCRIPTION_RECOVEREDを受け取り、purchases.subscriptionsv2.getの結果を再投影する
つまり、payment method edited のような支払い手段の編集事実そのものを直接保証する専用イベントを待つのではなく、回復イベントと最新状態再照会で要件を満たすのが現実的です。要件定義や要件表にも、可能なら 支払い情報更新そのものの検知 ではなく 支払い問題からの回復検知 と記載しておくと、実装と仕様の齟齬が起きにくくなります。
また、Google は In-App Messaging API で支払い修正導線を出せるため、grace / hold 中の churn 抑制にも使えます。
12. 購入復元と例外ケース
復元は「ボタン 1 つ」では済みません。
本質は、既存のストア契約をどの内部ユーザーへ安全に結び付けるかです。
12-1. 復元の基本方針
- 復元専用の別ロジックは作らず、
/billing/*/ingestにaction=restoreを渡す - 検証・投影の本体は新規購入と共通にする
- 違うのは紐付けルールだけにする
12-2. 復元時の判断表
| 状況 | 推奨動作 |
|---|---|
| 購入時の識別子と現在ログイン中ユーザーが一致 | 自動復元 |
| 既知の元契約と同じ内部アカウントへ到達できる | 自動復元 |
| 別の内部アカウントに既に紐付いている | 自動復元しない |
| 自動判定に十分な根拠がない | 手動確認 |
| 未ログイン | 仮保持のみ |
12-3. 未ログイン購入
未ログインでも購入できる UX にする場合、バックエンド設計を明示してください。
推奨:
- 一時的な
UnclaimedPurchaseを持つ - ここには store 側識別子だけを保存する
- ログイン完了時に確定紐付けする
- 確定できるまで
Entitlementを付与しないか、限定的に付与する方針を決める
12-4. 別アカウント誤復元を防ぐ
最も危険なのは、端末を共有しているケースや、
ログインし直した別ユーザーに既存購入を付けてしまうケースです。
防止策:
appAccountToken/obfuscatedAccountIdを必須化する- 復元時は既存系列との一致を優先する
- あいまいな場合は 409 で止める
- CS / 管理画面の手動救済フローを用意する
12-5. 返すべきエラー例
{
"code": "RESTORE_REQUIRES_MANUAL_REVIEW",
"message": "This purchase is already associated with another account."
}
12-6. クライアント側の復元導線
復元要件は、サーバの 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 側の最終状態は、RTDN を受けたうえでバックエンドがpurchases.subscriptionsv2.getを再照会して判断します。
どちらのプラットフォームでも、クライアントが見つけた購入をそのまま信用して権利を付与しないことが重要です。最終判断は必ずバックエンドで再照会して行います。
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 ではqueryPurchasesAsync()の結果だけに依存せず、必要に応じてincludeSuspendedSubscriptionsを考慮したうえで、RTDN とpurchases.subscriptionsv2.get再照会でも状態追従できることを確認します。別アカウント誤復元が起きないことも要確認です。 - 猶予期間
Apple は App Store Connect で Billing Grace Period をまず Sandbox で有効化してから試験し、支払い修正後にDID_RECOVERまたはDID_RENEW(subtype:BILLING_RECOVERY)と再照会結果で回復を確認します。Google は Play Console の grace period / account hold 設定を確認してから試験し、silent grace period 後にSUBSCRIPTION_IN_GRACE_PERIOD/SUBSCRIPTION_ON_HOLDなどの結果状態が見えることを確認します。
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 実装の確認は通常購入テストとは別に必ず行う
- 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. 運用・監視
課金は「作って終わり」ではありません。
正常時より、失敗時にどう戻すか と 異常をどう早く検知するか が重要です。
補正ジョブや再照合の詳細は 25 章で扱います。
この章では、運用として最低限見える状態にしておくべきもの を整理します。
14-1. 最低限見るべき運用観点
- Apple 通知 / Google RTDN を継続受信できていること
- 受信後の非同期投影が滞留していないこと
- 定期ジョブが予定どおり動いていること
PENDING/IN_GRACE_PERIOD/ON_HOLDが異常に滞留していないこと- CS や運用者が必要時に手動再照合できること
14-2. 監視すべきメトリクス
- 新規購入数
- 復元成功数
PENDING滞留数IN_GRACE_PERIOD滞留数ON_HOLD件数- Apple の通知受信数 / 失敗数
- Google RTDN の通知受信数 / 失敗数
- 未 ack 件数
- 反映遅延時間
- 409 復元エラー件数
- 失敗イベント再処理の滞留数
- 補正ジョブの最終成功時刻
14-3. アラート条件の例
- 未 ack 件数 > 0 が継続
- Webhook 5xx 増加
- RTDN 受信が急減
- 購入は増えているのに Entitlement 有効化が減少
- 返金 / revoke 件数が急増
- 409 復元エラーが急増
- バッチの最終成功時刻がしきい値を超えて古い
processingErrorが一定件数を超えて滞留
14-4. ログに残すべき情報
- provider
- environment
- request id / correlation id
- transactionId / purchaseToken
- originalTransactionId / linkedPurchaseToken
- notificationUUID / messageId
- internal user / billing account
- projection result
- ack result
- raw payload の保存先参照
- どのジョブが再処理したか
- 再試行回数 / 最終エラー
14-5. 管理画面で最低限見たい情報
- 現在の
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 / 履歴へ冪等に投影し、通知と補正ジョブで追従できること。