ハイライト
このセッションでは、ドーナツ配達アプリ Food Truck を通じた事例を軸に、今年の StoreKit の複数の更新を紹介します。
主要内容
Food Truck はもともと有料アプリでした。4.99 ドルを支払うと、ドーナツの配達、基本的なソーシャルフィードの閲覧、年間売上チャートの表示ができました。後に作者は無料ダウンロードに変更し、年間売上チャートは一度きりの内課金、プレミアムソーシャルフィードはサブスクリプションになりました。
すぐに問題が発生しました。Alice はバージョン 2.5 でアプリを購入し、Bob はバージョン 8.2 で無料ダウンロードしました。ビジネスモデルが変わったからといって Alice が以前購入した機能を失うべきではなく、Bob は内課金を完了してからプレミアムコンテンツをアンロックすべきです。
StoreKit が今年提示した核心的な答えは App Transaction です。アプリ自体の購入情報を旧レシートから切り出し、JWS で署名します。StoreKit が自動検証します。開発者は originalAppVersion を読み取り、ユーザーが最初にアプリをダウンロードしたバージョンを判断し、旧有料ユーザーにエンタイトルメントを維持するかどうかを決められます。
このセッションのもう半分はサーバーサイドです。App Store Server API の Get Transaction History にソートとフィルタリングが追加され、App Store Server Notifications V2 にはテスト通知と通知履歴エンドポイントが追加されました。これらはサーバー統合と障害復旧の問題に対応します。まず通知 URL が TEST 通知を受信できるか確認し、ダウンタイム後に履歴エンドポイントで漏れた V2 通知を補完します。
詳細
App Transaction で旧有料ユーザーを識別する
(02:05)App Transaction はアプリ購入記録を検証する新しい API です。現在のデバイス上のアプリの署名付き購入情報を表し、JWS で署名され、旧版 App Receipt の App detail 部分に代わります。StoreKit が自動検証し、開発者が JWS 署名を自行検証することもできます。
(06:53)Food Truck の移行ロジックは 3 ステップに分けられます。App Transaction を取得し、検証結果を処理し、元のアプリバージョンを読み取ります。
let result = await AppTransaction.shared
switch result {
case .unverified:
// ユーザーにアプリ購入を検証できないことを通知し、更新の入口を提供する。
case .verified(let appTransaction):
let originalAppVersion = appTransaction.originalAppVersion
// originalAppVersion で 8.0 より前に有料版を購入したかどうかを判断する。
}
キーポイント:
AppTransaction.sharedは transcript で示された、App Transaction のVerificationResultを取得する入口です。.unverifiedは StoreKit が検証を完了しなかったことを意味します。この場合、App Transaction を更新するユーザー向けの入口を提供することをセッションは推奨しています。.verifiedブランチでのみappTransactionを読み取り、未検証データをエンタイトルメントの根拠にしないでください。originalAppVersionはユーザーがこのアプリを最初にダウンロードしたバージョンです。Food Truck は 8.0 より前かどうかの判断に使います。- App Transaction の更新はユーザー認証をトリガーするため、ユーザーの操作に応じてのみ呼び出せます。バックグラウンドでサイレントに呼び出すことはできません。
この API の価値は「検証」という言葉そのものではなく、移行シナリオにあります。有料アプリが無料+内課金に変わると、旧ユーザーと新ユーザーが混在します。originalAppVersion は説明可能な境界を提供します。8.0 より前に購入したユーザーは年間売上チャートを維持し、8.0 以降に無料ダウンロードしたユーザーは現在の内課金エンタイトルメントに従ってアンロックします。
StoreKit モデルに追加されたすぐ使えるプロパティ
(09:01)StoreKit モデルに 3 つのプロパティが追加されました。priceLocale、environment、recentSubscriptionStartDate です。Xcode 14 でビルドすると、実装がアプリにコンパイルされるため、これらのプロパティは旧システムにもバックデプロイできます。
product.priceLocale
transaction.environment
renewalInfo.environment
subscriptionInfo.recentSubscriptionStartDate
キーポイント:
priceLocaleは価格計算後のローカライズされた書式に使います。たとえば年間サブスクリプション価格を月額に換算した後も App Store の価格ロケールを維持できます。environmentは Transaction と Renewal Info に現れ、Xcode、sandbox、production を区別し、テスト取引を本番分析に書き込むのを防ぎます。recentSubscriptionStartDateは直近の連続サブスクリプション期間の開始点です。- 「連続サブスクリプション」は 2 つのサブスクリプション期間の間に最大 60 日の空きを許容するため、ユーザーが何日間サブスクライブしたかの計算には使えません。
(13:21)旧システム上の StoreKit Testing in Xcode では、これらのプロパティがセンチネル値を返す場合があります。priceLocale は xx_XX、environment は空文字列、recentSubscriptionStartDate は Date.distantPast です。これらはローカルテスト環境で実値が取得できないことのみを示し、ビジネスロジックに入れるべきではありません。
SwiftUI エントリーポイント:オファーコード引き換えとレビューリクエスト
(15:01)サブスクリプションオファーコードの引き換えが SwiftUI フレンドリーな view modifier になりました。表示を開始するにはバインディング Boolean が必要で、シートが閉じると表示に成功したかどうかが返ります。引き換え成功で発生した取引は transaction listener に入るため、アプリ起動時に取引リスニングを設定してください。
// SwiftUI offer code redemption sheet
// バインディング Boolean で表示をトリガー;引き換えで発生した取引は transaction listener に入る。
offerCodeRedemption(isPresented: $isPresentingOfferCodeRedemption)
キーポイント:
- transcript はこの view modifier が「binding Boolean だけ必要」と明言しています。
- 引き換えシートはオファーコードフローを担当し、取引結果はシートから直接返らず transaction listener に入ります。
- この機能は iOS 16 から利用可能です。
(16:44)レビューリクエストにも SwiftUI エントリーポイントがあります。SwiftUI 環境に requestReview が追加され、RequestReviewAction を適切なタイミングで関数として呼び出せます。
@Environment(\.requestReview) private var requestReview
requestReview()
キーポイント:
requestReviewは SwiftUI の environment value から取得します。- 取得できるのは
RequestReviewActionです。 - システムは 365 日以内に同一ユーザーに最大 3 回までプロンプトを表示します。
- ユーザーがボタンをタップしたからといってレビューを強制しないでください。セッションは購入完了やゲームクリアなどのポジティブな瞬間を選ぶことを推奨しています。
StoreKit Messages:App Store メッセージの表示タイミングを制御する
(17:48)StoreKit message は App Store が配信するシートで、ユーザーに重要な情報を表示します。典型的な理由はサブスクリプション値上げに対するユーザー同意です。デフォルトでは、アプリがフォアグラウンドに入ると StoreKit が保留中のメッセージを確認し、アプリ上に直接オーバーレイ表示します。
Food Truck の例はドーナツエディタです。ユーザーが配達内容を編集中に価格同意シートが突然表示されると操作が中断されます。解決策は、表示タイミングを制御したい view で message listener を設定し、メッセージを一旦保存してエディタを離れた後に表示することです。
// 表示を遅延させたい view で StoreKit messages をリッスンする。
// 受信したメッセージは pendingMessages に追加し、後で表示する。
pendingMessages.append(message)
// 適切な親 view で SwiftUI 環境の displayStoreKitMessage を使って表示する。
displayStoreKitMessage(message)
キーポイント:
- セッションによると、保留中のメッセージはアプリがフォアグラウンドに入るたびに配信されます。
- message listener を設定していない場合、StoreKit は即座にシートを表示します。
- リッスンしている場合は
pendingMessagesに収集し、ユーザーが敏感なフローを離れた後に表示できます。 displayStoreKitMessageは SwiftUI の environment value で、DisplayMessageActionを提供します。- 同じメッセージが複数回配信されることがあります。メッセージはユーザーに表示された後にのみ既読としてマークされます。
旧版 applicationUsername を appAccountToken に移行する
(22:23)旧版 StoreKit は applicationUsername で取引と開発者自身のユーザーアカウントを関連付けました。任意の文字列を受け付けますが、Apple は UUID 文字列の送信を推奨しています。UUID 文字列でない場合、StoreKit は永続化を保証しません。
// 旧版 StoreKit の支払い時:applicationUsername を UUID 文字列形式で設定する。
applicationUsername = userAccountUUID.uuidString
// 現代の StoreKit API では対応するのが appAccountToken で、UUID 形式が必須。
appAccountToken = userAccountUUID
キーポイント:
applicationUsernameは開発者が作成する文字列で、取引と自社アカウントシステムを関連付けます。- UUID 文字列を渡すと、App Store server はそれを
appAccountTokenとして保存します。 - その後、App Store Server API の signed transaction info と V2 App Store Server Notifications の両方でこの UUID を確認できます。
- 旧版 StoreKit から現代の StoreKit API へ移行する際、既存の取引とアカウントの関連を失わずに済みます。
Get Transaction History がより精密な増分同期をサポート
(30:33)Get Transaction History はもともと originalTransactionId でユーザーの購入履歴全体を取得できました。レスポンスはページ分割され、1 ページあたり最大 20 件の signed transactions と revision token が返ります。revision を付けた後続リクエストで次のページを取得できます。
GET /inApps/v1/history/{transactionId}?revision={revision}
キーポイント:
transactionIdはこの API のパスパラメータです。このセッションの移行文脈では、ユーザーの購入履歴を照会する際に original transaction ID を渡します。revisionは次のページをリクエストするために使い、後続の増分同期のために保存できます。hasMoreが true のときは次のページをリクエストし続けます。- 結果は modified date でソートされるため、取り消された取引は
revocationDateとrevocationReasonの更新により後続結果に再出現する場合があります。
(33:20)今年の強化はソートとフィルタリングです。開発者は最初のリクエストから範囲を絞り込め、サーバー処理時間とネットワークリクエストを減らせます。
GET /inApps/v1/history/{transactionId}?productType=NON_CONSUMABLE&startDate=1640995200000&excludeRevoked=true
キーポイント:
productType=NON_CONSUMABLEはセッションの例に対応し、非消耗型内課金のみを取得します。startDateはミリ秒タイムスタンプで開始時刻を制限します。excludeRevoked=trueは取り消された取引を除外します。- 後続のページネーションリクエストは完全に同じクエリパラメータを保持し、前ページの
revisionのみ置き換えまたは追加します。 productType、productId、subscriptionGroupIdentifierは複数値フィルタに対応し、パラメータを繰り返し定義できます。
テスト通知でサーバー設定を検証する
(36:15)App Store Server Notifications V2 を統合する際、よくあるアプローチは sandbox アカウントを作成し、サブスクリプション購入を完了し、サーバーが SUBSCRIBED / INITIAL_BUY を受信できるか確認することでした。このステップには変数が多すぎます。アカウント、購入フロー、サーバー URL、コールバック処理のいずれも独立して失敗し得ます。
WWDC22 で Request a Test Notification エンドポイントが追加されました。開発者は App Store server に、App Store Connect で設定した URL へ TEST タイプの V2 通知を送信するよう直接リクエストできます。
POST /inApps/v1/notifications/test
キーポイント:
- このエンドポイントは sandbox と production の両方で呼び出せ、対応する環境に保存された通知 URL をテストできます。
- 成功すると HTTP 200 が返ります。
- レスポンスにはこのテスト通知を識別する
testNotificationTokenが含まれます。 - 実際の
TEST通知は非同期で送信されます。HTTP 200 はリクエストが送信されたことのみを意味します。 - TEST payload には取引関連フィールドがなく、特に
signedTransactionInfoがありません。
(40:12)TEST 通知が届かない場合は、Get Test Notification Status エンドポイントで送信結果を照会できます。
GET /inApps/v1/notifications/test/{testNotificationToken}
キーポイント:
- パスの
testNotificationTokenは前のエンドポイントから取得します。 - レスポンスの
signedPayloadは App Store server が送信を試みた TEST 通知 payload です。 firstSendAttemptResultは最初の送信結果を示します。SUCCESSは App Store server が開発者サーバーから HTTP 200 を受信したことを意味します。- 失敗時は具体的なエラー値が返され、URL、証明書、ネットワーク、レスポンスコードの問題を特定するのに役立ちます。
通知履歴でサーバー障害中の漏れを復旧する
(42:17)App Store server はもともと本番環境での失敗後、最大 5 回までリトライしていました。長時間のダウンタイムでは最後のリトライを逃す可能性があり、短い障害でも少数の通知を逃し、どのユーザーが影響を受けたか開発者にはわかりませんでした。
Get Notification History エンドポイントは復旧問題に対応します。日付範囲でアプリの V2 通知履歴を取得でき、当時開発者サーバーが正常に受信したかどうかに関わりません。
POST /inApps/v1/notifications/history
{
"startDate": 1654041600000,
"endDate": 1656633600000,
"notificationType": "DID_RENEW",
"notificationSubtype": "BILLING_RECOVERY"
}
キーポイント:
- リクエストボディに
startDateとendDateを含め、最初の送信試行がそのウィンドウ内に収まる通知を返します。 - リクエスト日の 6 ヶ月前までの通知履歴のみ取得できます。
notificationTypeとnotificationSubtypeでフィルタできます。originalTransactionIdを指定すれば、特定ユーザーの通知履歴のみを照会できます。- 後続のページネーションリクエストは query parameter で
paginationTokenを渡し、リクエストボディは変更しません。 - レスポンスの
notificationHistoryには最大 20 件の通知が含まれ、最古のものから並びます。 - 各レコードの
signedPayloadは元の通知 payload と同じで、firstSendAttemptResultは当時漏れた理由の分析に役立ちます。
重要ポイント
-
やること:有料から無料へ移行するアプリに「旧ユーザーエンタイトルメント維持」チェックを追加する。 価値がある理由:App Transaction は
originalAppVersionを提供し、ユーザーが最初にアプリをダウンロードしたバージョンを識別できます。 始め方:起動後のエンタイトルメント更新フローでAppTransaction.sharedを呼び出し、検証通過後にバージョン閾値で過去の有料コンテンツをアンロックするか判断します。 -
やること:StoreKit 取引レポートを環境別に振り分ける。 価値がある理由:
environmentは Xcode、sandbox、production を区別し、テストデータが収益分析を汚染するのを防ぎます。 始め方:取引または renewal info をサーバーに送る前にenvironmentを読み取り、非 production データはテストテーブルに書き込むか本番統計をスキップします。 -
やること:サブスクリプションアプリ向けに「通知 URL 自己診断」管理ツールを作る。 価値がある理由:Request a Test Notification と Get Test Notification Status は、App Store server がコールバック URL に到達できるかを独立して検証できます。 始め方:運用パネルから
POST /inApps/v1/notifications/testをトリガーし、testNotificationTokenを保存して status エンドポイントをポーリングし、送信結果を表示します。 -
やること:サーバー障害後に通知補償タスクを追加する。 価値がある理由:Get Notification History は 6 ヶ月以内の V2 通知履歴を取り戻せ、リトライ失敗や短い障害で漏れたイベントをカバーします。 始め方:障害の開始・終了時刻を記録し、通知履歴 API を呼び出し、各
signedPayloadをデコードしてnotificationTypeと取引 ID で冪等にユーザーエンタイトルメントを更新します。 -
やること:編集、決済、ゲームステージなど重要フロー中は StoreKit message の表示を遅延する。 価値がある理由:値上げ同意シートなどの重要な App Store メッセージは、デフォルトのフォアグラウンド表示でユーザー操作を中断する可能性があります。 始め方:敏感な view で message listener を設定し、メッセージを
pendingMessagesに書き込み、フロー完了後にdisplayStoreKitMessageで表示します。
関連セッション
- Explore in-app purchase integration and migration — サーバーサイド移行の続き。JWT、signed transaction 検証、verifyReceipt からの移行を扱います。
- Implement proactive in-app purchase restore — アプリ起動時にユーザーのサブスクリプションと内課金エンタイトルメントを能動的に復元する方法。
- Discover Benchmarks in App Analytics — App Store Connect の分析とベンチマーク。内課金コンバージョンデータと併せて見るのに適しています。
コメント
GitHub Issues · utterances