ドキュメント
メニューを開く

テーマ

このページの内容

ドキュメント

Part 1. 本編

モバイルアプリのアプリ内課金 実装・運用設計ドキュメント の「Part 1. 本編」をまとめたページです。

Part 1. 本編

1. 最初に押さえること

アプリ内課金を初めて実装するときは、細かい API 名より先に、次の 4 点を正しく理解しておくことが重要です。

1-1. ストアが真実の源泉である

Apple と Google の課金状態は、最終的にはストア側の状態が真実の源泉です
自社 DB は、その状態を自社サービスで使いやすい形にした投影結果として扱います。

したがって、クライアントから届いた transactionIdpurchaseToken をそのまま信用してはいけません。
必ずバックエンドからストア API または署名済みデータを使って再確認します。

1-2. 「決済がある」ことと「使わせてよい」ことは別である

課金の世界では、次のような状態が存在します。

  • 購入は存在するが、まだ支払いが完了していない
  • 決済失敗中だが、猶予期間中なので使わせる
  • 自動更新はオフだが、期限までは使わせる
  • 返金済みなので即時停止する

そのため、契約状態利用可否は分けて持ちます。

  • Subscription: ストア上の契約状態
  • Entitlement: 自社サービスの利用可否

1-3. 購入直後のクライアント通知だけに依存してはいけない

購入直後は、クライアントからサーバへ購入情報が送られてくるのが理想です。
しかし現実には、次のようなことが起こります。

  • 購入後にアプリがクラッシュする
  • 通信が切れてサーバ通知に失敗する
  • Webhook が先に届く
  • サーバ更新後にクライアントが結果を受け取れない

そのため、クライアント申告・サーバ通知・定期ジョブの 3 本柱で設計します。

1-4. 初回リリースでは「全部盛り」にしない

初学者が最も迷いやすいのは、価格変更や高度なオファーまで最初から実装しようとすることです。
初回リリースでは、まず次の 5 点だけを成立させます。

  1. 新規購入を正しく反映できる
  2. 更新・解約・返金を追従できる
  3. Google の acknowledgement を漏らさない
  4. 購入復元で誤付与しない
  5. テスト環境で再現できる

この章の一次情報


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 でアプリ作成MUSTIAP 設定の前提
自動更新サブスク作成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 の整理MUSTJWT を作りサーバから API を呼ぶ
appAppleId の整理SHOULDApp Store signed data 検証で使うことがある(特に production 側)
Sandbox テスターの準備MUST実機テスト用
TestFlight 配布の準備SHOULD実運用に近い QA

Apple で初学者が特に迷いやすい点

  • verifyReceipt は現行の主経路ではない
    Apple は verifyReceipt を deprecated としており、サーバ検証では App Store Server APIApple 署名済みの 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 などのテストトラック公開SHOULDQA や配布しやすい形で検証する
ライセンステスター設定MUSTテスト購入を無料で行う
Google Play Developer API 利用設定MUSTサーバ検証と acknowledgement
サービスアカウント / 権限設定MUSTDeveloper API を安全に呼ぶ
Pub/Sub topic + RTDN 設定MUST更新・返金・決済失敗・回復・猶予終了を自動反映する
Pub/Sub subscription 方式の選定(push / pull)MUSTRTDN の受信方式を決める
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 から、subscriptionOfferDetailsPricingPhase.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 を業務ロジックに直結させない
クライアントからサーバへ送る購入証跡の仕様MUSTApple / Google で分ける
クライアントでストア価格を取得して表示できることMUST月額表示やプラン比較の前提にする
クライアントで復元導線を提供できることMUSTApple の AppStore.sync() や Google の既存購入再同期につなげる
トライアル利用歴を判定できる永続的根拠MUSTストアの eligibility 判定だけに依存し切らない(詳細は 11-5)
サーバの冪等キー設計MUST再送や二重送信に耐える
監査ログ保存先MUSTトラブル時の原因調査に使う
ステージング / 本番の資格情報分離MUST誤課金・誤検証防止
「未反映だが購入済み」の UI 文言SHOULD反映遅延を吸収する

