TL;DR

iOSアプリにチップ(Tip Jar)機能を追加した。SwiftUI + StoreKit 2 でアプリ側を実装し、App Store Connect REST API を使って商品登録・ローカライズ・価格設定・審査用スクリーンショット・配信地域設定・TestFlight配信までをコマンドラインから完了させた。本記事ではその全手順を再現可能な形で記載する。

前提: App Store Connect APIだけでiOSアプリを審査提出する完全ガイドの続編として、APIキーの取得・JWT生成は既にセットアップ済みとする。

全体の流れ

  1. アプリ側の実装(StoreKit 2 + SwiftUI)
  2. App Store Connect APIで商品登録(3つの消費型アイテム)
  3. ローカライズ設定(日本語・英語)
  4. 価格設定($0.99 / $2.99 / $6.99)
  5. 審査用スクリーンショットのアップロード
  6. 配信地域の設定
  7. 有料アプリ契約の締結
  8. TestFlightでの動作確認

1. アプリ側の実装

1.1 StoreKit設定ファイル

Xcodeのテスト環境用に TipJar.storekit を作成する。これによりシミュレータでStoreKitのテストが可能になる。

{
  "products" : [
    {
      "displayPrice" : "0.99",
      "familyShareable" : false,
      "internalID" : "tip_small_001",
      "localizations" : [
        {
          "description" : "開発を応援する小さなチップ",
          "displayName" : "小さな応援",
          "locale" : "ja"
        },
        {
          "description" : "A small tip to support development",
          "displayName" : "Small Tip",
          "locale" : "en_US"
        }
      ],
      "productID" : "com.example.app.tip.small",
      "referenceName" : "Small Tip",
      "type" : "Consumable"
    }
  ]
}

XcodeGen を使っている場合は project.yml の scheme に StoreKit 設定を追加する:

schemes:
  MyApp:
    run:
      config: Debug
      storeKitConfiguration: MyApp/TipJar.storekit

1.2 TipJarManager(StoreKit 2)

import Foundation
import StoreKit

@MainActor
class TipJarManager: ObservableObject {
    static let productIDs: [String] = [
        "com.example.app.tip.small",
        "com.example.app.tip.medium",
        "com.example.app.tip.large"
    ]

    @Published var products: [Product] = []
    @Published var isLoading = true
    @Published var purchaseState: PurchaseState = .idle

    enum PurchaseState: Equatable {
        case idle, purchasing, success
        case error(String)
    }

    func loadProducts() async {
        isLoading = true
        do {
            let storeProducts = try await Product.products(for: Self.productIDs)
            products = storeProducts.sorted { $0.price < $1.price }
        } catch {
            products = []
        }
        isLoading = false
    }

    func purchase(_ product: Product) async {
        purchaseState = .purchasing
        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                switch verification {
                case .verified(let transaction):
                    await transaction.finish()
                    purchaseState = .success
                case .unverified:
                    purchaseState = .error("購入の検証に失敗しました")
                }
            case .userCancelled, .pending:
                purchaseState = .idle
            @unknown default:
                purchaseState = .idle
            }
        } catch {
            purchaseState = .error(error.localizedDescription)
        }
    }
}

1.3 TipJarView(SwiftUI)

import SwiftUI
import StoreKit

struct TipJarView: View {
    @StateObject private var manager = TipJarManager()

    var body: some View {
        List {
            Section {
                VStack(spacing: 8) {
                    Text("アプリを応援")
                        .font(.title2).bold()
                    Text("開発を支援するためにチップをお願いします。")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                        .multilineTextAlignment(.center)
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 12)
                .listRowBackground(Color.clear)
            }

            Section {
                if manager.isLoading {
                    ProgressView().frame(maxWidth: .infinity)
                } else {
                    ForEach(manager.products, id: \.id) { product in
                        Button {
                            Task { await manager.purchase(product) }
                        } label: {
                            HStack {
                                VStack(alignment: .leading) {
                                    Text(product.displayName).font(.body)
                                    Text(product.description)
                                        .font(.caption)
                                        .foregroundColor(.secondary)
                                }
                                Spacer()
                                Text(product.displayPrice)
                                    .font(.headline)
                                    .foregroundColor(.accentColor)
                            }
                        }
                    }
                }
            }

            if case .success = manager.purchaseState {
                Section {
                    HStack {
                        Spacer()
                        VStack {
                            Image(systemName: "heart.fill")
                                .font(.system(size: 32))
                                .foregroundColor(.pink)
                            Text("ありがとうございます!")
                                .font(.headline)
                        }
                        Spacer()
                    }
                }
            }
        }
        .navigationTitle("応援する")
        .task { await manager.loadProducts() }
    }
}

