TL;DR

App Store Connect の REST API を使い、コマンドラインからiOSアプリの審査提出に必要なほぼ全作業(メタデータ・スクリーンショット・年齢レーティング・ビルド紐付け・URL設定・暗号化コンプライアンス・価格設定)を完了させた。本記事ではその手順を再現可能な形で記載する。

注意: 「App Privacy(アプリのプライバシー)」のデータ使用状況の宣言だけは、2026年3月時点でAPIが提供されておらず、App Store Connectのブラウザから設定する必要がある。

前提条件

  • Apple Developer Program に登録済み
  • App Store Connect で API キーを発行済み
  • アプリの Bundle ID が登録済み
  • Xcode でアーカイブ・アップロード済みのビルドが存在する(xcodebuild -exportArchive でアップロード可能)
  • Python 3 + PyJWT + cryptography がインストール済み
pip install PyJWT cryptography

1. API キーの準備

1.1 API キーの発行

App Store Connect → ユーザーとアクセス → 統合 → App Store Connect API から新しいキーを発行する。

  • 名前: 任意(例: deploy-key
  • アクセス: Admin(メタデータ更新・提出に必要)

発行後、以下の情報をメモする:

項目説明
Key IDAPIキーの識別子(10文字程度の英数字)
Issuer ID組織の識別子(UUID形式)

ダウンロードした .p8 ファイルは安全な場所に保存する(一度しかダウンロードできない):

mkdir -p ~/.private_keys
mv ~/Downloads/AuthKey_XXXXXXXXXX.p8 ~/.private_keys/

注意: .p8 ファイルは秘密鍵です。Gitリポジトリにコミットしないこと。

1.2 JWT トークン生成(共通関数)

以降のすべてのAPIリクエストで使用するJWTトークン生成処理:

import jwt
import time
import os

# 環境変数から読み込む(ハードコードしない)
KEY_ID = os.environ["ASC_KEY_ID"]
ISSUER_ID = os.environ["ASC_ISSUER_ID"]
KEY_PATH = os.path.expanduser("~/.private_keys/AuthKey_" + KEY_ID + ".p8")

def generate_token():
    with open(KEY_PATH, "r") as f:
        private_key = f.read()

    now = int(time.time())
    payload = {
        "iss": ISSUER_ID,
        "iat": now,
        "exp": now + 1200,  # 20分有効
        "aud": "appstoreconnect-v1"
    }
    return jwt.encode(
        payload, private_key,
        algorithm="ES256",
        headers={"kid": KEY_ID}
    )

事前に環境変数を設定しておく:

export ASC_KEY_ID="YOUR_KEY_ID"
export ASC_ISSUER_ID="YOUR_ISSUER_ID"

1.3 API リクエストのヘルパー関数

import json
import urllib.request

def api_request(method, path, data=None):
    """App Store Connect API へのリクエストを送信"""
    token = generate_token()
    url = f"https://api.appstoreconnect.apple.com/v1/{path}"

    body = json.dumps(data).encode() if data else None
    req = urllib.request.Request(
        url, data=body, method=method,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        }
    )

    try:
        resp = urllib.request.urlopen(req)
        if resp.status == 204:
            return None
        return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        print(f"Error {e.code}: {e.read().decode()}")
        raise

2. アプリ情報の取得

まず、APIで操作する対象のIDを取得する。

2.1 アプリIDの確認

result = api_request("GET", "apps?filter[bundleId]=com.example.myapp")
app = result["data"][0]
APP_ID = app["id"]
print(f"App ID: {APP_ID}")

2.2 バージョン情報の取得

result = api_request("GET",
    f"apps/{APP_ID}/appStoreVersions"
    f"?filter[appStoreState]=PREPARE_FOR_SUBMISSION"
)
version = result["data"][0]
VERSION_ID = version["id"]
print(f"Version ID: {VERSION_ID}")
print(f"State: {version['attributes']['appStoreState']}")

2.3 ローカリゼーションIDの取得

result = api_request("GET",
    f"appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations"
)
for loc in result["data"]:
    locale = loc["attributes"]["locale"]
    print(f"{locale}: {loc['id']}")

