コンテンツへスキップ

QualiArtsengineer blog

Goで実装するAppStoreの返金検知システム

Goで実装するAppStoreの返金検知システム

6 min read

はじめに

株式会社QualiArtsで「IDOLY PRIDE(以降、アイプラ)」のバックエンドエンジニアチームのリーダーをしている末吉です。 主にゲームAPIの開発やインフラの運用、チームメンバーの進捗管理や開発スケジュールの策定等を担当しています。

課金アイテムを取り扱ってるiOSアプリの場合、返金が発生することがあります。こちらはユーザー側でAppStoreに返金の申請を行うなど、様々な要因で発生します。

こちらの返金を検知をするためには、AppStoreでの仕様に合わせたシステムをプロダクト側で構築する必要があります。

そこで、本記事ではタイトルの通りGoを使用したAppStoreの返金検知システムについて紹介します。

前提として、本記事は2024年4月現在の仕様に基づいているため、今後プラットフォーム側の仕様が変更になる可能性がある点については注意してください。

全体の流れ

まず、AppStoreの返金を検知するまでの流れについて簡単に説明します。

AppStoreでは返金が発生すると、プロダクト側で設定したURLにAPI経由で返金情報が送信されます。

そのため、プロダクト側の対応としてはこの返金通知を受け取るHTTPサーバーを用意する必要があります。

general

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の返金検知システムについて紹介しました。

本記事が皆さまの開発、運用に少しでもお役に立てば幸いです。

2020年に株式会社QualiArtsにサーバーサイドエンジニアとして新卒入社。『IDOLY PRIDE』では複数の機能開発やインフラ構築を担当。現在はサーバーサイドチームのリーダーを担当。