1.4 トランザクション監視

Appinit() でトランザクションリスナーを設定する:

@main
struct MyApp: App {
    private var transactionListener: Task<Void, Never>?

    init() {
        transactionListener = Task.detached {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                }
            }
        }
    }
    // ...
}

2. JWT生成(共通関数)

以降のAPIコールで使う共通のトークン生成コード:

import jwt, time, requests, json

KEY_ID = 'YOUR_KEY_ID'
ISSUER_ID = 'YOUR_ISSUER_ID'
P8_PATH = '~/.private_keys/AuthKey_YOUR_KEY_ID.p8'

def get_token():
    with open(P8_PATH, 'r') as f:
        private_key = f.read()
    payload = {
        'iss': ISSUER_ID,
        'iat': int(time.time()),
        'exp': int(time.time()) + 1200,
        'aud': 'appstoreconnect-v1'
    }
    return jwt.encode(payload, private_key, algorithm='ES256',
                      headers={'kid': KEY_ID, 'typ': 'JWT'})

def headers(content_type=False):
    h = {'Authorization': f'Bearer {get_token()}'}
    if content_type:
        h['Content-Type'] = 'application/json'
    return h

3. アプリIDの取得

r = requests.get(
    'https://api.appstoreconnect.apple.com/v1/apps',
    params={'filter[bundleId]': 'com.example.app', 'fields[apps]': 'name,bundleId'},
    headers=headers()
)
app_id = r.json()['data'][0]['id']
print(f"App ID: {app_id}")

4. 消費型アイテムの作成

3つのチップアイテムを作成する:

products = [
    ('com.example.app.tip.small', 'Small Tip'),
    ('com.example.app.tip.medium', 'Medium Tip'),
    ('com.example.app.tip.large', 'Large Tip'),
]

for product_id, name in products:
    body = {
        'data': {
            'type': 'inAppPurchases',
            'attributes': {
                'productId': product_id,
                'name': name,
                'inAppPurchaseType': 'CONSUMABLE',
            },
            'relationships': {
                'app': {
                    'data': {'type': 'apps', 'id': app_id}
                }
            }
        }
    }
    r = requests.post(
        'https://api.appstoreconnect.apple.com/v2/inAppPurchases',
        headers=headers(content_type=True),
        json=body
    )
    print(f"{name}: {r.status_code} - ID: {r.json()['data']['id']}")

注意: referenceName 属性は v2 API では使用不可。name のみを使用する。

5. ローカライズの設定

各商品に日本語・英語のローカライズを追加する:

localizations = {
    'IAP_ID_SMALL': [
        {'locale': 'en-US', 'name': 'Small Tip',
         'description': 'A small tip to support development'},
        {'locale': 'ja', 'name': '小さな応援',
         'description': '開発を応援する小さなチップ'},
    ],
    'IAP_ID_MEDIUM': [
        {'locale': 'en-US', 'name': 'Medium Tip',
         'description': 'A tip to support development'},
        {'locale': 'ja', 'name': '応援',
         'description': '開発を応援するチップ'},
    ],
    'IAP_ID_LARGE': [
        {'locale': 'en-US', 'name': 'Large Tip',
         'description': 'A generous tip to support development'},
        {'locale': 'ja', 'name': '大きな応援',
         'description': '開発を大きく応援するチップ'},
    ],
}

for iap_id, locs in localizations.items():
    for loc in locs:
        body = {
            'data': {
                'type': 'inAppPurchaseLocalizations',
                'attributes': {
                    'locale': loc['locale'],
                    'name': loc['name'],
                    'description': loc['description'],
                },
                'relationships': {
                    'inAppPurchaseV2': {
                        'data': {'type': 'inAppPurchases', 'id': iap_id}
                    }
                }
            }
        }
        r = requests.post(
            'https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations',
            headers=headers(content_type=True),
            json=body
        )
        print(f"  {loc['locale']}: {r.status_code}")

6. 価格の設定

価格ポイントIDは Base64 エンコードされた JSON で構成される:

import base64

def make_price_point_id(iap_id, territory, price_tier):
    """価格ポイントIDを構築する"""
    data = json.dumps(
        {"s": iap_id, "t": territory, "p": price_tier},
        separators=(',', ':')
    )
    return base64.b64encode(data.encode()).decode().rstrip('=')