ローカリゼーションが存在しない場合は作成する:

api_request("POST", "appStoreVersionLocalizations", {
    "data": {
        "type": "appStoreVersionLocalizations",
        "attributes": {"locale": "ja"},
        "relationships": {
            "appStoreVersion": {
                "data": {"type": "appStoreVersions", "id": VERSION_ID}
            }
        }
    }
})

3. メタデータの設定

3.1 説明文・キーワード・プロモーションテキスト

各ローカリゼーションに対して説明文を設定する。

JA_LOC_ID = "your-ja-localization-id"
EN_LOC_ID = "your-en-localization-id"

# 日本語メタデータ
api_request("PATCH", f"appStoreVersionLocalizations/{JA_LOC_ID}", {
    "data": {
        "type": "appStoreVersionLocalizations",
        "id": JA_LOC_ID,
        "attributes": {
            "description": "アプリの詳細な説明文をここに記載...",
            "keywords": "キーワード1,キーワード2,キーワード3",
            "promotionalText": "プロモーションテキスト",
            "whatsNew": "初回リリース"
        }
    }
})

# 英語メタデータ
api_request("PATCH", f"appStoreVersionLocalizations/{EN_LOC_ID}", {
    "data": {
        "type": "appStoreVersionLocalizations",
        "id": EN_LOC_ID,
        "attributes": {
            "description": "Your app description here...",
            "keywords": "keyword1,keyword2,keyword3",
            "promotionalText": "Promotional text",
            "whatsNew": "Initial release"
        }
    }
})
print("Metadata updated!")

3.2 サポートURL・マーケティングURL

for loc_id in [JA_LOC_ID, EN_LOC_ID]:
    api_request("PATCH", f"appStoreVersionLocalizations/{loc_id}", {
        "data": {
            "type": "appStoreVersionLocalizations",
            "id": loc_id,
            "attributes": {
                "supportUrl": "https://example.com",
                "marketingUrl": "https://example.com"
            }
        }
    })
print("URLs updated!")

4. スクリーンショットのアップロード

スクリーンショットのアップロードは3段階(予約 → バイナリ送信 → コミット)で行う。

4.1 ディスプレイタイプと解像度

App Store Connect APIでは、デバイスごとに screenshotDisplayType を指定する必要がある。

Display Type解像度デバイス
APP_IPHONE_671290 x 2796iPhone 14/15/16 Pro Max
APP_IPHONE_651284 x 2778iPhone 12/13 Pro Max
APP_IPHONE_611179 x 2556iPhone 14/15
APP_IPHONE_551242 x 2208iPhone 8 Plus
APP_IPAD_PRO_3GEN_1292048 x 2732iPad Pro 12.9"(第3世代以降)
APP_IPAD_PRO_1292048 x 2732iPad Pro 12.9"(旧世代)

重要: シミュレータのデバイスとAPIのディスプレイタイプで求められる解像度が一致しない場合がある。例えば iPhone 17 Pro Max (1320x2868) のスクリーンショットは APP_IPHONE_67 (1290x2796) にそのままアップロードできない。sips でリサイズする必要がある。iPad Pro 13-inch (M5) の場合も 2064x2752 → 2048x2732 へのリサイズが必要。

# iPhone 17 Pro Max → APP_IPHONE_67 用にリサイズ
sips -z 2796 1290 screenshot.png --out screenshot_resized.png

# iPad Pro 13-inch (M5) → APP_IPAD_PRO_3GEN_129 用にリサイズ
sips -z 2732 2048 screenshot.png --out screenshot_resized.png

4.2 スクリーンショットセットの作成

def create_screenshot_set(localization_id, display_type="APP_IPHONE_67"):
    """ローカリゼーションにスクリーンショットセットを作成"""
    # 既存のセットを確認
    result = api_request("GET",
        f"appStoreVersionLocalizations/{localization_id}/appScreenshotSets"
    )
    for ss_set in result["data"]:
        if ss_set["attributes"]["screenshotDisplayType"] == display_type:
            return ss_set["id"]

    # なければ作成
    result = api_request("POST", "appScreenshotSets", {
        "data": {
            "type": "appScreenshotSets",
            "attributes": {"screenshotDisplayType": display_type},
            "relationships": {
                "appStoreVersionLocalization": {
                    "data": {
                        "type": "appStoreVersionLocalizations",
                        "id": localization_id
                    }
                }
            }
        }
    })
    return result["data"]["id"]