この章の一次情報


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/offerIdPlan に対応付ける
  • 購入時に埋め込む識別子を決める
    • Apple: appAccountToken
    • Google: setObfuscatedAccountId / setObfuscatedProfileId

Phase 2. クライアントからサーバへ購入証跡を送れるようにする

まずは購入直後の最短反映を作ります。

  • Apple: transactionId を送る
  • Google: purchaseToken を送る
  • サーバ側エンドポイント
    • POST /billing/apple/ingest
    • POST /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 --> J

5-2. 処理の原則

どの入口から入ってきても、最終的には同じ投影ロジックに流します。

  • クライアント申告
  • Apple 通知
  • Google RTDN
  • 定期ジョブ

これにより、実装が次のようになります。

  • 検証ロジックが一箇所にまとまる
  • 通知と手動再実行で結果がぶれない
  • テストしやすい
  • 冪等化しやすい

5-3. バックエンドの責務

バックエンドの責務は次の 5 つです。

  1. 購入証跡の真正性を確認する
  2. ストア状態を自社 DB に投影する
  3. Google の acknowledgement を期限内に行う
  4. 利用可否を API として返す
  5. 再送・順序逆転・部分失敗を吸収する

5-4. クライアントの責務

クライアントの責務は限定します。

  • 購入開始時に正しい識別子を埋め込む
  • 購入後に証跡をすみやかにサーバへ送る
  • アプリ再起動後に未送信の証跡を再送する
  • 商品価格や offer 文言は StoreKit / Google Play Billing Library から取得して表示する
  • Apple では Restore Purchases 導線、Google では既存購入の再同期導線を用意する
  • 利用可否の最終判断をサーバに委ねる

6. 最小ドメインモデル

初回リリースで最低限必要な概念だけに絞ります。

6-1. Plan

自社の販売プランです。

例:

  • PRO_MONTHLY
  • PRO_YEARLY

ストア固有の ID を直接業務ロジックに埋め込まず、
内部コードと対応付ける形にします。

6-2. StoreProductMapping

各ストアの商品と内部 Plan の対応です。

項目
providerAPPLE / GOOGLE
internalPlanCodePRO_MONTHLY
appleProductIdpro_monthly
googleProductIdpro.monthly
googleBasePlanIdmonthly
googleOfferIdtrial7d

6-3. BillingAccount

課金の帰属先です。
多くのサービスでは User と 1:1 でも構いませんが、将来を考えると別概念にしておくほうが安全です。

6-4. Subscription

ストア上の契約状態です。
最低限、次の状態を内部で扱えるようにします。

  • PENDING
  • TRIALING
  • ACTIVE
  • IN_GRACE_PERIOD
  • ON_HOLD
  • PAUSED
  • CANCELED
  • EXPIRED
  • REVOKED

TRIALING は内部状態です。無料トライアル期間中で entitlement は有効だが、通常課金開始前であることを区別したい場合に用います。不要なら ACTIVE に畳んでも構いませんが、本ドキュメントでは後続章と整合させるため TRIALING を明示的に扱います。
ON_HOLD は内部正規化状態です。Google の account hold を表すために用います。
Apple には同名の公式状態はありません。Apple 側では billing retry や期限切れ関連の事実を保持しつつ、内部で必要な場合に限って ON_HOLD 相当へ正規化する、という整理で扱います。

6-5. Entitlement

自社サービスの利用可否です。

最低限ほしい項目:

  • billingAccountId
  • planCode
  • isActive
  • effectiveFrom
  • effectiveUntil
  • reason

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.obfuscatedExternalAccountIdDeveloper API 側で返るアカウント識別子購入時に埋めた値が返る
messageIdPub/Sub メッセージ IDRTDN 受信の冪等化に使う

注意: クライアント 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

  1. appAccountToken の一致
  2. 既知の originalTransactionId の一致
  3. 既知の transactionId 系列からの到達
  4. 自動紐付けを行わず、手動確認

Google

  1. externalAccountIdentifiers.obfuscatedExternalAccountId の一致
  2. linkedPurchaseToken から既存契約を辿る
  3. outOfAppPurchaseContext.expiredExternalAccountIdentifiers を補助手掛かりとして使う
  4. 自動紐付けを行わず、手動確認

