スマホ

GoogleInappBilling(Androidアプリ都度課金)のレシート検証について

スマホのアプリ内課金は、ゲームのポイントのような都度課金や、音楽聞き放題のような月額のサブスクリプション含め当たり前の機能となってきました。

スマホを使って有料のサービスを受けたことがある人なら、以下のいずれかを利用している可能性は高いのではないでしょうか。

Google(Inapp Billing)
Apple(Inapp Purchase)
Amazon(Inapp Purchase)

課金する側であればスマホを操作するだけで難しいことはないのですが、サービスを提供する側になると色々と悩ましい問題が発生します。

今回は、アプリ内課金の実装について調べる機会があったので、特に Google(Android)の仕様についてまとめておきたいと思います。

アプリ内課金の流れ

アプリ内課金の流れは Google でも Apple でも大きな違いはなく、ネイティブアプリでの決済だけであればそれほど苦労することはありません。

例えば Google だと、以下にネイティブアプリの実装で使用するフローや API についてのドキュメントが用意されています。

ネイティブアプリ単体であれば、ストア(GooglePlay Store)の商品に対して「決済」や「消費」をすることは Android 開発者なら朝飯前なのかもしれませんね。

よって今回はネイティブアプリではなく、ネイティブで決済した後のレシート情報をサーバサイドで検証する部分にフォーカスしてみます。

サーバサイドでレシート検証する必要性

ネイティブで決済した際、ストアから決済レシートが発行されます。

多くのサービスでは、レシートの内容(例えば購入した商品ID)を参考にポイントなど何かしらのインセンティブをユーザに付与すると思います。

ただしネイティブアプリ側でレシートの内容を鵜呑みにしてしまうと、レシートの改竄などセキュリティ面のリスクがあるため、サーバサイドから Google の Purchase API を使ってレシート検証するのが一般的になっているでしょうか。

フローイメージ

アプリで決済する
レシートと署名が発行される
レシートと署名をサーバサイドへ送信
サーバサイドで署名の整合性を確認
サーバサイドでレシートの整合性を確認
サーバサイドから Purchase API でレシート確認
API で返されたレシートの整合性を確認
ユーザへポイントなどを付与
アプリで決済ステータスを消費にする

ただし、サーバサイドでレシート検証を実装する際に気をつけたいポイントが大きく 3 つあります。

アプリから送信された情報のチェック
Purchase API とのやり取り
重複課金を防ぐ仕組み

初めてこの手の実装を行う際は未知な部分が多くて、ネイティブアプリと結合するまで不安です。

そこでハマりそうなポイントを 1 つずつ見ていきたいと思います。

アプリから送信された情報のチェック

ストアの商品を購入すると、ネイティブアプリ側でレシートと署名の情報が取得できます。

レシート情報は JSON 文字列のデータですが、署名は Base64 エンコードされた長い文字列になります。

サーバサイドではまず、この署名がアプリから正しく送信されてきたものかどうかをチェックします。必要な情報は以下の通り。

レシートの情報(JSON)
署名(文字列)
アプリの公開鍵

「アプリの公開鍵」は GooglePlay Console 上でアプリごとに管理されているものになります。

要は、これらの情報を使ってレシートの SHA1 ハッシュ値と公開鍵で復号化した署名を照合するわけです。

これを PHP で書くと以下のようなイメージ。

せっかく Android が絡むので、サーバサイド側の署名チェックも Kotlin で書いてみましょうか。

必要な部分だけ抜粋してエラー処理は省きます。

これで署名が正しいことが確認できれば次のステップです。

Purchase API とのやり取り

次に、レシートの情報を Google の Purchase API から取得して、アプリから送られてきたレシートとの整合性を確認します。

Purchase API のエンドポイントとリクエスト・レスポンスの仕様は以下の通りなので、まずは API を叩くのに必要な情報を揃えておきます。

エンドポイントの URL パスには以下の 3 つの情報を埋め込みます。全てレシート情報(JSON)に記載されている値です。

https://www.googleapis.com/androidpublisher/v3/applications/[packageName]/purchases/products/[productId]/tokens/[token]

packageName: アプリのパッケージ名
productId: 商品ID
token: トークン

ただし、この API を実行するには、Google のクライアント認証でアクセストークンを取得しておく必要があります。

以下のドキュメントを参考にしてもらうとわかりますが、OAuth の API を叩いて取得します。

Authorization

Scope: https://www.googleapis.com/auth/androidpublisher

必要に応じてリフレッシュトークンも使用しますが、面倒であればライブラリに任せてしまうのも手ですね。

Gradle を使っている場合は、build.gradle の dependencies に以下の定義を追加します。

compile ではなく implementation を使いましょうって話ですね。

Gradleのcompileは非推奨なのでapiかimplementationを使う夏頃から携わっているプロジェクトで初めて SpringBoot を利用しています。 今回の開発言語は Kotlin でしたが、これまで...