4.3 スクリーンショットのアップロード(3ステップ)

import os

import hashlib
import base64

def upload_screenshot(screenshot_set_id, filepath, filename):
    """スクリーンショットを予約→アップロード→コミットする"""
    with open(filepath, "rb") as f:
        file_data = f.read()
    filesize = len(file_data)

    # チェックサムを自前で計算(MD5 → Base64)
    checksum = base64.b64encode(
        hashlib.md5(file_data).digest()
    ).decode()

    # Step 1: 予約(Reserve)
    result = api_request("POST", "appScreenshots", {
        "data": {
            "type": "appScreenshots",
            "attributes": {
                "fileName": filename,
                "fileSize": filesize
            },
            "relationships": {
                "appScreenshotSet": {
                    "data": {
                        "type": "appScreenshotSets",
                        "id": screenshot_set_id
                    }
                }
            }
        }
    })

    screenshot_id = result["data"]["id"]
    upload_ops = result["data"]["attributes"]["uploadOperations"]
    print(f"  Reserved: {screenshot_id}")

    # Step 2: バイナリアップロード
    for op in upload_ops:
        chunk = file_data[op["offset"]:op["offset"] + op["length"]]
        req = urllib.request.Request(
            op["url"], data=chunk, method=op["method"]
        )
        for h in op["requestHeaders"]:
            req.add_header(h["name"], h["value"])
        urllib.request.urlopen(req)
    print(f"  Uploaded: {len(upload_ops)} chunk(s)")

    # Step 3: コミット
    result = api_request("PATCH", f"appScreenshots/{screenshot_id}", {
        "data": {
            "type": "appScreenshots",
            "id": screenshot_id,
            "attributes": {
                "uploaded": True,
                "sourceFileChecksum": checksum
            }
        }
    })
    state = result["data"]["attributes"]["assetDeliveryState"]["state"]
    print(f"  Committed: {state}")
    return screenshot_id

4.4 実行例

# スクリーンショットセットを作成
ja_ss_set = create_screenshot_set(JA_LOC_ID)
en_ss_set = create_screenshot_set(EN_LOC_ID)

# アップロード
screenshots = [
    ("screenshot_01.png", "/path/to/resized/screenshot1.png"),
    ("screenshot_02.png", "/path/to/resized/screenshot2.png"),
    ("screenshot_03.png", "/path/to/resized/screenshot3.png"),
]

for filename, filepath in screenshots:
    print(f"\n{filename}:")
    upload_screenshot(ja_ss_set, filepath, filename)
    upload_screenshot(en_ss_set, filepath, filename)

出力例:

screenshot_01.png:
  Reserved: 4a21bff6-...
  Uploaded: 1 chunk(s)
  Committed: UPLOAD_COMPLETE

5. 年齢レーティングの設定

5.1 App Info ID の取得

result = api_request("GET", f"apps/{APP_ID}/appInfos")
APP_INFO_ID = result["data"][0]["id"]

5.2 レーティング内容の設定

すべての項目を設定する必要がある。一般的なツール系アプリの場合、すべて "NONE" / False にする。

api_request("PATCH", f"ageRatingDeclarations/{APP_INFO_ID}", {
    "data": {
        "type": "ageRatingDeclarations",
        "id": APP_INFO_ID,
        "attributes": {
            # 文字列型("NONE", "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE")
            "alcoholTobaccoOrDrugUseOrReferences": "NONE",
            "contests": "NONE",
            "gamblingSimulated": "NONE",
            "gunsOrOtherWeapons": "NONE",
            "horrorOrFearThemes": "NONE",
            "matureOrSuggestiveThemes": "NONE",
            "medicalOrTreatmentInformation": "NONE",
            "profanityOrCrudeHumor": "NONE",
            "sexualContentGraphicAndNudity": "NONE",
            "sexualContentOrNudity": "NONE",
            "violenceCartoonOrFantasy": "NONE",
            "violenceRealistic": "NONE",
            "violenceRealisticProlongedGraphicOrSadistic": "NONE",
            # ブール型
            "gambling": False,
            "lootBox": False,
            "unrestrictedWebAccess": False,
            "messagingAndChat": False,
            "ageAssurance": False,
            "advertising": False,
            "parentalControls": False,
            "userGeneratedContent": False,
            "healthOrWellnessTopics": False   # ← 注意: ブール型
        }
    }
})
print("Age rating set to 4+")

