Open8

【iOS】サブスク実装調査メモ(Storekit 2, Server Notifications V2)

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

公式動画による全体像の把握

初回のサブスクリプション時(動画の21:25-)


下2つの矢印は任意と思われる

  • iOSアプリは、App Store Serverから受け取ったSigned transaction infoについて
    • 端末上でverifyしてoriginalTransactionIdとその他ほしい値をサーバーに送信する、または、
    • Signed transaction infoとして自社サーバーに送信し、サーバーでverifyし、必要な値をDBに保存する、のでもよい。
  • Server notificationの中のsigned transaction infoがapp account tokenを含んでいるので、in-app userと紐づけることができる
  • いつでも、/inApps/v1/subscriptionsのAPIにoriginalTransactionIdを送信して、情報を確認できる状態になる。

renewに成功した時


下2つの矢印は任意と思われる

  • notificationのpayloadの中の、signed transaction infoとsigned renewal infoを見て、次のサブスクのrenewal dateや、顧客の次のrenewelのrenewal preferencesについて確認することができる。

renewに失敗し、grace period(猶予期間)を終えても失敗した時

  • grace periodを有効化するかを選べるかも?

renewに失敗し、grace periodに復活した場合

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Server Notifications V2

Receivingについて

  • https://developer.apple.com/documentation/appstoreservernotifications/receiving_app_store_server_notifications
  • iapについて、HTTP POSTを使ってJSONオブジェクトをあなたのサーバーに送信するよ。
  • POSTのbodyには、responseBodyV2のデータが入っている。
    • responseBodyV2には、JWS形式で署名されたsignedPayload(The signedPayload is a string of three Base64 URL-encoded components, separated by a period.)が入っている
    • 読み取り方法は詳しくはこちら
    • モンストの記事も参考になる
    • Apple PKIからG3 Root証明書を取得して使う。
    • Apple側が秘密鍵で署名しているので、読み取る側は公開鍵を使えばOK(悪意のある人はAppleの秘密鍵を持っていないので大丈夫)
    • ではSubscriptionKey_xxxx.p8はいつ使うの?→App Store Server APIを叩く際には、SubscriptionKey_xxxxx.p8を使い、リクエストの作成・レスポンスの読み取りを行う。今回はサーバー通知の読み取りだから公開鍵を使えばOK。
  • signedPayloadを読み取ると、responseBodyV2DecodedPayloadになる。
    • notificationTypesubtypedataを含む。
    • dataの中に、signedTransactionInfosignedRenewalInfoがある。これらJWS表記であり、デコードする必要がある。
    • daraのAPI doc

      公式docのスクショ

      動画のスクショ(一部プロパティが省略されている)
  • サーバー通知を保存するときは、notificationUUIDでuniqueとなるように制御する
    • サーバー通知に対して200を返さなかった場合は何度もリトライしてくれる。そのたびにレコードが重複して作成されるのを防ぐため。

Respondingについて

  • https://developer.apple.com/documentation/appstoreservernotifications/responding_to_app_store_server_notifications
  • AppleからServer Notificationを受け取ったら、自社サーバーも200または50x, 40xのステータスコードを返すこと。
    • dataを返す必要はない
  • 最初の通知の際に200を返さなかったら、Appleは5回リトライしてくれる(本番環境だけね)
    • it retries five times, at 1, 12, 24, 48, and 72 hours after the previous attempt.

  • 自分でApp Store Server APIを叩くなどで解決することもできる。
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

JWSTransactionDecodedPayload

  • サーバー通知から受け取ったdataの中の、signedTransactionInfoをdecodeしたもの。この中に含まれている情報の中に、サブスクリプションを管理する上で重要な情報が含まれている。
  • 公式リファレンス

重要そうなプロパティを抜粋

名前 公式の解説
appAccountToken A UUID you create at the time of purchase that associates the transaction with a customer on your own service.
expiresDate The UNIX time, in milliseconds, that the subscription expires or renews.
isUpgraded A Boolean value that indicates whether the customer upgraded to another subscription.
originalPurchaseDate The UNIX time, in milliseconds, that represents the purchase date of the original transaction identifier.
originalTransactionId The transaction identifier of the original purchase.
productId
purchaseDate The UNIX time, in milliseconds, that the App Store charged the user’s account for a purchase, restored product, subscription, or subscription renewal after a lapse.
subscriptionGroupIdentifier
transactionId
transactionReason The reason for the purchase transaction, which indicates whether it’s a customer’s purchase or a renewal for an auto-renewable subscription that the system initiates.
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

  • App Storeから「一度契約して終了したサブスクリプション」を再度契約したとき、originalTransactionIdは過去のものと同じになる?
  • App Storeからのプラン変更は、
    • 現在有効なサブスクリプションがある場合に、変更することができる?
    • 現在有効なサブスクリプションがないが昔契約していたサービスに対して、別のプランで契約することができる?

