iOS/Androidアプリでのサービス構築にあたって、定期購読の構築はビジネス継続の核心である。
iOS/Androidで定期購読の構築は異なる部分が多過ぎるため、同一APIで扱うことはできないし、推奨しない。
iOS用API、Android用APIと分けて構築することを推奨する。
公式ドキュメント
最初に読むべきドキュメントはこちら。
iOS | Android | |
アプリ側 | StoreKit | Google Play Billing Library |
サーバー側 | App Store Server API App Store Receipts | Google Play Developer API |
概念・用語の比較
概念 | iOS-App Store | Android-Google Play |
定期購読の1つの購読 | original_transaction_id | 購入トークン(purchase_token) |
定期購読の1つの購読内での1つの決済 | transaction_id | オーダー ID(order_id) |
Androidの購入トークンとオーダーIDの違い
定期購入の場合、初回購入時に購入トークンとオーダー ID が作成されます。自動更新のたびに、購入トークンは同じままで新しいオーダー ID が発行されます。アップグレード、ダウングレード、交換、再登録が発生したときは、新しい購入トークンとオーダー ID が作成されます。
購入トークンとオーダー ID
定期購入の更新のオーダー番号には、何回目の更新かを表す整数が追加されます。たとえば、初回の定期購入のオーダー ID が
購入トークンとオーダー IDGPA.1234-5678-9012-34567
である場合、その後のオーダー ID はGPA.1234-5678-9012-34567..0
(1 回目の更新)、GPA.1234-5678-9012-34567..1
(2 回目の更新)などになります。
定期購入についてバックエンドがやること
定期購入についてバックエンドがやることは、大きく2つである。
- 初回決済時のレシートの正当性を検証し、サービスを提供すること。
- 定期購入のステータス(継続/解約/返金)を追跡し、サービス提供の継続を判断すること。
バックエンドでは上記シーケンス図のAPIサーバーを構築する。
定期購入のステータス
バックエンドは定期購読のステータスを定期的に確認して、アプリと同期を取る必要がある。
Android-Google Playの定期購入のステータス
Androidでは、Purchases.subscriptionsv2:getのレスポンスにステータスが含まれる。
ステータス サブスクリプションの状態 | 意味 | サービスを提供するか (判断の一例) |
アクティブ SUBSCRIPTION_STATE_ACTIVE | 定期購入が有効である。 | 有効期限までは提供する。 |
キャンセル済みSUBSCRIPTION_STATE_CANCELED | ユーザーが定期購読をキャンセルした。次回継続はしないが、期限切れになるまで定期購読は有効である。 | 有効期限までは提供する。 期限終了後はユーザーは継続の意思がないため、提供しない。 |
猶予期間中SUBSCRIPTION_STATE_IN_GRACE_PERIOD | 支払いに関する問題が発生した。問題が解決するまで、定期購読は有効である。 | 有効期限までは提供する。 期限終了後はユーザーは継続の意思があるため、猶予期間まで提供する。 |
保留中 SUBSCRIPTION_STATE_ON_HOLD | 支払いに関する問題が発生した。問題が解決するまで、定期購読は無効である。 | 有効期限までは提供する。 期限終了後はユーザーは継続の意思があるが、Googleが定期購読無効と判断しているため、提供も中止 or 終了する。 |
一時停止中 SUBSCRIPTION_STATE_PAUSED | 定期購入の利用を一時停止し、再開するまで利用できなくなりました。 | 有効期限までは提供する。 期間終了後は、ユーザーは定期購入の一時停止の意思を示しているため、再開するまでは提供しないのが妥当。 |
期限切れ SUBSCRIPTION_STATE_EXPIRED | ユーザーが定期購読をキャンセルした。その後、有効期限が切れた。 | 提供しない。 |
問題は「猶予期間」「保留中」の扱いである。
次回分の決済が出来てないが、ユーザーは継続の意思を示しているからである。しかし何らかの理由で決済が完了できていない。
(登録しているクレジットカードの有効期限切れなどの理由で)
・「猶予期間」は有無を設定することができる。(Google Play Consoleにて)
・「一時停止」も設定可否を設定することができる。(Google Play Consoleにて)
基本的には、1ヶ月ごとの定期購入だとすると、
決済できた日から、1ヶ月間が有効になる(定期購読期間に入る)。
1ヶ月後に、2ヶ月目の決済が行われる。
決済成功すれば、そのまま2ヶ月目の定期購読期間に入る。
決済失敗すれば、猶予期間を設定している場合、猶予期間に入る。
その間は、定期購入しているものとして扱う。(サービス提供できる)
決済失敗すれば、猶予期間を設定していない場合、2ヶ月目からは保留中という扱いになる。
この間は、定期購入していない扱いになる。(サービス提供できない)
保留中ステータスが帰ってくるのは最大30日である。
この辺は何度も、
・定期購入を販売する(薄青の注記に大事なことが書かれています!)
・
を読まないとなかなか理解できない内容である。
(1週間、毎日眺めていると嫌でもわかってくる)
iOS-AppStoreの定期購読のステータス
定期購読ステータスの取得方法
iOSでは、Get All Subscription Statusesのレスポンスにステータスが含まれる。
ステータス値_定義 | 意味 | サービスを提供するか (判断の一例) |
1_active | 定期購読が有効である。 | 有効期限までは提供する。 |
2_expired | 定期購読が期限切れである。 | 期限切れなので提供しない。 |
3_in a billing retry period | 定期購読は請求の再試行期間中である。 | 決済できていないため、提供しない。 |
4_in a billing grace period | 請求猶予期間中である。 | 決済できていないが、猶予期間であるため、提供する。 |
5_revoked | 定期購読は解約または無効になった。 | 何らかの理由で取り消されたため提供しない。 |
AppleもGoogle同様、猶予期間を設けるか否かは販売者が設定できる。
関連動画 Manage in-app purchases on your server
猶予期間と保留中について
Reducing Involuntary Subscriber Churn を参照のこと。
定期購読の次回請求時に決済が失敗すると、
・猶予期間を設定している場合は、猶予期間。
・猶予期間(billing grace period)を設定していない場合は、保留中(billing retry period)になる。
猶予期間を有効にすると、購読期間に応じて次の猶予期間が設けられる。
・毎週のサブスクリプションの場合は 6 日間
・1 か月およびそれ以上のサブスクリプションの場合は 16 日
猶予期間内に決済が終わらない場合は、保留中となる。
保留期間は最大60日間である。
(60日に猶予期間含む。猶予期間が6日だとすると、保留期間は最大54日となる)
猶予期間と保留中の違いは、AppStoreは猶予期間は有料サービスを提供可とみなし、保留中は提供不可とみなしていることである。
また、猶予期間中に決済成功した場合、次回請求日は変わらない。
しかし、保留中に決済成功した場合、次回請求日は決済成功日を元に起算される違いがある。
こちらの動画も参考になります。
https://developer.apple.com/videos/play/wwdc2018/705/
ステータス遷移の判断方法
ユーザーの定期購読状態を追跡するにはApp Store Server Notificationsを利用しましょう。AppleもApp Store Server Notificationsの使用を推奨しているため、それ一択になります。
App Store Server Notificationsはスマホゲームのモンスターストライクでも使用されているようで、考慮事項はあるものの、実用レベルと考えます。
何らかの制約で、ステータスを日々追いかけないといけないとすれば、次のように考えます。
まず、Get All Subscription Statusesのレスポンスにステータスが含まれるので、そのステータスから、現在の定期購読状態を判断するのが第一です。
ただし、それによってわかるのは現在のステータスであって、ステータスがどう変化したか?は分かりませんので、original_transaction_idごとのステータスを日々DBに記録していく必要があります。
変更前ステータス | 変更後ステータス (現在のステータス) | 分類 | 判断 |
1有効 | 1有効 かつ transaction_idが変わった | 決済 | 決済が行われ、新しい購読期間になった。 |
1有効 | 2期限切れ | 期限切れ | 自動更新OFFのまま定期購読期間が過ぎた。 |
1有効 | 4猶予期間 | 猶予期間 | 自動更新ONで次回請求日が来たが、決済に失敗した。 |
4猶予期間 | 1有効 | 決済 | 猶予期間中にユーザーの決済情報が更新され、決済に成功した。新しい購読期間になった。 |
4猶予期間 | 3保留中 | 保留中 | 自動更新ONのまま猶予期間が過ぎた。 |
3保留中 | 1有効 | 決済 | 保留中にユーザーの決済情報が更新され、決済に成功した。新しい購読期間になった。 |
ステータス判断には、初心者でもわかるiOSサブスク課金のサーバ側の実装!App Store Server Notifications Version 2(StoreKit 2)のJWS検証と判定方法を解説!も参考にさせていただきました。
確認のためにGet All Subscription Statusesを使うのは良いと思うのですが、そのステータスをシンプルに今のユーザーの状況に反映するだけで良いと思っています。
前のステータスを判断する必要もなくて、status=1有効/4猶予期間であれば有効、それ以外は無効とすれば良いだけですね。
返金された場合のステータス、レシートは?
定期購読の更新のための決済はいつ行われるか?
サブスクリプションの更新プロセスは、有効期限の 10 日前に開始されます。この 10 日間、App Store は、サブスクリプションの自動更新を遅らせたり妨げたりする可能性のある請求の問題をチェックします。
サブスクリプションの有効期限が切れる前の 24 時間の間に、App Store はサブスクリプションの自動更新を試み始めます。
サブスクリプションの請求の処理
サーバー実装時の注意点
サーバーで定期購読期間に応じた有料サービスの利用可能期間(付与期間)を管理することがあるかもしれません。
その場合、次のことを知っておきましょう。
アプリは、自動更新サブスクリプションがアクティブな期間に基づいて、ユーザーがアクセスできるコンテンツを判断する必要があります。
サブスクリプションの請求の処理
API呼び出し回数の制限・割り当て
GooglePlayの割り当て
API | 割り当て |
Google Play Developer API | デフォルトで 1 日あたり 200,000 件。 |
Voided Purchases API | 1 日につき 6,000 クエリ(1 日の開始(終了)は太平洋標準時の午前 0 時) 30 秒間に 30 クエリ。 |
AppStoreの割り当て
API | 割り当て |
App Store Connect API | 1 時間あたり 3500 リクエスト。 レスポンスに「user-hour-lim:3500」このように記述される。 |
App Store Server API | 記述なし。 |
Googleはリクエスト数拡大の請求もできるが、
基本的にはこれらの割り当てを超えないようにバックエンドを設計する必要がある。
リリース時には割り当てが超えないか監視も必要になる。
定期購入の検証方法
iOSのレシート検証
1.アプリはこのレシート情報をバックエンドに送信する。
2.バックエンドはレシート情報が正当なものか、Google Playに照会する。
POST https://buy.itunes.apple.com/verifyReceipt
リクエストボディはJSON形式で。
{
receipt-data: "asdfasdfasgfasdf(The Base64-encoded receipt data.)",
password: "Your app’s shared secret",
exclude-old-transactions: true / false
}
レスポンス
{
"status": 0, //リクエストの結果を表すコード値
"receipt": {
"bundle_id": "jp.example.app", //アプリのID
"in_app": [
{
"transaction_id": "1111111111111111", //その1回の購入を一意に識別するためのID
"product_id": "jp.example.app.subscription", //購入したアイテムのID
"purchase_date": "2018-12-11 08:08:50 Etc/GMT", //購入日時(※)
"expires_date": "2018-12-11 08:08:55 Etc/GMT" //有効期限
},
]
},
"latest_receipt_info": [
{
"product_id": "",
"transaction_id": "",
"purchase_date": "",
"expires_date": ""
},
],
"latest_receipt": "MII...", //Base64エンコードされたレシートデータ。省略しているが、実際は10KB前後の文字列
"pending_renewal_info": [
{
"auto_renew_status": "1", //今の購読が有効期限を迎えた際、自動更新されるか否かを表す値
"is_in_billing_retry_period": "1" //更新に失敗している購読で、ストアがなお更新を試みているかを表す値
}
]
}
- 以下の点を検証する
- status = 0 であること
- receipt.bundle_id が自身のアプリのIDであること
- latest_receipt_infoの最新のレシートが有効期間内であること
これらを満たしていれば、ユーザーは決済すみと判断して、サブスク商品の役務提供を行う。
3.バックエンドはレシート情報をDB等に保存しておく。その情報は定期購入の追跡で使用する。
Androidのレシート検証
アプリが購入時に受け取るレシートは以下の内容。
{
"orderId":"GPA.1111-1111-1111-11111",
"packageName":"jp.example.app", //アプリを一意に特定できる名前
"productId":"example", //購入したアイテムのID
"purchaseTime":1544515730000,
"purchaseState":0,
"purchaseToken":"xxx...", //ストアのAPIを利用するためのトークン
"autoRenewing":true
}
1.アプリはこのレシート情報をバックエンドに送信する。
2.バックエンドはレシート情報が正当なものか、Google Playに照会する。
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
{token} はpurchaseTokenに相当します。
3.バックエンドはレシート情報をDB等に保存しておく。その情報は定期購入の追跡で使用する。
定期購入の追跡方法
ユーザーが定期購入を継続して決済したか、返金(払い戻し)したかなど、初回決済後も定期的に定期購読の状態を追跡する必要があります。
タイミング(NG)
いつ?定期購読の状態を確認するかは、いくつか考え方があります。
- ユーザーがアプリを使用したことをサーバー側で検知して確認する。
- サーバー側で定期的に(1日1回、1週間に1回など)バッチなどで確認する。
ざっくりと確認するのであれば、
バッチで各ユーザーが定期購読期間が終了したら確認する。をベースに、1の方法を組み合わせるのが良いと思います。
1の方法だけでは、非アクティブなユーザーがどうなったか追跡する機会がないからです。
2の方法は確認頻度が短くなるほど、リアルタイム性が向上しますが、確認結果が定期購読が有効のままであるという当たり前の結果のユーザーが増えるでしょう。頻度が短くなるほど、経費もかかります。
この記事では触れていませんが、定期購読の状態を追跡するのに、
・Android...リアルタイム デベロッパー通知
・iOS...App Store Server Notifications
を使用することを検討してください。
私がプロジェクトで使用しなかったため、調査対象外となっているだけです。
この方法はNGみたいです!マネしないでください。
https://qiita.com/ckm/items/b8cf23ba4bd0ae5bbf34#b-%E8%87%AA%E5%8B%95%E6%9B%B4%E6%96%B0%E3%81%AE%E7%A2%BA%E8%AA%8D
こちらの記事に、
・全ての定期購読を毎日照会するようなことはせず、更新が必要な分のみを問い合わせること
とあるからです。
・定期購入のステータスを確認するために、API を定期的にポーリングしないでください。たとえば、API を毎日呼び出して、個々の定期購入を確認することはお避けください。
・定期購入が期限切れになるか更新されると RTDN が届くため、有効期限に基づいて API 呼び出しのスケジュールを設定する必要はありません。
おすすめの方法
タイミング(OK)
Android
購入ステータスが変更されたことを通知してくれる、リアルタイム デベロッパー通知をバックエンドで受信し、Google Play Developer API を呼び出し、Method: purchases.subscriptionsv2.getより完全なステータスを取得してバックエンドに反映する。
そうしないと、API の割り当て制限を受ける可能性があるとのこと。
むやみに全ユーザーを毎日、purchases.subscriptionsv2.getを呼んでいると、いざという時に(初回購入時など他のユーザーに必要な時に)APIの呼び出しができなくなるリスクがあります。
iOS
アプリ内購入と払い戻しのステータスが変化したことを通知してくれる、App Store Server Notificationsから通知をバックエンドで受信し、Get All Subscription Statusesより完全なステータスを取得してバックエンドに反映する。
Android-Google Playの定期購読追跡方法
アプリで定期購入のステータスを確認するには、Google Play Billing Library の
定期購入ライフサイクルの処理BillingClient.queryPurchasesAsync()
または Google Play Developer API のPurchases.subscriptionsv2:get
を使用して照会します。
上記の通り、Google Play Developer API の Purchases.subscriptionsv2:get
を使用してユーザーの定期購読状況を追跡します。
(レシート検証で使用したのと同じAPIです)
Purchases.subscriptionsv2:get
の仕様・用法
Method: purchases.subscriptionsv2.get を参照のこと。
リクエスト
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
{token} string 必須。定期購入の購入時にユーザーのデバイスに提供されるトークン。
purchase_tokenのことと思われる。ユーザーから受け取り、バックエンドでDB等に保管しておく必要がある。
レスポンス
JSONデータ。重要箇所のみピックアップする。
フィールド | 説明 |
latestOrderId | 定期購入の購入に関連付けられた最新の注文のオーダー ID。定期購入の自動更新の場合、まだ更新されていない場合の登録注文のオーダー ID、または直近の注文の ID |
subscriptionState | 定期購入の現在の状態。 ( SubscriptionState を参照) |
lineItems[] | 定期購入するアイテム情報。 |
定期購読が更新されていたらどうなるか?どう判断するか?
- 戻り値の項目orderIdがサーバ未登録である
- expiryTimeMillisがリクエスト前の有効期限より将来の値である
で判断します。
ユーザーが定期購入を自動更新に設定しているかどうか?
lineItemsのautoRenewingPlanで確認します。
{
"productId": string,
"expiryTime": string,
"autoRenewingPlan": {
"autoRenewEnabled": boolean,
"priceChangeDetails": {
object (SubscriptionItemPriceChangeDetails)
}
},
"prepaidPlan": {}
"offerDetails": {}
}
autoRenewingPlan -> autoRenewEnabled が trueなら自動更新ONです。
ユーザーの決済金額はどう取得するか?
ユーザーが実際に支払った金額は取得できません。(どのAPIでも提供されていない)
ですが、ユーザーが支払ったと実質的に見做せる金額を取得する方法は2つあります。
Purchases.subscriptionsv2:get
のpriceChangeDetailsを参照し、
価格変更がない限り、継続時の決済は初回時の決済と同額とする。(方法1)
同じくPurchases.subscriptionsv2:get
のOfferDetailsを参照し、
その情報をもとに、Method: monetization.subscriptions.basePlans.offers.getを叩く。
レスポンスのRegionSubscriptionOfferPhaseConfigから現在の設定値を取得し、その値を決済金額と見做す。(方法2)
おすすめは1ですね。シンプルなので。
iOS-App Storeの定期購読追跡方法
定期購読追跡のガイドライン
https://developer.apple.com/videos/play/wwdc2021/10174
こちらの動画で「App内課金の状況を把握するため、効果的なサーバ運用のガイドライン」が紹介されているので、従うのが良さそうです。
- Subscription Status API
- 定期購読が有効か期限切れなのか猶予期間中なのかその他の状態なのかを簡単にチェックできます。
- リクエスト項目はoriginal_transaction_idのみ(必須)。
- statusを見ることで、ステータスを簡単にチェックできる。
- 定期購読の最新の情報を取得できる。
- In-App Purchase History API
- verifyReceiptのlatest_receipt_infoに相当。(Replacement for latest_receipt_info in /verifyReceipt)
- 違い
- JWT形式であり安全であること
- ページングでサイズを制御できること
- 違い
- リクエスト項目はoriginal_transaction_idのみ(必須)。
- verifyReceiptのlatest_receipt_infoに相当。(Replacement for latest_receipt_info in /verifyReceipt)
- App Store server notifications V2
- Apple社がかなり推してる。(さらに強化することが今年の目標とのこと)
- V2はもう利用して良い。
V1でできるが、V2でできないものは、subtypeで判別可能な模様。
Get All Subscription Statusesの仕様・用法
Get All Subscription Statusesを参照のこと。
リクエスト
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
original_transcation_id のみで問い合わせが可能です。
レスポンス
下記のような形式のJSONが返ってきます。
{
"data": {
"subscriptionGroupIdentifier": {},
"lastTransactions": [
{
"originalTransactionId": "xxx",
"status": 1,
"signedRenewalInfo": "",
"signedTransactionInfo": ""
}
]
},
"environment": "",
"appAppleId": "",
"bundleId": ""
}
status
定期購読のステータスが数値で表記されます。
signedRenewalInfo
verifyReceiptのPending_renewal_infoと同様である。
{
"autoRenewStatus": "0:自動更新OFF,1:自動更新ON",
"expirationIntent": "期限切れの理由.1:ユーザーによるキャンセル。2:支払いできなかった。3:価格上昇に同意しなかった。4:更新時に商品が有効でなかった。5:その他",
"gracePeriodExpiresDate": "猶予期間終了日"
}
signedTransactionInfo
{
"environment": "",
"expiresDate": "有効期限",
"transactionId": "定期購読ID",
"originalPurchaseDate": "定期購読の購入日(最初の決済日)",
"originalTransactionId": "大元の定期購読ID",
"productId": "商品ID",
"purchaseDate": "決済日時",
"revocationDate": "失効日時",
"revocationReason": "失効理由"
}
signedTransactionInfoには、
・有効期限
・商品ID
・決済日時
などが入っています。
JWTをデコードするには?
AppStoreConnectでカギを取得する。
定期購読が更新されていたらどうなるか?どう判断するか?
transactionIdがサーバ未登録の値であることで判断します。
定期購読の返金の捉え方
AppStore/Google Play共に、定期購読のステータスに「返金」がない。(これが共通点)
返金については定期購読のステータスとは別に追跡する必要があると考える。
但し、返金について用意されているAPIが両者で異なる。
・AppStoreは「この定期購読が返金されたか?」という問い合わせが可能
・GooglePlayは「私のアプリで返金されたのはどの定期購入?」というが可能
である。(これが相違点)
AppStoreの返金の追跡
AppStoreはGET REFUND HISTORYがある。
GET https://api.storekit.itunes.apple.com/inApps/v2/refund/lookup/{originalTransactionId}
パラメータにoriginalTransactionIdが含まれることからわかるように、
「この定期購読のうち返金された決済はどれか?」という問い合わせになる。
レスポンスは下記JSON形式となる。
{
"hasMore": boolean,
"revision": string,
"signedTransactions": []
}
signedTransactions には、originalTransactionId内のtransactionIdのうち、
返金されたtransactionIdに関するJWSTransactionが設定される。
定期的にoriginalTransactionIdごとにステータスを追跡しているのであれば、
その一環としてこれを叩くようにしても良いし、
ステータス追跡とは別に返金計上のためにこのAPIを叩くようにするのもあり。
GooglePlayの返金の追跡
GooglePlayはVoided Purchases APIがある。
GET https://www.googleapis.com/androidpublisher/v3/applications/
your_package_name/purchases/voidedpurchases?access_token=your_auth_token
(使用できるクエリパラメータは「Method: purchases.voidedpurchases.list」を参照のこと)
Voided Purchases APIには、上記のようにorder_idがない。
そのため、「この定期購入が返金されたか?」という追跡ではなく、
「私のアプリで返金した注文(order_id)はどれ?」という問い合わせである。
レスポンスは下記JSON形式となる。
{
"kind": string,
"purchaseToken": string,
"purchaseTimeMillis": string,
"voidedTimeMillis": string,
"orderId": string,
"voidedSource": integer,
"voidedReason": integer
}
voidedReason以下のいずれかの返金(払い戻し)理由が設定される。
・0:その他
・1:購入者都合
・2:サービス未提供
・3:欠陥
・4:誤注文
・5:不正行為
・6:アプリが詐欺
・7:チャージバック
ドキュメント上、「返金」という文字はなく、購入の取り消しだが、結果的には返金されると推測されるため、この記事では返金としている。
このAPIをステータスの追跡とは別に定期的に叩いて、
返金されたものとして計上する。
このAPIでの返金概念はMethod: purchases.subscriptionsv2.getにおけるステータスとは別概念であるため、それぞれ独立して扱うようにする。
割引・キャンペーンの実施方法
割引・キャンペーンについて記述する予定です。
iOS
Android
Androidの定期購入商品については、まず下記の図を参照する。