初回実装では、まず externalAccountIdentifiers.obfuscatedExternalAccountIdlinkedPurchaseToken を確実に扱えれば十分です。
outOfAppPurchaseContext は、前回購読がすでに期限切れになった後の再購読でだけ使う補助情報として考えると理解しやすいです。

7-4. やってはいけないこと

  • メールアドレスをそのまま obfuscatedAccountId に入れる
  • appAccountToken を未設定のまま運用開始する
  • 復元時に「見つかった購入を全部いまのログインユーザーに付ける」
  • 1 台の端末だけを根拠にユーザー帰属を決める

この章の一次情報


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名目的MethodPath認証位置づけ
Apple購入情報反映APIApple 購入証跡登録POST/billing/apple/ingestBearer購入直後 / 復元時の入口
Google購入情報反映APIGoogle 購入証跡登録POST/billing/google/ingestBearer購入直後 / 復元時の入口
Apple通知受信APIApple の通知受信POST/webhooks/apple/notifications署名検証通知ドリブン補正
Google通知受信APIGoogle RTDN の通知受信POST/webhooks/google/rtdnPub/Sub JWT 検証push subscription を採る場合の通知ドリブン補正

加えて、クライアントが現在の利用可否を取得できる read model を提供すること は MUST です。
ただし、それは専用の GET /me/entitlement を新設してもよいですし、既存の画面取得 API に entitlement を含める形でも構いません。

8-1-2. SHOULD

初回リリースでも強く推奨するが、業務要件によっては後追い実装も許容できるものです。

API名目的MethodPath認証位置づけ
利用可否取得API現在の利用可否を専用 endpoint として取得するGET/me/entitlementBearerentitlement の再利用性が高い場合の専用 read model
課金状態再照合API課金状態の手動再照合POST/admin/billing/reconcileAdminCS / 運用の救済、障害復旧、手動補正

8-1-3. 条件付きで追加を検討してよいもの

要件によっては、次の endpoint を追加すると運用しやすくなります。