注意点: healthOrWellnessTopics はドキュメント上では列挙型のように見えるが、実際にはブール型(True/False)で指定する必要がある。文字列で送ると ENTITY_ERROR.ATTRIBUTE.TYPE エラーになる。

6. プライバシーポリシーURLの設定

プライバシーポリシーURLは App Info Localizations(バージョンではなくアプリ情報レベル)に設定する。

# App Info Localizations を取得
result = api_request("GET",
    f"appInfos/{APP_INFO_ID}/appInfoLocalizations"
)

for loc in result["data"]:
    loc_id = loc["id"]
    locale = loc["attributes"]["locale"]

    api_request("PATCH", f"appInfoLocalizations/{loc_id}", {
        "data": {
            "type": "appInfoLocalizations",
            "id": loc_id,
            "attributes": {
                "privacyPolicyUrl": "https://example.com/privacy-policy.html"
            }
        }
    })
    print(f"{locale}: Privacy URL set")

URLの設定先の違い:

  • privacyPolicyUrlappInfoLocalizations(アプリ情報レベル)
  • supportUrl, marketingUrlappStoreVersionLocalizations(バージョンレベル)

間違えると 404 やバリデーションエラーになるので注意。

7. カテゴリ・著作権・コンテンツ権利の設定

7.1 アプリカテゴリの設定

プライマリカテゴリとセカンダリカテゴリを設定する。カテゴリIDは UTILITIES, REFERENCE, EDUCATION, PRODUCTIVITY など。

api_request("PATCH", f"appInfos/{APP_INFO_ID}", {
    "data": {
        "type": "appInfos",
        "id": APP_INFO_ID,
        "relationships": {
            "primaryCategory": {
                "data": {"type": "appCategories", "id": "REFERENCE"}
            },
            "secondaryCategory": {
                "data": {"type": "appCategories", "id": "UTILITIES"}
            }
        }
    }
})
print("Categories set!")

7.2 著作権の設定

バージョンに著作権表記を設定する。未設定だと審査提出時にエラーになる。

api_request("PATCH", f"appStoreVersions/{VERSION_ID}", {
    "data": {
        "type": "appStoreVersions",
        "id": VERSION_ID,
        "attributes": {
            "copyright": "2026 Your Name"
        }
    }
})
print("Copyright set!")

7.3 コンテンツ権利の宣言

サードパーティのコンテンツを使用しているかの宣言。未設定だと審査提出がブロックされる。

api_request("PATCH", f"apps/{APP_ID}", {
    "data": {
        "type": "apps",
        "id": APP_ID,
        "attributes": {
            "contentRightsDeclaration": "DOES_NOT_USE_THIRD_PARTY_CONTENT"
        }
    }
})
print("Content rights declared!")

注意: 値は "DOES_NOT_USE_THIRD_PARTY_CONTENT" または "USES_THIRD_PARTY_CONTENT" のいずれか。

8. ビルドの紐付け

アップロード済みのビルドをバージョンに紐付ける。

8.1 ビルドIDの取得

result = api_request("GET",
    f"builds?filter[app]={APP_ID}&sort=-uploadedDate&limit=5"
)

for build in result["data"]:
    attrs = build["attributes"]
    print(f"ID: {build['id']}")
    print(f"  Version: {attrs['version']}")
    print(f"  State: {attrs['processingState']}")
    print(f"  Uploaded: {attrs['uploadedDate']}")

