StoreKit 2 が難しい本当の理由
個人開発者がアプリ内課金の実装でつまずくのは、APIの難しさよりも「正しい実装がどれか分からない」という問題が大きいです。Stack Overflowで見つけた答えは StoreKit 1 の旧APIを使っていることが多く、Apple の公式ドキュメントは概念の説明に力を入れすぎてコードの全体像が見えにくい。そして、サンドボックス環境と本番環境の挙動の違いが予期せぬバグを生みます。
2022年に登場した StoreKit 2 は、async/await を全面採用したモダンなAPIに刷新されました。しかしその分、StoreKit 1 の知識がある人にとってはむしろ混乱の原因になっています。SKPaymentQueue や SKProductsRequest は廃止され、Transaction や Product という新しい型が中心に据えられています。
2014年から個人で iOS アプリを作り続け、累計5,000万DLほどの中で StoreKit 1 から StoreKit 2 まで実装と運用を経験してきました。本稿で扱うのは、Claude Code を設計パートナーとして活用しながら、StoreKit 2 × SwiftUI での課金実装を一から構築する流れです。消耗型アイテム(One-time purchase)からサブスクリプションまで、App Store 審査を通過できる品質のコードと、後半では公式ドキュメントには書かれていない運用上の落とし穴と判断軸まで踏み込みます。
なお、Claude Code の Swift/iOS 開発環境の構築については Claude Code × Swift/iOS 本番アプリ開発完全ガイド を先にご覧ください。
Claude Code が StoreKit 2 実装のパートナーになれる理由
StoreKit 2 の実装で Claude Code が特に力を発揮するのは、以下の3つの場面です。
① プロジェクト全体のコンテキストを持った設計提案
Claude Code はプロジェクト全体のファイルを読み込みながら作業します。「この ViewModel でサブスクリプション状態を管理したい」と伝えると、既存のアーキテクチャ(MVVM・Clean Architecture 等)に合わせた設計を提案してくれます。孤立したサンプルコードではなく、自分のプロジェクトに馴染む実装が得られます。
② エラーハンドリングの網羅的な実装
StoreKit 2 の StoreKit.AppStore.sync() や Transaction.updates のエラーケースは多岐にわたります。Claude Code に「StoreKit 2 のエラーを全ケース網羅してください」と指示すると、StoreKitError・Product.PurchaseError・VerificationResult.VerificationError のすべてを処理するコードを一度に生成できます。
③ Sandbox とのデバッグ支援
「Sandbox で購入後に entitlements が更新されない」「Transaction.currentEntitlements が空を返す」といった典型的な問題を Claude Code に相談すると、状況の診断と修正コードを即座に提示してくれます。
Step 1: 商品設計と App Store Connect での構成
実装前に、App Store Connect での商品設定を Claude Code と一緒に設計します。
Claude Code へのプロンプト例
私のアプリは写真編集アプリです。以下の課金モデルを実装したいです:
- プレミアムフィルター(消耗型、1回購入 ¥120)
- Pro月額サブスクリプション(¥480/月)
- Pro年額サブスクリプション(¥3,800/年)
StoreKit 2 のProduct ID命名規則と App Store Connect の設定項目を教えてください。
Claude Code は以下のような命名規則を提案してくれます。
Product ID の命名規則(推奨):
com.yourcompany.appname.premium_filter ← 消耗型
com.yourcompany.appname.pro_monthly ← 月額サブスクリプション
com.yourcompany.appname.pro_yearly ← 年額サブスクリプション
App Store Connect 設定チェックリスト:
- Subscription Group 名: "Pro Features"(日英で設定)
- 年額は月額の10ヶ月相当に設定(割引感を出す)
- 試用期間: 7日間無料トライアル(月額のみ推奨)
- Grade: 月額を1、年額を2に設定
これらの Product ID は Xcode の StoreKit Configuration File(.storekit ファイル)に追加します。ローカルテスト用の設定ファイルを作成しておくことで、App Store Connect に商品を実際に登録する前にシミュレーターでテストできます。
Step 2: StoreKit 2 コア実装 — PurchaseManager の構築
アプリ全体で課金状態を管理する PurchaseManager を実装します。Claude Code に以下を依頼します。
SwiftUI アプリで StoreKit 2 を使ったPurchaseManagerを作成してください。
要件:
- @Observable マクロを使用(iOS 17以降対象)
- consumable と auto-renewable subscription の両方に対応
- Transaction.updates のリスナーを起動時に開始
- isSubscribed プロパティで現在のサブスクリプション状態を管理
- エラーハンドリングを全ケース網羅
Claude Code が生成するコアの実装です。
import StoreKit
import Observation
@Observable
final class PurchaseManager {
// MARK: - 購入可能な商品一覧
private ( set ) var products: [Product] = []
// MARK: - サブスクリプション状態
private ( set ) var isSubscribed: Bool = false
// MARK: - 購入済み消耗型アイテムのID群
private ( set ) var purchasedConsumables: Set < String > = []
// MARK: - エラー状態
private ( set ) var purchaseError: String ?
// MARK: - ローディング状態
private ( set ) var isPurchasing: Bool = false
// MARK: - Product ID 定義
private let productIDs: Set < String > = [
"com.yourcompany.appname.premium_filter" ,
"com.yourcompany.appname.pro_monthly" ,
"com.yourcompany.appname.pro_yearly"
]
// Transaction監視タスク
private var updateListenerTask: Task< Void , Error > ?
init () {
// アプリ起動時にTransactionリスナーを開始
updateListenerTask = listenForTransactions ()
Task {
await loadProducts ()
await updateSubscriptionStatus ()
}
}
deinit {
updateListenerTask ? . cancel ()
}
// MARK: - 商品一覧の取得
@MainActor
func loadProducts () async {
do {
// App Store Connectから商品情報を取得
let fetchedProducts = try await Product. products ( for : productIDs)
// 価格順にソート
products = fetchedProducts. sorted { $0 .price < $1 .price }
} catch {
// 商品取得失敗(ネットワーク障害などで発生)
purchaseError = "商品情報の取得に失敗しました: \( error. localizedDescription ) "
}
}
// MARK: - 購入処理
@MainActor
func purchase ( _ product: Product) async {
isPurchasing = true
purchaseError = nil
do {
// 購入リクエスト
let result = try await product. purchase ()
switch result {
case . success ( let verificationResult) :
// レシートの検証
let transaction = try checkVerified (verificationResult)
// サブスクリプション状態の更新
await updateSubscriptionStatus ()
// 消耗型アイテムの場合は購入完了を記録
if product.type == .consumable {
purchasedConsumables. insert (product.id)
// 消耗型はfinishを忘れずに呼ぶ
await transaction. finish ()
} else {
await transaction. finish ()
}
case .userCancelled :
// ユーザーが購入をキャンセル(エラーではない)
break
case .pending :
// 保護者の承認待ち(Ask to Buy)などの状態
purchaseError = "購入の承認を待っています。保護者の方に確認してください。"
@unknown default:
purchaseError = "予期しない購入結果です。もう一度お試しください。"
}
} catch StoreKitError. userCancelled {
// ユーザーキャンセルは正常なフロー
break
} catch StoreKitError. notAvailableInStorefront {
purchaseError = "この商品は現在の地域では購入できません。"
} catch StoreKitError. networkError ( let urlError) {
purchaseError = "ネットワークエラー: \( urlError. localizedDescription ) "
} catch {
purchaseError = "購入処理に失敗しました: \( error. localizedDescription ) "
}
isPurchasing = false
}
// MARK: - レシート検証
private func checkVerified < T >( _ result: VerificationResult<T>) throws -> T {
switch result {
case . unverified ( _ , let verificationError) :
// Appleの署名検証に失敗した場合(改ざんの可能性)
throw verificationError
case . verified ( let signedType) :
return signedType
}
}
// MARK: - サブスクリプション状態の更新
@MainActor
func updateSubscriptionStatus () async {
var hasActiveSubscription = false
// 現在有効な全Transactionを確認
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified (result)
// サブスクリプション商品かつ有効期限内であることを確認
if transaction.productType == .autoRenewable
&& transaction.revocationDate == nil {
hasActiveSubscription = true
}
} catch {
// 検証エラーのTransactionはスキップ
continue
}
}
isSubscribed = hasActiveSubscription
}
// MARK: - Transaction更新のリスナー
private func listenForTransactions () -> Task< Void , Error > {
return Task. detached {
// App外での購入(サブスクリプション更新など)を監視
for await result in Transaction.updates {
do {
let transaction = try self . checkVerified (result)
await self . updateSubscriptionStatus ()
await transaction. finish ()
} catch {
// 検証エラーのTransactionはfinishしない
print ( "Transaction verification failed: \( error ) " )
}
}
}
}
// MARK: - 購入の復元
@MainActor
func restorePurchases () async {
isPurchasing = true
do {
// App Store に購入履歴の同期を要求
try await AppStore. sync ()
await updateSubscriptionStatus ()
} catch {
purchaseError = "購入の復元に失敗しました: \( error. localizedDescription ) "
}
isPurchasing = false
}
}
この実装で特に重要な点をいくつか解説します。
Transaction.updates リスナーについて : App外でのサブスクリプション更新(自動更新・プランアップグレードなど)を受け取るために必須です。アプリ起動時に開始し、deinit でキャンセルします。これを忘れると、サブスクリプションの自動更新後にアプリを再起動するまで有効期限が反映されません。
checkVerified() について : StoreKit 2 はローカルでのレシート検証を自動的に行いますが、結果は VerificationResult で返ってきます。unverified ケースを無視して進むと、改ざんされたトランザクションを処理してしまいます。
Step 3: サブスクリプション管理 — 更新・解約・グレードアップ
サブスクリプションの管理で最も複雑なのは、状態遷移の処理です。ユーザーは月額から年額にアップグレードしたり、解約後にまた購読を再開したりします。
Claude Code に状態遷移図の設計を依頼します。
StoreKit 2 のサブスクリプション状態管理で、以下のケースを
enum で表現してください:
- アクティブ(月額)
- アクティブ(年額)
- 試用期間中
- 解約済み(有効期限内)
- 期限切れ
- 購入なし
生成されたコードを元に、詳細な状態管理を追加します。
enum SubscriptionStatus : Equatable {
case notSubscribed // 未購読
case inTrial (expiresDate: Date) // 試用期間中
case active (plan: SubscriptionPlan, expiresDate: Date) // 有効
case gracePeriod (expiresDate: Date) // 支払い猶予期間
case billingRetry // 支払い再試行中
case expired // 期限切れ
case revoked // 払い戻し済み
var isEntitled: Bool {
switch self {
case .inTrial, .active, .gracePeriod :
return true
default:
return false
}
}
}
enum SubscriptionPlan : String {
case monthly = "com.yourcompany.appname.pro_monthly"
case yearly = "com.yourcompany.appname.pro_yearly"
}
次に、PurchaseManager に詳細な状態取得メソッドを追加します。
// MARK: - 詳細なサブスクリプション状態の取得
@MainActor
func fetchSubscriptionStatus () async -> SubscriptionStatus {
let subscriptionIDs: [ String ] = [
SubscriptionPlan.monthly. rawValue ,
SubscriptionPlan.yearly. rawValue
]
// サブスクリプション商品の最新ステータスを取得
guard let product = products. first ( where : { subscriptionIDs. contains ( $0 .id) }),
let statuses = try? await product.subscription ? .status else {
return .notSubscribed
}
// 最も優先度の高いステータスを返す
for status in statuses {
guard let renewalInfo = try? status.renewalInfo.payloadValue,
let transaction = try? status.transaction.payloadValue else {
continue
}
switch status.state {
case .subscribed :
let plan = SubscriptionPlan ( rawValue : transaction.productID) ?? .monthly
let expiresDate = transaction.expirationDate ?? Date ()
// 試用期間中かどうかを確認
if transaction.offerType == .introductoryOffer {
return . inTrial ( expiresDate : expiresDate)
}
return . active ( plan : plan, expiresDate : expiresDate)
case .inGracePeriod :
return . gracePeriod ( expiresDate : transaction.expirationDate ?? Date ())
case .inBillingRetryPeriod :
return .billingRetry
case .revoked :
return .revoked
case .expired :
// 解約済みだが有効期限内のケースを確認
if let expiresDate = transaction.expirationDate, expiresDate > Date () {
let plan = SubscriptionPlan ( rawValue : transaction.productID) ?? .monthly
return . active ( plan : plan, expiresDate : expiresDate)
}
return .expired
@unknown default:
continue
}
}
return .notSubscribed
}
Step 4: App Store Server API でのサーバーサイド検証
クライアントサイドの StoreKit 2 検証は Apple が行うため、基本的には信頼できます。しかし、高価値なコンテンツを提供する場合や、複数デバイス間での状態同期が必要な場合は、サーバーサイドでの検証も実装すべきです。
Claude Code に依頼します。
Node.js/TypeScript で App Store Server API の
/inApps/v1/transactions/{transactionId} エンドポイントを
呼び出してトランザクションを検証するコードを書いてください。
JWT認証(ES256)を使ったリクエスト生成も含めてください。
生成されたサーバーサイドの実装例です。
import { SignJWT, importPKCS8 } from 'jose' ;
interface AppStoreTransactionResponse {
signedTransactionInfo : string ;
// その他のフィールド
}
class AppStoreServerAPI {
private readonly bundleId : string ;
private readonly issuerId : string ;
private readonly keyId : string ;
private readonly privateKey : string ;
constructor ( config : {
bundleId : string ;
issuerId : string ;
keyId : string ;
privateKey : string ; // YOUR_PRIVATE_KEY の形式
}) {
this .bundleId = config.bundleId;
this .issuerId = config.issuerId;
this .keyId = config.keyId;
this .privateKey = config.privateKey;
}
// JWT トークンの生成(有効期限: 最大60分)
private async generateToken () : Promise < string > {
const privateKey = await importPKCS8 ( this .privateKey, 'ES256' );
const token = await new SignJWT ({
bid: this .bundleId,
})
. setProtectedHeader ({
alg: 'ES256' ,
kid: this .keyId,
typ: 'JWT' ,
})
. setIssuer ( this .issuerId)
. setIssuedAt ()
. setAudience ( 'appstoreconnect-v1' )
. setExpirationTime ( '1h' )
. sign (privateKey);
return token;
}
// トランザクション情報の取得
async getTransaction ( transactionId : string ) : Promise < AppStoreTransactionResponse > {
const token = await this . generateToken ();
// 本番環境とSandboxで異なるエンドポイント
const baseUrl = process.env. NODE_ENV === 'production'
? 'https://api.storekit.itunes.apple.com'
: 'https://api.storekit-sandbox.itunes.apple.com' ;
const response = await fetch (
`${ baseUrl }/inApps/v1/transactions/${ transactionId }` ,
{
headers: {
Authorization: `Bearer ${ token }` ,
},
}
);
if (\ ! response.ok) {
const errorBody = await response. json ();
throw new Error (
`App Store Server API error: ${ response . status } - ${ JSON . stringify ( errorBody ) }`
);
}
return response. json ();
}
// サブスクリプション状態の取得(全デバイス共通)
async getSubscriptionStatuses ( transactionId : string ) {
const token = await this . generateToken ();
const baseUrl = process.env. NODE_ENV === 'production'
? 'https://api.storekit.itunes.apple.com'
: 'https://api.storekit-sandbox.itunes.apple.com' ;
const response = await fetch (
`${ baseUrl }/inApps/v1/subscriptions/${ transactionId }` ,
{
headers: {
Authorization: `Bearer ${ token }` ,
},
}
);
if (\ ! response.ok) {
throw new Error ( `Subscription status fetch failed: ${ response . status }` );
}
return response. json ();
}
}
export { AppStoreServerAPI };
Step 5: ペイウォール UI の設計パターン(SwiftUI)
Claude Code に SwiftUI でのペイウォール実装を依頼する際、シンプルな要求から始めて徐々に詳細化していくアプローチが効果的です。
struct PaywallView : View {
@Environment (PurchaseManager. self ) private var purchaseManager
@State private var selectedPlan: Product ?
@State private var showError: Bool = false
@Environment (\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack ( spacing : 24 ) {
// ヘッダー: プレミアム機能の魅力訴求
paywallHeader
// 機能一覧
featureList
// プラン選択
planSelector
// 購入ボタン
purchaseButton
// 復元リンク
restoreButton
// 利用規約・プライバシーポリシー
legalLinks
}
. padding ()
}
. navigationTitle ( "Pro にアップグレード" )
. navigationBarTitleDisplayMode (.inline)
. toolbar {
ToolbarItem ( placement : .navigationBarLeading) {
Button ( "閉じる" ) { dismiss () }
}
}
. alert ( "エラー" , isPresented : $showError) {
Button ( "OK" ) { purchaseManager.purchaseError = nil }
} message : {
Text (purchaseManager.purchaseError ?? "" )
}
. onChange ( of : purchaseManager.purchaseError) { _ , error in
showError = error \ != nil
}
. task {
// ビュー表示時に最新の商品情報を取得
await purchaseManager. loadProducts ()
// デフォルトで年額プランを選択(割引感を強調)
selectedPlan = purchaseManager.products. first {
$0 .id == SubscriptionPlan.yearly. rawValue
}
}
}
}
// MARK: - ヘッダー
private var paywallHeader: some View {
VStack ( spacing : 12 ) {
Image ( systemName : "star.fill" )
. font (. system ( size : 64 ))
. foregroundStyle (.yellow)
Text ( "Pro で写真編集を解放しよう" )
. font (.title2. bold ())
. multilineTextAlignment (.center)
Text ( "プロフェッショナルなフィルター・高度な編集ツール・広告なし体験" )
. font (.body)
. foregroundStyle (.secondary)
. multilineTextAlignment (.center)
}
}
// MARK: - プラン選択UI
private var planSelector: some View {
VStack ( spacing : 12 ) {
ForEach (purchaseManager.products. filter {
$0 .type == .autoRenewable
}) { product in
PlanCard (
product : product,
isSelected : selectedPlan ? .id == product.id,
isYearly : product.id == SubscriptionPlan.yearly. rawValue
)
. onTapGesture {
withAnimation (. easeInOut ( duration : 0.2 )) {
selectedPlan = product
}
}
}
}
}
// MARK: - 購入ボタン
private var purchaseButton: some View {
Button {
guard let plan = selectedPlan else { return }
Task {
await purchaseManager. purchase (plan)
if purchaseManager.isSubscribed {
dismiss ()
}
}
} label : {
Group {
if purchaseManager.isPurchasing {
ProgressView ()
. tint (.white)
} else {
Text (selectedPlan. map {
"始める — \( $0 . displayPrice ) "
} ?? "プランを選択してください" )
. fontWeight (.semibold)
}
}
. frame ( maxWidth : . infinity )
. frame ( height : 56 )
}
. buttonStyle (.borderedProminent)
. disabled (selectedPlan == nil || purchaseManager.isPurchasing)
}
// MARK: - 購入復元
private var restoreButton: some View {
Button ( "以前の購入を復元する" ) {
Task {
await purchaseManager. restorePurchases ()
if purchaseManager.isSubscribed {
dismiss ()
}
}
}
. font (.footnote)
. foregroundStyle (.secondary)
}
// MARK: - 法的リンク
private var legalLinks: some View {
HStack ( spacing : 8 ) {
Link ( "利用規約" , destination : URL ( string : "https://yourapp.com/terms" )\ ! )
Text ( "・" )
Link ( "プライバシーポリシー" , destination : URL ( string : "https://yourapp.com/privacy" )\ ! )
}
. font (.caption)
. foregroundStyle (.secondary)
}
}
// MARK: - プランカードコンポーネント
struct PlanCard : View {
let product: Product
let isSelected: Bool
let isYearly: Bool
var body: some View {
HStack {
VStack ( alignment : .leading, spacing : 4 ) {
HStack {
Text (isYearly ? "年額プラン" : "月額プラン" )
. font (.headline)
if isYearly {
// 年額の割引バッジ
Text ( "2ヶ月分お得" )
. font (.caption. bold ())
. padding (.horizontal, 8 )
. padding (.vertical, 2 )
. background (.green)
. foregroundColor (.white)
. clipShape (.capsule)
}
}
if isYearly {
// 月換算額を表示(購買意欲向上)
Text ( "月あたり約 \( monthlyEquivalent ) /月換算" )
. font (.caption)
. foregroundStyle (.secondary)
} else {
product.subscription ? .introductoryOffer. map { _ in
Text ( "7日間無料トライアル付き" )
. font (.caption)
. foregroundStyle (.green)
}
}
}
Spacer ()
VStack ( alignment : .trailing) {
Text (product.displayPrice)
. font (.title3. bold ())
Text (isYearly ? "/ 年" : "/ 月" )
. font (.caption)
. foregroundStyle (.secondary)
}
}
. padding ()
. background (
RoundedRectangle ( cornerRadius : 12 )
. fill (isSelected ? Color.accentColor. opacity ( 0.1 ) : Color (.systemGray6))
. stroke (isSelected ? Color.accentColor : Color.clear, lineWidth : 2 )
)
}
// 年額から月換算を計算
private var monthlyEquivalent: String {
guard let decimal = product.price as Decimal ? ,
decimal > 0 else { return "" }
let monthly = decimal / 12
// ロケールに合わせたフォーマット
return product.priceFormatStyle. format (monthly)
}
}
Step 6: テスト戦略 — StoreKit Testing と Sandbox
StoreKit 2 のテストは、3つの段階で行います。
① Xcode StoreKit Testing(ローカル)
プロジェクトに .storekit 構成ファイルを追加することで、App Store に接続せずに課金フローをテストできます。Claude Code に依頼します。
XCTestで PurchaseManager の以下のケースをテストするコードを書いてください:
1. loadProducts() で商品リストが返ってくること
2. purchase() で成功した場合に isSubscribed が true になること
3. purchase() でキャンセルした場合にエラーにならないこと
4. restorePurchases() が呼び出せること
import XCTest
import StoreKit
@testable import YourApp
final class PurchaseManagerTests : XCTestCase {
var purchaseManager: PurchaseManager\ !
override func setUp () async throws {
purchaseManager = PurchaseManager ()
// StoreKit Testing の設定ファイルを読み込む
try await SKTestSession.shared. clearTransactions ()
}
// 商品読み込みのテスト
func testLoadProductsReturnsProducts () async throws {
await purchaseManager. loadProducts ()
XCTAssertFalse (purchaseManager.products. isEmpty , "商品リストが空です" )
XCTAssertEqual (purchaseManager.products. count , 3 , "3商品が読み込まれるはず" )
}
// 購入成功のテスト
func testPurchaseMonthlySubscriptionSucceeds () async throws {
await purchaseManager. loadProducts ()
guard let monthlyProduct = purchaseManager.products. first ( where : {
$0 .id == "com.yourcompany.appname.pro_monthly"
}) else {
XCTFail ( "月額商品が見つかりません" )
return
}
// テスト環境での購入実行
await purchaseManager. purchase (monthlyProduct)
XCTAssertNil (purchaseManager.purchaseError, "エラーが発生しました: \( purchaseManager. purchaseError ?? "" ) " )
XCTAssertTrue (purchaseManager.isSubscribed, "購入後に isSubscribed が true になるはず" )
}
}
② Sandbox 環境でのテスト
Sandbox 環境では、以下の状況をテストします。
サブスクリプションの自動更新 : Sandbox では更新間隔が短縮されます(1ヶ月 → 5分)
解約後の期限切れ : Sandbox で解約し、期限切れ後の状態を確認します
払い戻しシナリオ : App Store Connect の Sandbox 管理画面から払い戻しをシミュレートできます
Sandbox テスト用のアカウントは App Store Connect の「ユーザーとアクセス → サンドボックス → テスター」で作成します。Claude Code に「Sandbox テストのチェックリストを作成してください」と依頼すると、見落としがちなケースを網羅したリストを生成してくれます。
Step 7: よくある実装ミスと Claude Code による防止策
StoreKit 2 の実装でよくある失敗を3つ紹介します。Claude Code に「このコードの StoreKit 2 実装を確認してください」と渡すだけで、これらの問題を検出してくれます。
ミス① Transaction.updates を App 起動時に開始していない
// ❌ 間違い: ViewModelやViewで遅れてリスナーを開始する
struct ContentView : View {
var body: some View {
Text ( "Hello" )
. task {
// このタイミングでは App 起動直後のトランザクションを取り逃す
for await result in Transaction.updates { ... }
}
}
}
// ✅ 正しい: App初期化時に必ず開始する
@main
struct YourApp : App {
@State private var purchaseManager = PurchaseManager () // init内でリスナーを開始
var body: some Scene {
WindowGroup {
ContentView ()
. environment (purchaseManager)
}
}
}
ミス② 消耗型アイテムで Transaction.finish() を呼び忘れる
// ❌ 間違い: finish()を呼ばないと次回購入が「待機中」になる
case . success ( let result) :
let transaction = try checkVerified (result)
grantConsumableItem () // アイテムを付与
// finish() がない!→ 次回購入時に同じTransactionが再処理される
// ✅ 正しい: アイテム付与後に必ずfinish()
case . success ( let result) :
let transaction = try checkVerified (result)
grantConsumableItem ()
await transaction. finish () // 必須
ミス③ currentEntitlements の判定でサブスクリプションIDを確認していない
// ❌ 間違い: 全Transactionを有効なサブスクリプションとして処理
for await result in Transaction.currentEntitlements {
isSubscribed = true // 一度購入した消耗型も含まれてしまう
}
// ✅ 正しい: productTypeとrevocationDateを確認
for await result in Transaction.currentEntitlements {
let transaction = try checkVerified (result)
if transaction.productType == .autoRenewable
&& transaction.revocationDate == nil {
isSubscribed = true
}
}
Claude Code への確認の仕方は「このPurchaseManagerの実装で StoreKit 2 のベストプラクティスから外れている箇所はありますか?」と質問するだけです。上記のような問題を検出し、修正案とともに提示してくれます。
Step 8: App Store 審査に向けた最終確認
App Store の審査では、課金実装に関して特に以下の点がチェックされます。Claude Code に「App Store ガイドライン 3.1.1 と 3.1.2 の観点でこの実装を確認してください」と依頼するのが効果的です。
審査通過のポイントは以下の通りです。
購入復元ボタンの設置(必須) : サブスクリプションを含むアプリは「購入を復元する」ボタンが必須です。ない場合はリジェクトされます
利用規約・プライバシーポリシーのリンク(必須) : ペイウォール画面に必ず表示します
試用期間の明確な表示 : 無料トライアルがある場合、期間終了後に課金が始まることを明記します
価格の明確な表示 : 「¥480/月」のように単位を含めて表示します
サブスクリプション管理への導線 : 「Appleの設定アプリからサブスクリプションを管理できます」という旨のテキストを含めることを推奨します
iOS テスト関連の実装については Claude Code で作る iOS テスト自動化基盤 も参考にしてください。
公式ドキュメントには書かれていない StoreKit 2 の運用知見
2014年から個人でアプリを作り続けてきて、累計5,000万DLほどになる頃には、StoreKit の挙動の細部に気づくようになりました。Apple の公式ドキュメントは「正しい使い方」は丁寧に説明していますが、「実運用で何が起こるか」については沈黙していることが多いです。廣川が壁紙アプリと癒し系アプリで StoreKit 2 を実装するなかで気づいた点を、運用視点で整理します。
Transaction.updates のタイミングは予測できない
公式の StoreKit 2 ガイドでは「アプリ起動直後に Transaction.updates を購読し始める」とだけ書かれています。しかし実運用では、以下のような順序で Transaction が届くことがあります。
アプリ起動から 0.3 秒後(同期完了済みの場合)
アプリ起動から 3〜5 秒後(バックグラウンドフェッチ後)
アプリがフォアグラウンドに戻った瞬間(リジューム時)
数分後、まったく予期しないタイミング(サーバー側で払い戻しが処理された場合)
つまり、ペイウォール UI を purchaseManager.purchasedProductIDs.isEmpty だけで判定すると、起動直後に「未購入扱い」となってペイウォールが一瞬表示される現象が起こります。対策として、私のアプリでは UserDefaults に最後の購入状態をキャッシュし、Transaction.updates の初回フェッチ完了まではキャッシュを優先する設計にしました。
@MainActor
final class PurchaseManager : ObservableObject {
@Published private ( set ) var isInitialFetchComplete = false
@Published private ( set ) var purchasedProductIDs: Set < String > = []
private let cacheKey = "cached_purchased_product_ids"
init () {
// 1. キャッシュから即座に復元
if let cached = UserDefaults.standard. array ( forKey : cacheKey) as? [ String ] {
purchasedProductIDs = Set (cached)
}
// 2. バックグラウンドで Transaction.updates を購読
Task { await observeTransactions () }
}
private func observeTransactions () async {
// 起動時に既存のエンタイトルメントを同期
await syncEntitlements ()
isInitialFetchComplete = true
// 以後の更新を継続的に監視
for await update in Transaction.updates {
await handleTransaction (update)
}
}
private func syncEntitlements () async {
var ids: Set < String > = []
for await result in Transaction.currentEntitlements {
if case . verified ( let tx) = result {
ids. insert (tx.productID)
}
}
purchasedProductIDs = ids
UserDefaults.standard. set ( Array (ids), forKey : cacheKey)
}
}
ペイウォール側では isInitialFetchComplete を待ち、未完了時はキャッシュを信頼する分岐を入れます。これだけで「起動時のペイウォール一瞬表示」のサポート問い合わせがほぼゼロになりました。
サンドボックスでサブスクリプションの自動更新が「速すぎる」
App Store Connect のサンドボックスでは、月額サブスクリプションが現実時間で 5 分ごとに更新される仕様になっています。これは便利な一方、テスト中に意図せず更新が走ってログが汚れる原因にもなります。
廣川の場合、サンドボックス専用のテストアカウントを 5 つ作り、それぞれ「新規購入」「更新済み」「解約済み」「失効済み」「リファンド済み」の状態を担当させる運用にしました。Apple のドキュメントには明記されていませんが、サンドボックスアカウントの状態は App Store Connect の「Sandbox Testers」セクションでアカウント単位で確認できます。
appAccountToken を必ず付ける
Product.purchase() の引数に appAccountToken: UUID を渡せます。公式ドキュメントでは「ユーザーを識別したい場合に使用」とだけ書かれており、優先度が低そうに見えますが、これがないと払い戻し対応で大きく困ります。
let result = try await product. purchase ( options : [
. appAccountToken (userUUID) // 自社サーバーで発行した安定 ID
])
App Store Server Notifications v2 で払い戻し通知が届いたとき、signedTransactionInfo に appAccountToken が入っていれば、自社 DB の特定ユーザーに対する権利剥奪を確実に実行できます。appAccountToken がないと、originalTransactionId をユーザーに紐づけて保存するロジックを別途用意する必要が出てきます。最初から appAccountToken を付ける運用にしておく方が、後で困りません。
Stripe × Lab サイトの経験から見た StoreKit 2 の設計思想
Dolice Labs の4サイト(Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab)では、Web 側のサブスクリプション課金に Stripe を採用しています。記事単体購入・月額プラン(Pro)・永久プラン(Premium)・チップという 4 種類の決済を扱っており、Stripe の設計思想と StoreKit 2 の設計思想を行き来する機会が多くあります。両者を比較すると、StoreKit 2 の独特な制約と利点が見えてきます。
Webhook 駆動 vs アプリ駆動の根本的な違い
Stripe では、ほぼすべての状態変化が Webhook で自社サーバーに通知される ことを前提に設計します。customer.subscription.updated や invoice.paid のような明確なイベント名が決まっており、それぞれの発火タイミングも公式ドキュメントで保証されています。
StoreKit 2 にも App Store Server Notifications v2 という同等の仕組みがありますが、主な状態取得手段はアプリ側の Transaction.updates です。アプリがオフラインの間に発生した状態変化を、サーバー側だけで完結して扱う設計は、Apple のエコシステムでは不自然に映ります。
この違いは、設計に次のような影響を与えます。
真実のソース(Source of Truth) : Stripe は完全にサーバー、StoreKit はアプリとサーバーの両方
ユーザー識別の確実性 : Stripe は Customer ID で 1:1、StoreKit は appAccountToken を付けないと曖昧
テストの容易さ : Stripe は CLI で任意のイベントを発火可能、StoreKit はサンドボックスの挙動に縛られる
この比較から学んだのは、StoreKit 2 を採用する場合でも、サーバー側の真実のソースを別に持つべき ということです。Dolice Labs では Cloudflare KV に site:{site}:article:{email}:{slug} というキーで購入記録を保存していますが、アプリ側でも同じパターンを採用しました。Realm の purchased_products テーブルにレコードを書き込み、Transaction.updates ともサーバー側の状態とも整合性を取るロジックを Claude Code に書いてもらいました。
価格の柔軟性で StoreKit が勝る場面
Stripe では、価格変更や A/B テストを行うために Price を複数作成して切り替える必要があります。StoreKit では、App Store Connect の Price Tier を変更すれば全ユーザーに即座に反映されます。
しかし、これは諸刃の剣でもあります。壁紙アプリで月額 ¥120 → ¥150 に変更したとき、既存ユーザーには影響を与えず、新規ユーザーにのみ新価格が適用されました。Stripe では、この「既存ユーザーは旧価格で継続」を実現するために、複数の Price を維持して新規のみ新 Price を割り当てる必要があり、運用負荷が大きく異なります。
解約フローのコントロール
Stripe では、ユーザーが解約するためのリンクを自社で実装できます(Customer Portal でも、独自 UI でも)。StoreKit では、解約画面は Apple の「サブスクリプション管理」設定画面に飛ばすしかありません 。これはユーザー保護の観点では正しい設計ですが、解約理由の収集や引き留めの導線を仕込む余地がほぼないことも意味します。
この制約に気づいたとき、廣川は「アプリ内の解約直前画面で何らかのフックを使ってアンケートを表示できないか」と Claude Code に相談しました。Claude Code はすぐに「Apple のガイドラインに反する可能性が高く、審査で指摘される」と教えてくれました。代わりに、ペイウォール表示時にユーザーが過去に解約していた場合のみ「以前ご利用いただいたサービスについて、改善のために一言だけお聞かせいただけますか」と表示する代替案を提示してくれました。設計の制約を AI と一緒に確認しながら進められる利点を強く感じた場面でした。
個人開発12年の視点で見るサブスクリプション設計の判断軸
2014年から個人開発を続けてきて感じるのは、サブスクリプションの「設計」はコードを書く時間より、判断を悩む時間の方が圧倒的に長いということです。AdMob の広告収益が月100万円を超えていた時期、「広告を一切外してサブスクリプションだけにする」案を何度も検討しました。最終的にハイブリッドモデルを選んだのですが、その判断の根拠を整理しておきます。Claude Code と何度も対話しながら詰めた内容です。
判断軸 1: ARPU か MAU か
サブスクリプションは ARPU(ユーザー当たり収益)を上げる施策で、広告は MAU(月間アクティブユーザー)からの収益化です。両者は対立しないように見えて、UI 設計のレベルでぶつかります。広告枠を残せばペイウォールの威力が弱まり、ペイウォールを強くすれば離脱率が上がって広告収益も落ちます。
壁紙アプリで廣川が選んだのは、「無料ユーザーは広告 + 全機能、有料ユーザーは広告なし + 限定壁紙」 という配分でした。広告を完全に外さなかった理由は、累計ダウンロードのうち課金率が想定より低かったからです(業界平均の 2〜3% 程度)。完全サブスク化を選ばなかったのは、AdMob からの安定収益を失う方が個人開発者にとってリスクが大きいと判断したためです。
判断軸 2: 月額か買い切りか
これは延々と悩むテーマです。最終的に 両方提供する 形に落ち着きました。Dolice Labs でも Pro(月額)と Premium(永久)の両方を提供しています。
判断の根拠は「読者の心理コストを最小化したい」という点でした。月額は安いが「いつ解約しよう」と考え続ける負担があり、買い切りは高いが「もう何も悩まなくていい」という安心感を提供できます。どちらを選ぶかはユーザー自身の判断で、こちらは選択肢を提示するだけです。
iOS アプリでも同じ思想で、消耗型 IAP(個別壁紙の購入)+ 非消耗型 IAP(永久のプロ機能)+ 月額サブスクリプションの 3 段構えを試したことがあります。Claude Code に「この 3 つの違いをユーザーに 1 画面で説明する UI を作ってください」と依頼したところ、左から「お試し」「中期」「長期」のメンタルモデルで並べる案を出してくれました。実装してみると、特に長期利用想定のユーザーが買い切りを選ぶ比率が上がりました。
判断軸 3: 価格は読者にとって「フェア」か
国際芸術賞の審査経験から思うのですが、価格設定は「自分の作ったものの価値」を主張する側面があります。安すぎると「自信がないのかな」と思われ、高すぎると「強欲だな」と感じられる。
廣川は Dolice Labs の月額を ¥580、永久を ¥2,480 に設定しています(日本円ベース)。これは「ファミレスのランチくらいの月額」と「単行本2冊くらいの永久」という基準で決めました。iOS アプリでも同じ感覚で、月額 ¥150〜¥250 のレンジで設計しています。Tier の刻み方は App Store Connect の Price Tier の制約に従いつつ、$0.99/$1.99/$2.99 のような「99セント表記」は意図的に避けています。誠実さに欠ける印象があるからです。
判断軸 4: 投資対効果が測れない実装に時間をかけない
最後に、これは StoreKit の実装そのものとは関係ありませんが、最も重要な判断軸です。サブスクリプション周りのコードは、書けば書くほどバグが増えます。
たとえば「サブスクリプションがあと 3 日で切れる場合、ペイウォール内に色を変えた警告を表示する」のような細かい UX 改善は、本当に課金率に貢献するでしょうか。廣川はこの種の改善に何度も時間をかけ、A/B テストの結果として 「ほぼ無関係」 という結論に至りました。
Claude Code と作業するようになって変わったのは、「実装にかかる時間」が短くなった分、こうした細かい改善も気軽に試せるようになったことです。1 時間で実装できれば、1 週間で結果を見て「効果なし」と判断するコストも下がります。個人開発者にとって AI が真に変えたのは、ここだと感じています。
全体を振り返って
StoreKit 2 の実装は、API の理解さえできれば Claude Code との組み合わせで大幅に効率化できます。この記事で実装したポイントを整理します。
PurchaseManager で課金状態をアプリ全体で管理し、Transaction.updates を起動時から監視します
VerificationResult の checkVerified() でレシート検証を必ず行います
サブスクリプション状態は productType・revocationDate の両方を確認します
消耗型アイテムは付与後に Transaction.finish() を呼びます
テストは Xcode StoreKit Testing → Sandbox → 本番の順で段階的に行います
次のステップとして、App Store Server Notifications v2 の Webhook 受信を実装することをおすすめします。サブスクリプションのキャンセルや払い戻しをリアルタイムに検知し、サーバーサイドの状態を即座に更新できます。Claude Code に「App Store Server Notifications v2 の Webhook を Express.js で受信するコードを書いてください」と依頼すれば、署名検証込みの完全な実装を数分で得られます。
App Store Connect の設定自動化については Claude Code × App Store Connect リリース自動化 もあわせてご覧ください。