重要度API名目的MethodPath追加を検討する条件
SHOULD契約詳細取得API契約詳細表示用 read modelGET/me/billing/subscription設定画面などで、専用 entitlement endpoint より詳しい契約情報を見せたい
補足-復元専用 endpoint は原則不要--本ドキュメントでは /billing/*/ingestaction=restore を渡して共通化する

8-1-4. 購入証跡の検証(レシート検証)とは

「購入証跡の検証」 は、クライアントから受け取った transactionIdpurchaseToken をもとに、バックエンドからストア 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. サーバ側の基本動作

  1. 認証済みユーザーを確定する
  2. actionpurchaserestore かを確認する
  3. 冪等性チェックを行う
  4. transactionId を使って App Store Server API を呼ぶ
  5. 必要に応じて Get All Subscription Statuses などで補足確認する
    • 主要 endpoint は、保存済みの transaction identifier を path parameter として利用できる
  6. Apple 側識別子と内部ユーザーの紐付け可否を判定する
  7. Subscription / Entitlement / 履歴を更新する
  8. クライアント向けの最終利用可否を返す

8-3-5. この endpoint に持たせるべき責務

  • やること
    • Apple への再照会
    • 内部状態への projection
    • 復元時の紐付け判定
  • やらないこと
    • クライアント申告だけで entitlement を付ける
    • Apple 通知待ちだけで初回反映を完了させる
    • UI 向けの詳細表示情報を過剰に返す

8-3-6. 主な呼び出し場面

場面A. 購入完了直後の最短反映

StoreKit で購入成功後、クライアントは transactionId を取得してこの API を呼びます。
サーバは Apple へ再照会し、購入直後に SubscriptionEntitlement を更新します。
この経路があることで、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 state

8-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 などの追加情報はストア応答から確認できます。

ただし、実運用では次の目的で packageNameproductId任意で受け取る 設計も有効です。

  • 想定外のアプリや商品に対する token を早期に弾く
  • 監査ログにクライアント申告値を残す
  • クライアント実装とサーバ実装の不整合を検知する

したがって、本ドキュメントでは purchaseToken を最小必須packageName / productId追加検証用の任意項目 として扱います。

8-4-5. サーバ側の基本動作

  1. 認証済みユーザーを確定する
  2. actionpurchaserestore かを確認する
  3. 冪等性チェックを行う
  4. purchaseToken と補完済みの packageName を使って purchases.subscriptionsv2.get を呼ぶ
  5. 状態、帰属先、line item を確認する
  6. Subscription / Entitlement / 履歴を更新する
  7. entitlement 付与可能で、かつ new purchase token を伴う未 acknowledgement purchase なら acknowledgement を実行する
  8. クライアント向けの最終利用可否を返す

8-4-6. この endpoint に持たせるべき責務

  • やること
    • Google への再照会
    • pending / active / expired などの内部状態化
    • acknowledgement 要否の判定
  • やらないこと
    • purchaseToken の文字列だけで有効判定する
    • クライアント申告だけで entitlement を付ける
    • renewal ごとに毎回 acknowledgement する

8-4-7. 主な呼び出し場面

場面A. 購入完了直後の最短反映

Billing Library で購入成功後、クライアントは purchaseToken を取得してこの API を呼びます。
サーバは Google API へ再照会し、必要なら acknowledgement まで行ったうえで SubscriptionEntitlement を反映します。

場面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 state

8-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. 受信時の基本動作

  1. Apple 署名を検証する
  2. notificationUUID などで冪等性を担保する
  3. 受信 payload を監査用に保存する
  4. 通知本文をそのまま真実の源泉にせず、必要な識別子を抽出する
  5. App Store Server API を再照会し、最新状態へ projection する
  6. すぐ返答し、重い処理は非同期化する

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
    end

8-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. 受信時の基本動作

  1. Pub/Sub push の JWT を検証する
  2. envelope と message.data を復号・解析する
  3. event id や publish 時刻などで冪等性を担保する
  4. 受信データを監査用に保存する
  5. 通知だけで確定せず、purchaseToken を使って Google API へ再照会する
  6. 最新状態へ projection する
  7. すぐ返答し、重い処理は非同期化する

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
    end

8-6-7. pull 方式を採る場合の読み替え

pull subscription を採る場合は、上図の POST /webhooks/google/rtdnsubscriber 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 判定ロジックを画面ごとに分散させない
  • isActivereason などの意味を 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 を呼んで isActiveeffectiveUntil を取得し、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 UI

8-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 screen

8-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. サーバ側の基本動作

  1. 管理権限を確認する
  2. 入力キーに応じてストア再照会を行う
  3. 現在の内部状態との差分を確認する
  4. 必要なら projection を再実行する
  5. 監査ログを残す

8-8-5. この endpoint の注意点

  • 直接 DB を書き換える入口にしない
  • 手動付与 API と混同しない
  • 誰が何を再照合したかを必ず残す

8-8-6. 主な呼び出し場面

場面A. CS 問い合わせ時の状態再確認

ユーザーから「購入済みなのに使えない」「解約したのに使えてしまう」といった問い合わせが来た場合、運用者は対象の transactionIdpurchaseToken を指定してこの 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 summary

8-9. 追加を検討してよい endpoint

8-9-1. GET /me/billing/subscription

API 名: 契約詳細取得API

設定画面や契約詳細画面で、既存の画面 API や GET /me/entitlement より多い情報を返したい場合に追加を検討します。

返却候補:

  • planCode
  • store
  • status
  • willRenew
  • effectiveUntil
  • inTrial
  • inGracePeriod

これは 利用可否判定の中核 ではなく、表示や説明責務のための 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 screen

8-9-2. 復元専用 endpoint を増やすべきか

本ドキュメントでは、復元は /billing/*/ingestaction=restore を渡して扱います。
そのため、初回リリースで POST /billing/apple/restorePOST /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 APIApple 署名済み 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 result

9-4. 通知フロー

Apple の通知は V2 を前提にします。
V2 の通知ボディは signedPayload で届き、JWS を verify and decode したうえで扱う必要があります。
data.signedTransactionInfodata.signedRenewalInfo も同様に、デコード済み JSON をそのまま信用せず、JWS として検証・デコードします。
Apple は App Store Server Library を提供しており、初回実装ではこれを使って検証処理を組むと安全です。