注意: ビルドの一覧取得に apps/{APP_ID}/builds を使うと 400 エラーになる場合がある。builds?filter[app]={APP_ID} を使うこと。

8.2 ビルドとバージョンの関連付け

BUILD_ID = "your-build-id"

api_request("PATCH",
    f"appStoreVersions/{VERSION_ID}/relationships/build",
    {
        "data": {
            "type": "builds",
            "id": BUILD_ID
        }
    }
)
print("Build associated!")
# => HTTP 204 (No Content) で成功

9. 暗号化コンプライアンスの設定

ビルドをアップロードした後、暗号化の使用有無を申告する必要がある。これを設定しないと審査提出でエラーになる。

api_request("PATCH", f"builds/{BUILD_ID}", {
    "data": {
        "type": "builds",
        "id": BUILD_ID,
        "attributes": {
            "usesNonExemptEncryption": False
        }
    }
})
print("Encryption compliance set")

補足: HTTPS通信のみの場合は usesNonExemptEncryption: False でよい。独自の暗号化を使用している場合は True を設定し、追加の申告が必要になる。

10. 価格の設定

アプリの価格を設定する。無料アプリの場合:

# 無料に設定
api_request("POST", f"apps/{APP_ID}/appPriceSchedules", {
    "data": {
        "type": "appPriceSchedules",
        "relationships": {
            "app": {
                "data": {"type": "apps", "id": APP_ID}
            },
            "manualPrices": {
                "data": [
                    {"type": "appPrices", "id": "${price1}"}
                ]
            },
            "baseTerritory": {
                "data": {"type": "territories", "id": "JPN"}
            }
        }
    },
    "included": [
        {
            "type": "appPrices",
            "id": "${price1}",
            "relationships": {
                "priceTier": {
                    "data": {"type": "priceTiers", "id": "0"}  # 0 = 無料
                }
            }
        }
    ]
})
print("Price set to free")

11. レビュー詳細の設定

審査チームへの連絡先とデモアカウント情報を設定する。これを設定しないと審査提出時に 409 エラーになる。

api_request("POST", "appStoreReviewDetails", {
    "data": {
        "type": "appStoreReviewDetails",
        "attributes": {
            "contactFirstName": "Taro",
            "contactLastName": "Yamada",
            "contactEmail": "taro@example.com",
            "contactPhone": "+81-90-0000-0000",
            "demoAccountRequired": False,
            "demoAccountName": "",
            "demoAccountPassword": "",
            "notes": ""
        },
        "relationships": {
            "appStoreVersion": {
                "data": {"type": "appStoreVersions", "id": VERSION_ID}
            }
        }
    }
})
print("Review detail created!")

注意: demoAccountRequiredFalse にしても、demoAccountNamedemoAccountPassword は空文字で明示的に指定する必要がある。省略すると ENTITY_ERROR.ATTRIBUTE.REQUIRED エラーになる。

12. 提出前の最終確認

すべての設定が揃っているか確認するスクリプト:

def check_submission_readiness():
    # バージョン状態
    ver = api_request("GET", f"appStoreVersions/{VERSION_ID}")
    attrs = ver["data"]["attributes"]
    print(f"Version: {attrs['versionString']}")
    print(f"State: {attrs['appStoreState']}")

    # ビルド
    build = api_request("GET",
        f"appStoreVersions/{VERSION_ID}/build"
    )
    has_build = bool(build.get("data"))
    print(f"Build: {'associated' if has_build else 'MISSING'}")

    # ローカリゼーション
    locs = api_request("GET",
        f"appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations"
    )
    for loc in locs["data"]:
        a = loc["attributes"]
        locale = a["locale"]
        print(f"\n{locale}:")
        print(f"  Description: {'OK' if a.get('description') else 'MISSING'}")
        print(f"  Keywords: {'OK' if a.get('keywords') else 'MISSING'}")
        print(f"  Support URL: {a.get('supportUrl', 'MISSING')}")

        # スクリーンショット
        ss = api_request("GET",
            f"appStoreVersionLocalizations/{loc['id']}"
            f"/appScreenshotSets?include=appScreenshots"
        )
        for ss_set in ss["data"]:
            dtype = ss_set["attributes"]["screenshotDisplayType"]
            count = len([
                x for x in ss.get("included", [])
                if x["type"] == "appScreenshots"
            ])
            print(f"  Screenshots ({dtype}): {count}")

    # 年齢レーティング
    info = api_request("GET",
        f"appInfos/{APP_INFO_ID}"
        f"?include=ageRatingDeclaration"
    )
    age = info["data"]["attributes"].get("appStoreAgeRating")
    print(f"\nAge Rating: {age}")

    # プライバシーURL
    info_locs = api_request("GET",
        f"appInfos/{APP_INFO_ID}/appInfoLocalizations"
    )
    for il in info_locs["data"]:
        locale = il["attributes"]["locale"]
        privacy = il["attributes"].get("privacyPolicyUrl", 'MISSING')
        print(f"Privacy URL ({locale}): {privacy}")

