
こんにちは、バックエンドエンジニアのogatasoです。
Mirrativでは、配信者にギフトを贈る際に必要なコインをアプリ内課金で購入できます。 今回、不正の検知と対策を目的として、過去および今後のすべての返金履歴を取得し、データベースに取り込む仕組みを導入しました。 本記事では、App Storeから送られてくる返金通知をリアルタイムで受け取る方法と、過去の課金が返金されていないか調べる方法について解説します。
目次
App Store Server Notificationsとは
Appleは、アプリ内課金の各種イベントを通知する仕組みとして「App Store Server Notifications」を提供しています。サービス提供者はApp Store Server Notificationsに通知先のエンドポイントを設定しておくことで、AppleからのPOSTリクエストを通して通知を受け取ることができます。
返金を例に挙げると、iOSユーザが購入したコインの返金をAppleにリクエストし、Appleがそれを承認したタイミングで設定されたエンドポイントにPOST リクエストが送られます。
ここではテスト方法を交えつつ、Appleからの返金通知を受け取り、それをデコードするところまで説明します。
設定
まず初めに、以下の手順で返金通知を受け取る設定を行います。
キーを作成する
- Creating API keys to authorize API requests | Apple Developer Documentationを参考に、App Store Connectから秘密鍵を生成します。開発環境から本番環境を叩いてしまうような事故を避けるため、サンドボックス環境のみで有効なキーを作れると便利そうなのですが、そのようなことはできないようでした。
POSTリクエストを受け取るエンドポイントを設定する
- こちらもApp Store ConnectでURLを設定できます。Enter server URLs for App Store Server Notifications | Apple Developer Documentationを参考に設定を進めていただければと思います。ここでは、本番環境とサンドボックス環境でエンドポイントを分けて登録できます。
Appleは、アプリ内課金の各種イベントを通知する仕組みをテストするために任意のタイミングで通知を実行できるテスト用のAPIを提供しています。Request a Test Notification | Apple Developer Documentationを参考にAPIを叩くことでサンドボックス環境でテスト通知を送ってもらうことが可能です。
ただし、IP 制限をかけている場合は、Apple の通知が飛んでくる、17.0.0.0/8を許可しておく必要があります。
テスト通知を送る
設定が完了したら実際にテスト通知を送り、登録したAPIに通知が飛んでくるかを確かめましょう。 テスト通知はJWTを生成した上で以下のcurlコマンドを叩くことで送ってもらうことが可能です。
curl -v -X POST \
-H 'Authorization: Bearer ${JWT}' \
https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test
JWTの生成
JWTの内容についてはGenerating JSON Web Tokens for API Requests | Apple Developer Documentationで説明されています。
今回、JWTの生成には https://github.com/golang-jwt/jwt を利用しました。
以下のコードは、作成したPEM形式の秘密鍵をjwt.ParseECPrivateKeyFromPEMに渡し、JWTを生成しています。
func generateAppleJWT(privateKeyPEM []byte, keyID, issuerID string, now time.Time) (string, error) { privateKey, err := jwt.ParseECPrivateKeyFromPEM(privateKeyPEM) if err != nil { return "", err } token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ "iss": issuerID, // 発行者ID "iat": now.Unix(), // 発行日時 "exp": now.Add(1 * time.Minute).Unix(), // 有効期限 "aud": "appstoreconnect-v1", // App Store Connectを指定する固定値 "bid": "com.example.testbundleid", // アプリのバンドルID }) token.Header["kid"] = keyID signedToken, err := token.SignedString(privateKey) if err != nil { return "", err } return signedToken, nil }
生成したJWTを含めて上述のcurlコマンドを実行し、成功すれば、設定したAPIにAppleからPOSTリクエストが送られてくるはずです。
通知の検証とデコード
Appleから送られてくる通知のリクエストボディはAppleの秘密鍵で署名されたJWS形式になっており、検証とデコードが必要になります。
検証、デコードは https://github.com/awa/go-iap を利用すれば簡単に行える上に、型定義も含まれているため、非常に便利です。 今回はこれを使って以下のような実装をしました。
http.HandleFunc("POST /apple_store_server_notification", func(w http.ResponseWriter, r *http.Request) { // リクエストボディを読み取る body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() // responseBodyをnotificationに格納する var notification appstore.SubscriptionNotificationV2SignedPayload if err := json.Unmarshal(body, ¬ification); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } // ParseNotificationV2WithClaimで検証、デコードしてpayloadに格納する var payload appstore.SubscriptionNotificationV2DecodedPayload client := appstore.New() if err := client.ParseNotificationV2WithClaim(notification.SignedPayload, &payload); err != nil { http.Error(w, "invalid signed payload", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) }
変数payloadにAppleからの通知情報を格納しています。
payload.NotificationTypeには通知タイプが保持されており、テスト通知の場合はTESTが入っていると思います。
サンドボックス環境でREFUND通知を取得する
返金用のREFUNDタイプの通知を受け取るには、サンドボックス環境で実際に何かを購入し、返金処理を行う必要があります。
ミラティブにはアプリから返金処理を行う機能は存在しないため、クライアントエンジニアの方に開発ビルドでテスト用の返金ボタンを設置してもらい、実験を行いました。
注意点として、AppleのSandbox環境では、返金画面を開こうとすると「接続できません」と出ることがあります。そのような場合は日を改めてみることをお勧めします。
返金ボタンを押して数秒待つとREFUNDタイプの通知が送られてきます。
トランザクション情報のデコード
REFUNDタイプだと、payload.Data.SignedTransactionInfoに返金情報が含まれているはずです。例によって、こちらもJWS形式になっているのでParseNotificationV2WithClaimで検証とParseが必要です。
if payload.NotificationType == appstore.NotificationTypeV2Refund { transactionInfo := appstore.JWSTransactionDecodedPayload{} if err := client.ParseNotificationV2WithClaim(string(payload.Data.SignedTransactionInfo), &transactionInfo); err != nil { return err } // transactionInfoの中身を保存する処理
あとはtransactionInfoの中身から必要な情報を抜き出し、DBに保存するだけです。どのような情報が含まれているかは以下のページを確認してください。
以上で、App Storeからの返金通知を受け取る方法の説明を終わります。補足として、 Responding to App Store Server Notifications | Apple Developer Documentation によると、リクエストを正常に処理できなかった場合もApple側で数回のリトライを行なってくれるそうですが、リトライの上限を超えた場合は以下で説明する方法を参考に取得することが可能です。
過去の返金履歴の取得方法
続いて、過去の返金履歴を取得する方法について説明します。こちらはスクリプトを作成して取得しました。
購入履歴については全てミラティブのDBに保存してあったため、今回はそれら全てのトランザクションIDに対して、参照するトランザクションの状態を問い合わせていきました。
以下のコードはJWTを生成したのち、GET https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId} を叩いています。その後、レスポンスを検証、デコードし、リターンしています。
func GetTransactionInfo( ctx context.Context, client *appstore.Client, storekitURL string, transactionID string, ) (*appstore.JWSTransactionDecodedPayload, int, error) { // JWTの生成 signedToken, err := generateJWT() if err != nil { return nil, 0, err } // リクエスト作成 req, err := http.NewRequestWithContext( ctx, http.MethodGet, storekitURL+"inApps/v1/transactions/"+transactionID, nil, ) if err != nil { return nil, 0, errors.WithStack(err) } req.Header.Set("Authorization", "Bearer "+signedToken) // リクエスト送信 res, err := http.DefaultClient.Do(req) if err != nil { return nil, 0, errors.WithStack(err) } defer res.Body.Close() // レスポンス読み込み body, err := io.ReadAll(res.Body) if err != nil { return nil, 0, errors.WithStack(err) } if res.StatusCode != http.StatusOK { return nil, res.StatusCode, nil } var parsedRes api.TransactionInfoResponse if err := json.Unmarshal(body, &parsedRes); err != nil { return nil, 0, errors.WithStack(err) } var payload appstore.JWSTransactionDecodedPayload if err := client.ParseNotificationV2WithClaim(parsedRes.SignedTransactionInfo, &payload); err != nil { return nil, 0, errors.WithStack(err) } return &payload, res.StatusCode, nil }
リトライ処理の必要性
以上のコードでトランザクションを取得できるのですが、取得したいトランザクションが非常に多かったため、スクリプトの実行時間も非常に長いものとなりました。 そんな中、途中で500番台のステータスコードが返ってきてリクエストに失敗してしまうことが多々ありました。そのため、必要に応じてリトライを入れることをお勧めします。
返金トランザクションの見分け方
トランザクションが返金されているものかどうかの判断はRevocationDateが0でないかどうかで判断しました。
https://developer.apple.com/documentation/storekit/transaction/revocationdate
返金されたトランザクションであれば、通知取得時と同様の方法で、DBに保存する処理を行いました。
if transactionInfo.RevocationDate != 0 { // DBに保存する処理 }
レートリミットについて
トランザクション取得APIは下記のページによると秒間50回までのレートリミットがあります。弊社の環境だとリクエストを送ってレスポンスを受け取るまでに0.25秒程度かかったため、あまり時間をかけたくない方は並列でリクエストを送ることをおすすめします。
We are hiring!
ミラティブではエンジニアを絶賛募集中ですので、ご興味のある方はお気軽にご連絡ください!