プログラム的にはこれでいいのですが、この API を利用するには Google の「サービスアカウント」を作成して、アカウント作成時に発行される認証ファイル(JSONまたはp12)を取得しておく必要があります。

GooglePlay Console 上のネイティブアプリ一覧画面にアクセスすると、左メニューに「設定」がありますので、ここからサービスアカウントの発行や API の利用許可をすることができます。

ここまで準備できたら、Google のクライアント認証で取得したアクセストークンを Purchase API の URL にリクエストパラメータとして付与します。

https://www.googleapis.com/androidpublisher/v3/applications/[packageName]/purchases/products/[productId]/tokens/[token]?access_token=[accessToken]

各パラメータに問題がある場合は 400、認証情報に問題がある場合は 401 など、Google からレスポンスのステータスコードとエラーが返されますが、このあたりの詳細な仕様はどこにも記載されていないのでもどかしいところです。

API のリクエストに問題がなければ、レシート情報が返ってきますので必要事項を確認して整合性チェックを完了します。

・テスト購入判定
・アプリから送信されたレシートとの照合
・消費されていないかの確認

レスポンスの purchaseType の有無で、テスト購入かどうかも判断できるので、この API のチェックは必ずやっておきたいところです。

また記事の最後に追記しましたが、2019 年の 5 月上旬から、レスポンスに「acknowledgementState」が追加されています。同じくして、レシートデータにも「acknowledged」が増えています。

{
“kind”: “androidpublisher#productPurchase”,
“purchaseTimeMillis”: long,
“purchaseState”: integer,
“consumptionState”: integer,
“developerPayload”: string,
“orderId”: string,
“purchaseType”: integer,
“acknowledgementState”: integer
}

重複課金を防ぐ

重複課金をどのように制御するかは、プロジェクトの実装や運用方法によって方針が異なると思います。

私は、テスト購入の際には「注文ID(OrderId)」がレシート情報に入ってこないという仕様を読んでいたので、purchaseToken を活用して一意性を給う方向で検討していたのですが、テストアプリで試したところ OrderId は含まれていました。

よってアプリ単位で、同じ OrderId のレシートを重複して許容しなければ良さそうです。
(アプリに関係なく OrderId はユニークになっていると思いますが)

しかし Google のドキュメントには、「テスト購入(サンドボックス)では OrderId が情報として入ってこないので注意しろ」と書いてあるのですが、実際にはテスト購入時にも OrderId は入ってきているのですよね。

これは純粋に、Google のドキュメントが古い可能性がありますがどうなんでしょうか。英語版ドキュメントを見た方が無難かも?

注: テスト購入には orderId フィールドがありません。テストトランザクションをトラックするには、purchaseToken フィールドを使用してください。

アプリ内課金の管理 – 注文番号を使う

その他の注意事項

その他、いくつかハマった点を備忘録として残しておきます。

Developer Payload

レシート情報の整合性を確認する際、これまでは「Developer Payload」の項目をレシートに含める方法が多くとられていたようです。

例えば、そのプロジェクトでしかわかりえないような値(ユーザIDとか)をネイティブアプリでレシート情報に追加し、それをサーバサイド側で照合するという方法です。

私も最初、この手法も取り入れようと思っていたのですが、「クックパッド開発者ブログ」でも書かれている通り「Developer Payload」の項目を利用する方法は推奨されなくなっています。

よってここまで書いてきた通り、サーバサイドから Purchase API でレシート情報を確認するのがベターです。

あとは、個人情報やセキュアな情報が残らない程度に、決済に必要な情報をログに残しておくといったところでしょうか。

払い戻しの実行や確認

ユーザの購入があれば、払い戻しの対応をしないといけないシーンもやってきます。

こちら側が強制的に払い戻しするケースは少ないと思いますが、ユーザが GooglePlay 側に払い戻しを承認されると売り上げが消滅します。

ユーザが一方的に GooglePlay に対して払い戻ししたものは、voided の API を叩くことで 30 日以内のものが調査可能となっています。

しかし、ガイドでは API のバージョンが v2 になっていますが、リファレンスでは v3 です。ここは最新の v3 を使っておきましょう。

2019 年の 12 月からは v3 しかサポートされなくなりますしね。古い API を使っている場合は要注意です。

https://www.googleapis.com/androidpublisher/v3/applications/packageName/purchases/voidedpurchases

ここでリストアップされると、購入履歴ごとに以下の値が返ってきます。

気を付けなければいけないのは、OrderId がないことです。

{
“kind”: “androidpublisher#voidedPurchase”,
“purchaseToken”: “some_purchase_token”,
“purchaseTimeMillis”: “1468825200000”,
“voidedTimeMillis”: “1469430000000”
}

代わりに purchaseToken と purchaseTimeMillis を代用することになりますが、purchaseToken を DB に格納する際に、余裕をもって varchar(1000) や text 型のカラムにしていると INDEX が貼れないので参照する際に困ります。

よって、別途 purchaseToken のハッシュ値(md5, sha1, sha256など)をカラムに持っておき、INDEX を貼っておけば、調査や購入アイテムの消費対応の際に楽になると思います。