check_submission_readiness()

13. 審査提出

すべてが揃ったら、APIから審査提出できる。審査提出は reviewSubmissions API を使う(旧 appStoreVersionSubmissions は非推奨)。

# Step 1: レビュー提出を作成
result = api_request("POST", "reviewSubmissions", {
    "data": {
        "type": "reviewSubmissions",
        "relationships": {
            "app": {
                "data": {"type": "apps", "id": APP_ID}
            }
        }
    }
})
SUBMISSION_ID = result["data"]["id"]
print(f"Submission created: {SUBMISSION_ID}")

# Step 2: バージョンを提出アイテムとして追加
api_request("POST", "reviewSubmissionItems", {
    "data": {
        "type": "reviewSubmissionItems",
        "relationships": {
            "reviewSubmission": {
                "data": {"type": "reviewSubmissions", "id": SUBMISSION_ID}
            },
            "appStoreVersion": {
                "data": {"type": "appStoreVersions", "id": VERSION_ID}
            }
        }
    }
})
print("Version added to submission")

# Step 3: 審査に提出
api_request("PATCH", f"reviewSubmissions/{SUBMISSION_ID}", {
    "data": {
        "type": "reviewSubmissions",
        "id": SUBMISSION_ID,
        "attributes": {
            "submitted": True
        }
    }
})
print("Submitted for review!")

注意: appStoreVersionSubmissions エンドポイントは 403 エラーを返す場合がある。reviewSubmissions + reviewSubmissionItems の2段階で提出すること。

まとめ

本記事で紹介したAPIの操作一覧:

操作エンドポイントメソッド
アプリ検索apps?filter[bundleId]=...GET
バージョン取得apps/{id}/appStoreVersionsGET
メタデータ設定appStoreVersionLocalizations/{id}PATCH
スクリーンショット予約appScreenshotsPOST
スクリーンショットコミットappScreenshots/{id}PATCH
年齢レーティングageRatingDeclarations/{id}PATCH
プライバシーURLappInfoLocalizations/{id}PATCH
カテゴリ設定appInfos/{id}PATCH
著作権設定appStoreVersions/{id}PATCH
コンテンツ権利宣言apps/{id}PATCH
ビルド紐付けappStoreVersions/{id}/relationships/buildPATCH
暗号化コンプライアンスbuilds/{id}PATCH
価格設定apps/{id}/appPriceSchedulesPOST
レビュー詳細appStoreReviewDetailsPOST
審査提出(作成)reviewSubmissionsPOST
審査提出(アイテム追加)reviewSubmissionItemsPOST
審査提出(確定)reviewSubmissions/{id}PATCH

APIでできないこと(2026年3月時点)

操作対応
App Privacy(データ使用状況の宣言)App Store Connectのブラウザから設定が必要

上記を除き、iOSアプリの審査提出に必要なほぼすべての作業をAPIで完結できる。CI/CDパイプラインの構築にも応用可能で、xcodebuild archivexcodebuild -exportArchive(アップロード付き)→ 本記事のAPI操作という流れで、ビルドからリリースまでの自動化が可能になる。

補足: xcrun altool --upload-app は非推奨。xcodebuild -exportArchivedestination: upload を指定する方法が推奨されている。

参考