Goで実装するAppStoreの返金検知システム
はじめに
株式会社QualiArtsで「IDOLY PRIDE(以降、アイプラ)」のバックエンドエンジニアチームのリーダーをしている末吉です。 主にゲームAPIの開発やインフラの運用、チームメンバーの進捗管理や開発スケジュールの策定等を担当しています。
課金アイテムを取り扱ってるiOSアプリの場合、返金が発生することがあります。こちらはユーザー側でAppStoreに返金の申請を行うなど、様々な要因で発生します。
こちらの返金を検知をするためには、AppStoreでの仕様に合わせたシステムをプロダクト側で構築する必要があります。
そこで、本記事ではタイトルの通りGoを使用したAppStoreの返金検知システムについて紹介します。
前提として、本記事は2024年4月現在の仕様に基づいているため、今後プラットフォーム側の仕様が変更になる可能性がある点については注意してください。
全体の流れ
まず、AppStoreの返金を検知するまでの流れについて簡単に説明します。
AppStoreでは返金が発生すると、プロダクト側で設定したURLにAPI経由で返金情報が送信されます。
そのため、プロダクト側の対応としてはこの返金通知を受け取るHTTPサーバーを用意する必要があります。
AppStoreServerNotificationV2について
AppStoreServerNotificationV2 という機構を利用することで、サブスクリプションのステータスの変更などの様々なイベントの通知をプロダクト側のサーバーに通知することができます。
その中に返金通知も含まれているので、この仕様に従ってHTTPサーバーを実装することで返金通知を受け取ることができます。
Goを使ったAppStoreServerNotificationV2の検証から返金情報を取得するまでの実装
ここからはGoを使用した実装方法について紹介します。
この通知はPOSTで送信されるのですが、以下のようなJSONがBodyに入る形になります。
{
"signedPayload": "afajljxxxdafa...."
}
このsignedPayloadに入っている値はJWSで符号化されたものです。 JWSは、以下のように「header」「payload」「signature」がピリオドで繋がれた形になっています。
${header}.${payload}.${signature}
このJWSの検証を行うことで、AppStoreからの通知が改ざんされていないことを確認することができます。
headerのx5cフィールドには以下の3つの証明書が含まれているので、こちらの証明書を検証していきます。
{
"alg": "...",
"x5c": "..."
}
- サーバ証明書: 署名に使った証明書
- 中間証明書: サーバ証明書を署名した公開鍵を含む証明書
- ルート証明書: 中間証明書を署名した公開鍵を含む証明書
こちらを自前で検証を行うことも可能ですが、github.com/awa/go-iap/appstore というライブラリを使用することで簡単に検証を行うことができます。
下記はgo-iap/appstoreを使用したサンプルコードになります。
package verify
import (
"encoding/json"
"errors"
"github.com/awa/go-iap/appstore"
jwt "github.com/golang-jwt/jwt/v4"
)
type verifier struct {
client *appstore.Client
}
func NewVerifier(appleRootCertificate string) *verifier {
client := appstore.New()
return &verifier{
client: client,
}
}
func (v *verifier) ParseNotification(notificationJWS string) (*appstore.SubscriptionNotificationV2DecodedPayload, error) {
payload := appstore.SubscriptionNotificationV2DecodedPayload{}
if err := v.client.ParseNotificationV2WithClaim(notificationJWS, &payload); err != nil {
return nil, err
}
if err := payload.Valid(); err != nil {
return nil, err
}
return &payload, nil
}
func (v *verifier) ParseSignedTransactionInfo(transactionInfoJWS string) (*appstore.JWSTransactionDecodedPayload, error) {
payload := appstore.JWSTransactionDecodedPayload{}
if err := v.client.ParseNotificationV2WithClaim(notificationJWS, &payload); err != nil {
return nil, err
}
if err := payload.Valid(); err != nil {
return nil, err
}
return &payload, nil
}
1つずつ解説していきます。
ParseNotificationV2WithClaimにAppStoreから 送られてきたsignedPayloadを渡すことで、下記のようなデコードされたデータを取得することができます。
type NotificationTypeV2 string
const (
NotificationTypeV2Refund NotificationTypeV2 = "REFUND"
NotificationTypeV2Test NotificationTypeV2 = "TEST"
)
type SubscriptionNotificationV2DecodedPayload struct {
NotificationType NotificationTypeV2 `json:"notificationType"`
Subtype SubtypeV2 `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
NotificationVersion string `json:"version"`
SignedDate int64 `json:"signedDate"`
Data SubscriptionNotificationV2Data `json:"data,omitempty"`
Summary SubscriptionNotificationV2Summary `json:"summary,omitempty"`
jwt.RegisteredClaims
}
type SubscriptionNotificationV2Data struct {
AppAppleID int `json:"appAppleId"`
BundleID string `json:"bundleId"`
BundleVersion string `json:"bundleVersion"`
Environment string `json:"environment"`
SignedRenewalInfo JWSRenewalInfo `json:"signedRenewalInfo"`
SignedTransactionInfo JWSTransaction `json:"signedTransactionInfo"`
Status AutoRenewableSubscriptionStatus `json:"status"`
}
NotificationTypeはどのようなイベントだったかという種類が文字列が入ります。返金通知の場合は「REFUND」となります。
SubscriptionNotificationV2DecodedPayload.Dataの中にはSignedTransactionInfoという、こちらもJWSで符号化されたデータが含まれています。
こちらもParseNotificationV2WithClaimに渡すことで、下記のような取引の決済情報を取得することができます。 最終的にOriginalTransactionIdを使用すれば、どの決済情報が返金されたのかを特定することができます。
type JWSTransactionDecodedPayload struct {
AppAccountToken string `json:"appAccountToken"`
BundleId string `json:"bundleId"`
Environment Environment `json:"environment"`
ExpiresDate int64 `json:"expiresDate"`
InAppOwnershipType string `json:"inAppOwnershipType"`
IsUpgraded bool `json:"isUpgraded"`
OfferIdentifier string `json:"offerIdentifier"`
OfferType OfferType `json:"offerType"`
OriginalPurchaseDate int64 `json:"originalPurchaseDate"`
OriginalTransactionId string `json:"originalTransactionId"`
ProductId string `json:"productId"`
PurchaseDate int64 `json:"purchaseDate"`
Quantity int64 `json:"quantity"`
RevocationDate int64 `json:"revocationDate"`
RevocationReason RevocationReason `json:"revocationReason"`
SignedDate int64 `json:"signedDate"`
Storefront string `json:"storefront"`
StorefrontId string `json:"storefrontId"`
SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"`
TransactionId string `json:"transactionId"`
TransactionReason TransactionReason `json:"transactionReason"`
IAPtype IAPType `json:"type"`
WebOrderLineItemId string `json:"webOrderLineItemId"`
jwt.RegisteredClaims
}
その他
Goでの実装は以上になりますが、言語によらず実際に返金通知を受け取るHTTPサーバーを実装する際には以下のような点に注意すると良いでしょう。
リトライを考慮した実装にする
公式のドキュメントでは、AppStoreServerNotificationV2の場合は最大5回までリトライされる と記載されています。 条件としては、成功しているなら200番台、失敗している場合は500番台や400番台などを返すようにすることが記載されています。
返金通知を受け取るサーバーでエラーハンドリングが正しく行えていないと、リトライが起きずに通知が失われてしまう可能性があ ります。そのため、リトライを考慮した実装を行うことが重要です。
IP制限を行う
AppStoreからの通知は指定のIPアドレスから送信されます。IP制限を行うことで、より安全性を高めることができます。
2024年4月現在では17.0.0.0/8を許可すれば問題ない 形になっています。
通知のテストを行う
最後に、用意したサーバーが通知を受信できている状態になっているかテストすることをおすすめします。
方法としては、公式でテスト通知のエンドポイント が用意されています。こちらをPOSTでAPIを叩くことでテスト通知を送信することが可能です。
この時に送られてくるNotificationTypeは「TEST」になります。
これにより、
- AppStoreConnectで設定したURLが正しいかどうか
- IP制限が正しいかどうか
を検証することができます。
こちらのエンドポイントを叩くためには、AppStoreConnect APIのKeyが必要です。作成方法ですが、AppStoreConnectの「ユーザー&アクセス」 → 「キー」 から作成することができます。
200番でレスポンスが返ってくればテスト通知が送信されています。
おわりに
本記事では、Goを使用したAppStoreの返金検知システムについて紹介しました。
本記事が皆さまの開発、運用に少しでもお役に立てば幸いです。