まず価格ポイントを確認して、目的の価格に対応するtierを特定する:

# $0.99, $2.99, $6.99 に対応する price tier を取得
r = requests.get(
    f'https://api.appstoreconnect.apple.com/v2/inAppPurchases/{iap_id}/pricePoints',
    headers=headers(),
    params={'filter[territory]': 'USA', 'limit': 200}
)
for pp in r.json()['data']:
    price = pp['attributes']['customerPrice']
    if price in ['0.99', '2.99', '6.99']:
        print(f"${price} -> tier from ID: {pp['id']}")

実際の価格設定は iapPriceSchedule を使う:

# 価格ティア: $0.99=10010, $2.99=10036, $6.99=10088
price_configs = [
    {'iap_id': 'IAP_ID_SMALL', 'tier': '10010'},   # $0.99
    {'iap_id': 'IAP_ID_MEDIUM', 'tier': '10036'},   # $2.99
    {'iap_id': 'IAP_ID_LARGE', 'tier': '10088'},    # $6.99
]

for config in price_configs:
    pp_id = make_price_point_id(config['iap_id'], 'USA', config['tier'])
    body = {
        'data': {
            'type': 'inAppPurchasePriceSchedules',
            'relationships': {
                'inAppPurchase': {
                    'data': {'type': 'inAppPurchases', 'id': config['iap_id']}
                },
                'baseTerritory': {
                    'data': {'type': 'territories', 'id': 'USA'}
                },
                'manualPrices': {
                    'data': [{'type': 'inAppPurchasePrices', 'id': '${price1}'}]
                }
            }
        },
        'included': [{
            'type': 'inAppPurchasePrices',
            'id': '${price1}',
            'relationships': {
                'inAppPurchasePricePoint': {
                    'data': {'type': 'inAppPurchasePricePoints', 'id': pp_id}
                }
            }
        }]
    }
    r = requests.post(
        'https://api.appstoreconnect.apple.com/v1/inAppPurchasePriceSchedules',
        headers=headers(content_type=True),
        json=body
    )
    print(f"Price set: {r.status_code}")

7. 審査用スクリーンショットのアップロード

各商品に審査用のスクリーンショットを添付する。3ステップで行う:

import hashlib, os

screenshot_path = 'tipjar_screenshot.png'
file_size = os.path.getsize(screenshot_path)
with open(screenshot_path, 'rb') as f:
    file_data = f.read()
md5 = hashlib.md5(file_data).hexdigest()

for iap_id in iap_ids:
    # Step 1: 予約を作成
    body = {
        'data': {
            'type': 'inAppPurchaseAppStoreReviewScreenshots',
            'attributes': {
                'fileName': 'tipjar.png',
                'fileSize': file_size,
            },
            'relationships': {
                'inAppPurchaseV2': {
                    'data': {'type': 'inAppPurchases', 'id': iap_id}
                }
            }
        }
    }
    r = requests.post(
        'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots',
        headers=headers(content_type=True),
        json=body
    )
    data = r.json()['data']
    ss_id = data['id']
    ops = data['attributes']['uploadOperations']

    # Step 2: チャンクをアップロード
    for op in ops:
        chunk = file_data[op['offset']:op['offset'] + op['length']]
        hdrs = {h['name']: h['value'] for h in op['requestHeaders']}
        requests.put(op['url'], headers=hdrs, data=chunk)

    # Step 3: アップロードをコミット
    commit = {
        'data': {
            'type': 'inAppPurchaseAppStoreReviewScreenshots',
            'id': ss_id,
            'attributes': {
                'uploaded': True,
                'sourceFileChecksum': md5,
            }
        }
    }
    requests.patch(
        f'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots/{ss_id}',
        headers=headers(content_type=True),
        json=commit
    )
    print(f"Screenshot uploaded for {iap_id}")

8. 配信地域の設定

全地域で利用可能にする:

# 全テリトリーを取得
r = requests.get(
    'https://api.appstoreconnect.apple.com/v1/territories',
    headers=headers(),
    params={'limit': 200}
)
territory_data = [{'type': 'territories', 'id': t['id']} for t in r.json()['data']]

for iap_id in iap_ids:
    body = {
        'data': {
            'type': 'inAppPurchaseAvailabilities',
            'attributes': {'availableInNewTerritories': True},
            'relationships': {
                'inAppPurchase': {
                    'data': {'type': 'inAppPurchases', 'id': iap_id}
                },
                'availableTerritories': {
                    'data': territory_data
                }
            }
        }
    }
    r = requests.post(
        'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAvailabilities',
        headers=headers(content_type=True),
        json=body
    )
    print(f"Availability set: {r.status_code}")