受信時の基本手順

  1. signedPayload を JWS として verify and decode する
  2. notificationUUID で冪等化する
  3. signedTransactionInfo / signedRenewalInfo も必要に応じて verify and decode する
  4. 必要に応じて App Store Server API へ再照会する
  5. 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 Notification
  • Get 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 で最低限保持したい識別子

  • transactionId
  • originalTransactionId
  • appAccountToken
  • webOrderLineItemId(必要に応じて)
  • environment

9-10. Apple の初回リリースでの実践的な結論

4 章の方針に沿い、初回リリースでは次を成立させます。

  1. 購入時に appAccountToken を設定する
  2. クライアントから transactionId を送る
  3. サーバで Get Transaction Info を呼ぶ
  4. 必要に応じて Get All Subscription Statuses で補う
  5. 通知は V2 を受ける
  6. 通知欠落時は Get Notification History で補正する

この章の一次情報


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 result

10-4. RTDN の理解

RTDN は「完全な purchase state」ではありません。
通知には、主に次のようなものが入ります。

  • packageName
  • eventTimeMillis
  • subscriptionNotification.notificationType
  • purchaseToken

ここに完全な状態は入っていません。
したがって、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 が必要です。
実装では、典型的には subscriptionStateSUBSCRIPTION_STATE_ACTIVE などの付与可能状態で、かつ acknowledgementStateACKNOWLEDGEMENT_STATE_PENDING の purchase を ack 対象として扱います。
PENDING の間は acknowledge してはいけません。
Google Play の 3 日ルールは、保留中の購入が最終的に課金成立した時点で開始します。
怠ると、Google Play は自動返金・取り消しを行います。

更新

renewal は acknowledgement 不要です。

実行条件

Google では、purchases.subscriptionsv2.get の結果で entitlement 付与可能な subscriptionState であり、かつその purchase の acknowledgementStateACKNOWLEDGEMENT_STATE_PENDING の場合に acknowledgement を行います。

実装上の原則

  • entitlement 付与と acknowledgement は同じユースケース内で扱う
  • acknowledgement は entitlement 付与後に行う
  • ただし片方だけ失敗する可能性を前提に再試行手段を持つ
  • 未 ack を検知するジョブを持つ

10-7. 支払い問題と猶予期間

Google では、状態遷移を次のように扱います。

  • SUBSCRIPTION_STATE_ACTIVE
  • SUBSCRIPTION_STATE_IN_GRACE_PERIOD
  • account hold 相当
  • SUBSCRIPTION_STATE_EXPIRED

一次情報では、grace period 中は entitlement を維持すべきとされています。
一方で account hold に入った後は、利用を止める前提で設計します。

加えて、現行仕様では grace period は default enabledaccount 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 点を成立させます。

  1. 購入時に setObfuscatedAccountId を設定する
  2. クライアントから purchaseToken を送る
  3. サーバで purchases.subscriptionsv2.get を呼ぶ
  4. new purchase token を伴う未 acknowledgement purchase なら acknowledgement する
  5. RTDN を受ける
  6. RTDN 受信後にも purchases.subscriptionsv2.get を呼ぶ

この章の一次情報


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 PENDINGfalse決済未確定
Google grace periodtrue一次情報に沿う
account holdfalse支払い未回復
自動更新オフだが期限前trueまだ契約期間内
返金 / revokefalse直ちに停止
Apple Billing Grace Period 中true一次情報に沿って利用継続とする
Apple billing retry(grace ではない)false初回リリースでは保守的に停止する

11-3. 返金の考え方

返金は「将来更新しない」ではなく、
すでに付与した権利を止めるべきイベントです。

そのため、CANCELEDREVOKED は分けます。

  • 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 層で考えるのが安全です。

  1. 購入 UI での eligibility 判定
    ストアが真実の源泉です。Apple では Product.SubscriptionInfo.isEligibleForIntroOffer を使えます。Google では Play 管理 eligibility、または app 実行時 eligibility を使い、購入可能な offer を ProductDetails から選びます。
    これは UI 上でどの offer を見せるかの判断材料 です。購入可否や entitlement の最終判断そのものを、自社だけで確定するための値ではありません。
  2. 自社要件としての履歴管理
    CS、分析、A/B テスト、既存会員向け文言分岐のために、自社 DB に hasStartedTrialAtLeastOncefirstTrialStartedAt を持つ価値があります。