purchaseToken についての公式の説明は以下の通りです。通常は 180 文字くらいの文字列が返ってきているのですが、どちらにしても MySQL の文字コードが utf8mb4 だと INDEX に使うにはギリギリですね。

注: Google Play は購入用のトークンを生成します。このトークンは、意味を成さない 1,000 字以内の文字の羅列です。 購入アイテムを消費するに記載しているように、購入アイテムを消費したときなどにトークン全体を別のメソッドに渡します。 トークンは短縮や切り捨てはせず、トークン全体を保存し、返す必要があります。

アプリ内課金を実装する – アイテムを購入する

acknowledgedパラメータの追加

2019 年の 5 月上旬から、レシート検証 API やレシート文字列について以下の変更が加わりました。

APIのレスポンスに acknowledgementState が追加される
レシートデータ(JSON文字列)に acknowledged が追加される

こちらについては、リリースノートが出ています。

Purchases must be acknowledged within three days

The Purchase object now includes an isAcknowledged() method that indicates whether a purchase has been acknowledged. In addition, the server-side API now includes acknowledgement boolean values for Product.purchases.get() and Product.subscriptions.get(). Before acknowledging a purchase, be sure to use these methods to determine if the purchase has already been acknowledged.

Purchaseオブジェクトに、購入が承認されたかどうかを示す isAcknowledged() メソッドが追加されました。さらに、サーバーサイド API には、Product.purchases.get() およびProduct.subscriptions.get() の確認ブール値が含まれるようになりました。購入を承認する前に、購入がすでに承認されているかどうかを確認するためにこれらの方法を必ず使用してください。

以下を読む限り、レシート検証の際というよりは、ネイティブアプリ(Android)の消費を行うタイミングで承認するように見受けられます。

You can acknowledge a purchase by using one of the following methods:

・For consumable products, use consumeAsync(), found in the client API.
・For products that aren’t consumed, use acknowledgePurchase(), found in the client API.
・A new acknowledge() method is also available in the Server API.

次の方法のいずれかを使用して購入を承認できます。

・消耗品の場合は、クライアントAPIにあるconsumeAsync()を使用してください。
・消費されていない製品の場合は、クライアントAPIにあるacknowledgePurchase()を使用してください。
・Server APIには、新しいacknowledge()メソッドもあります。

3 番目に書かれている通り、API にも acknowledge の POST のエンドポイントが用意されていますが、クライアント側で対応できるならお任せした方が楽そうです。

ネイティブアプリ(Android)の Billing Library のバージョンとの絡みもありますが、月額課金の場合は特に注意しておきたい変更になります。

ここについては、そろそろ需要が出てきそうなので以下の月額のサブスクリプションの方で補足します。

GoogleInappBilling(Androidアプリ定期購入)のレシート検証について前回、GooglePlay(Android)の都度課金についてまとめてみましたが、最近は月額課金のサービスが多くなり、都度課金以上に需要...

よって、Google Play Billing Library 2.0 のアプリと連携する場合、API のレスポンスの acknowledgementState は「承認前」、 レシート文字列の acknowledged が「false」になっているかチェックしてあげる必要も出てきそうですね。

ちなみに、アプリが Google Play Billing Library 2.0 より前だと、レシート文字列には acknowledged が true となって入ってきているようです。

Purchase APIで401エラー

検証の過程で、Google の Purchase API を叩く際に 401 エラーに遭遇することがありました。

原因はアクセストークンの権限不足と思われます。

アカウントサービスの権限設定のときに触れましたが、この原因で引っ掛かる人の多くは GooglePlay Console 上でアカウントサービスの API 利用承認していないパターンになります。

利用許可をした記憶がない人は、GooglePlay Console の設定メニューを再度確認してみてください。

ただし、GooglePlay Console 上で承認したにも関わらず 401 エラーが続く場合は、権限の反映に時間がかかっている可能性があります。

実際に私がそうだったのですが、API の利用許可ボタン押下後、数時間経過しても 401 で弾かれ続けました。

その際、以下の情報に行き当たったのですが、そこのコメントにも書かれていた通り、割り切って次の日の朝まで待ってみたら疎通確認ができました。

Google 翻訳で訳したので、わかりにくかったらすみません。

I think that the cause of the problem is that it is necessary to wait several days before all methods of api starts working correctly. But I not found this in docs.

私は問題の原因はapiのすべての方法が正しく機能し始める前に数日待つ必要があることであると思います。しかし、私はこれをドキュメントで見つけませんでした。

Yeah, you’re right. Just had to wait a couple days.

ええ、その通りです。ほんの数日待たなければなりませんでした。

正式な情報がないのでこれが正しいかどうかは何とも言えませんが、とにかく慣れていないと設定周りでは疲弊します・・・。
(だから、この備忘録を書いているわけですが)

検証を進めていく上で、新たな問題や訂正が必要な項目が出てきたら追記していきます。