ChatGPTの回答べたばり

  • 一度終了したサブスクをストアアプリから再度契約する時
  • Apple (App Store): originalTransactionIdは変わりません。ユーザーが同じサブスクリプションを再購入しても、このIDは一貫して同じままです。
  • Google (Play Store): purchase tokenは変わります。サブスクリプションを再購入するたびに新しいトークンが生成されます。
  • 有効なサブスクリプションが自動更新された時
  • Apple (App Store): originalTransactionIdは、サブスクリプションが自動更新されても変わりません。
  • Google (Play Store): purchase tokenも、サブスクリプションが自動更新されても変わりません。


だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Server Notifications V2のnotificationTypeごとのハンドリング

  • 公式docに、ハンドリングの例が書かれている。subtypeと合わせて検証する模様。
  • 以下、自分なりの解釈を抜粋する。
    • OfferやFamily Sharing、subscriptionのprice increaseは今回の調査には含めない。
    • 表の--以下はちょっとマイナーなユースケースとして想定

状況とNotificationType / subtype

購読が成功した時

状況 NotificationType subtype
最初に購読した時 SUBSCRIBED INITIAL_BUY
自動更新に成功した時 DID_RENEW
-- -- --
失効したサブスクグループの中のいずれかを再購読した時 SUBSCRIBED RESUBSCRIBE

顧客がサブスクリプションの内容を変更した時

状況 NotificationType subtype
App Storeから購読を解約した時 DID_CHANGE_RENEWAL_STATUS AUTO_RENEW_DISABLED
購読の解約をキャンセルした時(自動更新されるように設定し直した時) DID_CHANGE_RENEWAL_STATUS AUTO_RENEW_ENABLED
-- -- --
同じサブスクグループでdown/upgrdeした時 DID_CHANGE_RENEWAL_PREF DOWNGRADE / UPGRADE
ダウングレードをキャンセルし、元の購読に戻る時 DID_CHANGE_RENEWAL_PREF

失効や更新失敗系

状況 NotificationType subtype
顧客が解約を選択して、expireしたとき EXPIRED VOLUNTARY
billing retry期間が回復せずに終了してexpireしたとき EXPIRED BILLING_RETRY
更新失敗し、billing retry periodに入った時 DID_FAIL_TO_RENEW
更新失敗し、billing retry periodに入った時 with Billing Grade Period enabled DID_FAIL_TO_RENEW GRACE_PERIOD
billing retrが成功した時 DID_RENEW BILLING_RECOVERY
Billing Grace Periodを抜けたとき(billing retryに続く) GRACE_PERIOD_EXPIRED
-- -- --
開発者がサブスクを削除してexpireしたとき EXPIRED PRODUCT_NOT_FOR_SALE

refunds(払い戻し)系

状況 NotificationType subtype
Appleがrefundsするとき REFUND
顧客からの異議申し立てで、Appleがrefundsを取り消す時 REFUND_REVERSED
appからrequest refund APIを通して行われた、顧客からのrefundを、Appleが断る時 REFUND_DECLINED
顧客からのrefund要求のために、Appleがcunsumption informationを要求する時 CONSUMPTION_REQUEST

developerがサブスクの更新期日を延長する系

状況 NotificationType subtype
特定のサブスクの更新日の延長が成功した時 RENEWAL_EXTENDED
認められたサブスクライバーsに対して、更新日の延長が成功した時 RENEWAL_EXTENSION SUMMARY
特定のサブスクライバーに対しての更新日の延長が失敗した時 RENEWAL_EXTENSION FAILURE

NotificationType別でハンドリング整理

  • ハンドリングが必要なNotificationTypeについてのみ整理している。ハンドリング補足に何も書かれていない場合でもDB側に変更を加える必要はある。
NotificationType ハンドリング補足
SUBSCRIBED subtypeを確認(INITIAL_BUY / RESUBSCRIBE
DID_RENEW
DID_CHANGE_RENEWAL_STATUS subtypeを確認(AUTO_RENEW_DISABLED / AUTO_RENEW_ENABLED
EXPIRED subtypeを確認(VOLUNTARY / BILLING_RETRY / PRODUCT_NOT_FOR_SALE