実装上は、初めて無料トライアル付き購読を正しく検証できた時点で、自社側の trial 利用履歴を true にします。
ただし、購入可否の最終判断は常にストア側を優先してください。自社 DB が false でも、ストアが non-eligible と判定したら trial は出してはいけません。

11-6. 月額をどこから取得するか

課金画面に表示する価格は、クライアントがストア SDK から取得するのを基本にします。
バックエンド DB の PlanStoreProductMapping は商品対応表であり、ローカライズ済み表示価格の真実の源泉にはしません。

  • Apple: StoreKit の Product.displayPrice
  • Google: queryProductDetailsAsync() で取得した ProductDetailssubscriptionOfferDetails / 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/*/ingestaction=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. 実課金は発生するか

通常の実装・動作確認では、次の環境を使います。

ストアテスト環境実課金備考
AppleSandboxなしSandbox テスターで購入しても実際の決済は行われない
AppleStoreKit Testing in Xcodeなしローカル完結。ストアサーバ疎通の確認には使えない
Googleライセンステスターなしテスト用支払い方法で確認できる
GooglePlay Billing Lab で real payment を有効化した場合あり通常の実装確認では使わない

したがって、通常の実装段階では Apple SandboxGoogle ライセンステスター を前提に動作確認します。

13-1-2. 購入証跡の検証エンドポイントの動作確認

まず確認したいのは、/billing/apple/ingest/billing/google/ingest が、実際の Sandbox / ライセンステスター由来の証跡で正しく動くことです。

Apple の場合

  1. Sandbox テスターでアプリから購入する
  2. クライアントが受け取った transactionId を控える
  3. /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 の場合

  1. ライセンステスターでアプリから購入する
  2. クライアントが受け取った purchaseToken を控える
  3. /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 NotificationGet 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 手順

  1. Sandbox テスターを準備する
  2. 実機または TestFlight で商品取得を確認する
  3. 購入時に appAccountToken を渡す
  4. 購入する
  5. transactionId/billing/apple/ingest へ送る
  6. Subscription / Entitlement が更新されることを確認する
  7. 通知を受け取れることを確認する
  8. 復元を実行し、二重付与されないことを確認する

Apple テストで覚えておくべきこと

  • Sandbox では本番課金は発生しない
  • TestFlight の IAP も sandbox で動く
  • Sandbox アカウントの subscription renewal rate によって、更新間隔だけでなく Billing RetryBilling Grace Period の長さも変わる
  • そのため、支払い失敗や猶予期間を試すときは、Sandbox Apple Account の設定を先に確認する
  • TestFlight ではサブスク更新は加速され、各期間のサブスクは日次で最大 6 回更新される
  • 通知疎通は Request a Test NotificationGet Test Notification Status で確認できる

13-4. Google の最小 E2E 手順

  1. ライセンステスターを設定する
  2. 必要に応じて internal / closed などのテストトラックへ出す
  3. 商品が publish 済みであることを確認する
  4. 購入時に setObfuscatedAccountId を設定する
  5. 購入する
  6. purchaseToken/billing/google/ingest へ送る
  7. purchases.subscriptionsv2.get の結果で反映されることを確認する
  8. acknowledgement が成功することを確認する
  9. 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テスト用stagingproduction
Google API 資格情報テスト用プロジェクトstagingproduction
RTDN topic / push sub分離分離分離
署名検証バイパス原則禁止原則禁止禁止
テスト購入判定の監査ログ有効有効有効

13-7. 単体テストで固定すべきもの

初回リリースで最も価値が高い単体テストは次です。

  • ストア状態 → internal state の変換
  • internal state → entitlement の変換
  • 同一イベント再処理時の冪等性
  • linked token / original transaction の紐付け分岐
  • 復元時の 409 / 手動確認分岐

この章の一次情報


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

  • ストアを真実の源泉とする
  • SubscriptionEntitlement を分ける
  • 履歴テーブルを持つ
  • 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 / 履歴へ冪等に投影し、通知と補正ジョブで追従できること。