ここまでで、各商品のステータスが READY_TO_SUBMIT になる。

9. TestFlight での配信

9.1 App Store バージョンの作成

IAPをテストするには、App Storeバージョンを作成してビルドを紐付ける必要がある:

body = {
    'data': {
        'type': 'appStoreVersions',
        'attributes': {
            'versionString': '1.1.0',
            'platform': 'IOS',
            'releaseType': 'MANUAL',
        },
        'relationships': {
            'app': {
                'data': {'type': 'apps', 'id': app_id}
            },
            'build': {
                'data': {'type': 'builds', 'id': build_id}
            }
        }
    }
}
r = requests.post(
    'https://api.appstoreconnect.apple.com/v1/appStoreVersions',
    headers=headers(content_type=True),
    json=body
)

9.2 Export Compliance の設定

TestFlightでテスト可能にするために、暗号化に関する宣言が必要:

body = {
    'data': {
        'type': 'builds',
        'id': build_id,
        'attributes': {
            'usesNonExemptEncryption': False
        }
    }
}
requests.patch(
    f'https://api.appstoreconnect.apple.com/v1/builds/{build_id}',
    headers=headers(content_type=True),
    json=body
)

9.3 ベータグループの作成とテスターの追加

# ベータグループを作成
body = {
    'data': {
        'type': 'betaGroups',
        'attributes': {
            'name': 'Internal Testers',
            'isInternalGroup': True,
        },
        'relationships': {
            'app': {
                'data': {'type': 'apps', 'id': app_id}
            }
        }
    }
}
r = requests.post(
    'https://api.appstoreconnect.apple.com/v1/betaGroups',
    headers=headers(content_type=True),
    json=body
)
group_id = r.json()['data']['id']

# テスターを追加
body = {
    'data': {
        'type': 'betaTesters',
        'attributes': {
            'firstName': 'Taro',
            'lastName': 'Yamada',
            'email': 'taro@example.com',
        },
        'relationships': {
            'betaGroups': {
                'data': [{'type': 'betaGroups', 'id': group_id}]
            }
        }
    }
}
requests.post(
    'https://api.appstoreconnect.apple.com/v1/betaTesters',
    headers=headers(content_type=True),
    json=body
)

# ビルドをグループに追加
body = {
    'data': [{'type': 'builds', 'id': build_id}]
}
requests.post(
    f'https://api.appstoreconnect.apple.com/v1/betaGroups/{group_id}/relationships/builds',
    headers=headers(content_type=True),
    json=body
)

10. 有料アプリ契約の締結

IAPを実際に動作させるには、App Store Connect で有料アプリ契約を完了させる必要がある。これはAPIでは行えず、ブラウザから手動で設定する。

App Store Connect → ビジネス で以下を完了する:

  1. 利用規約に同意
  2. 銀行口座の登録 — 振込先の銀行口座を追加(海外送金を考慮してメガバンク推奨)
  3. 納税フォームの提出:
    • U.S. Form W-8BEN — 日本在住の個人開発者が提出する米国源泉徴収の軽減申請
      • 日米租税条約の Article 12(1) を適用することで源泉徴収率が 30% → 0% になる
    • U.S. Certificate of Foreign Status of Beneficial Owner — 米国外の受益者であることの証明

すべてのステータスが「有効」になれば、TestFlight・サンドボックス・本番でIAPが動作する。

まとめ

作業方法
商品作成API (POST /v2/inAppPurchases)
ローカライズAPI (POST /v1/inAppPurchaseLocalizations)
価格設定API (POST /v1/inAppPurchasePriceSchedules)
スクリーンショットAPI(予約→アップロード→コミットの3ステップ)
配信地域API (POST /v1/inAppPurchaseAvailabilities)
TestFlight配信API(バージョン作成→Export Compliance→ベータグループ)
有料アプリ契約手動(銀行口座・納税フォーム)

チップ機能は「消費型(Consumable)」のIAPで実装するため、サブスクリプションのような複雑な管理は不要。StoreKit 2 のシンプルなAPIと App Store Connect REST API を組み合わせることで、ほぼ全ての作業をコマンドラインから完了できる。

注意: StoreKit テスト環境(Xcode の .storekit ファイル)では、ローカライズが正しく反映されない場合がある。本番環境では App Store Connect に設定したローカライズが適用されるため、TestFlight または本番で確認すること。