TL;DR
iOSアプリにチップ(Tip Jar)機能を追加した。SwiftUI + StoreKit 2 でアプリ側を実装し、App Store Connect REST API を使って商品登録・ローカライズ・価格設定・審査用スクリーンショット・配信地域設定・TestFlight配信までをコマンドラインから完了させた。本記事ではその全手順を再現可能な形で記載する。
前提: App Store Connect APIだけでiOSアプリを審査提出する完全ガイドの続編として、APIキーの取得・JWT生成は既にセットアップ済みとする。
全体の流れ
- アプリ側の実装(StoreKit 2 + SwiftUI)
- App Store Connect APIで商品登録(3つの消費型アイテム)
- ローカライズ設定(日本語・英語)
- 価格設定($0.99 / $2.99 / $6.99)
- 審査用スクリーンショットのアップロード
- 配信地域の設定
- 有料アプリ契約の締結
- 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 トランザクション監視
App の init() でトランザクションリスナーを設定する:
@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 → ビジネス で以下を完了する:
- 利用規約に同意
- 銀行口座の登録 — 振込先の銀行口座を追加(海外送金を考慮してメガバンク推奨)
- 納税フォームの提出:
- U.S. Form W-8BEN — 日本在住の個人開発者が提出する米国源泉徴収の軽減申請
- 日米租税条約の Article 12(1) を適用することで源泉徴収率が 30% → 0% になる
- U.S. Certificate of Foreign Status of Beneficial Owner — 米国外の受益者であることの証明
- U.S. Form W-8BEN — 日本在住の個人開発者が提出する米国源泉徴収の軽減申請
すべてのステータスが「有効」になれば、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 または本番で確認すること。