TL;DR
Using the App Store Connect REST API, I completed nearly all the tasks required for iOS app review submission—metadata, screenshots, age ratings, build association, encryption compliance, pricing, and URL configuration—from the command line. This article documents the procedure in a reproducible way.
Note: As of March 2026, “App Privacy” (data usage declarations) cannot be configured via API and must be set through the App Store Connect web interface.
Prerequisites
- Enrolled in the Apple Developer Program
- API key issued in App Store Connect
- Bundle ID registered
- A build archived and uploaded via Xcode (can be uploaded with
xcodebuild -exportArchive) - Python 3 +
PyJWT+cryptographyinstalled
pip install PyJWT cryptography
1. Preparing the API Key
1.1 Issuing an API Key
Go to App Store Connect → Users and Access → Integrations → App Store Connect API and generate a new key.
- Name: Anything you like (e.g.,
deploy-key) - Access:
Admin(required for metadata updates and submission)
After generation, note the following:
| Item | Description |
|---|---|
| Key ID | The API key identifier (alphanumeric, ~10 characters) |
| Issuer ID | Your organization’s identifier (UUID format) |
Save the downloaded .p8 file in a secure location (it can only be downloaded once):
mkdir -p ~/.private_keys
mv ~/Downloads/AuthKey_XXXXXXXXXX.p8 ~/.private_keys/
Warning: The
.p8file is a private key. Never commit it to a Git repository.
1.2 JWT Token Generation (Shared Function)
The JWT token generation used for all subsequent API requests:
import jwt
import time
import os
# Read from environment variables (never hardcode)
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, # Valid for 20 minutes
"aud": "appstoreconnect-v1"
}
return jwt.encode(
payload, private_key,
algorithm="ES256",
headers={"kid": KEY_ID}
)
Set environment variables beforehand:
export ASC_KEY_ID="YOUR_KEY_ID"
export ASC_ISSUER_ID="YOUR_ISSUER_ID"
1.3 API Request Helper Function
import json
import urllib.request
def api_request(method, path, data=None):
"""Send a request to the 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. Retrieving App Information
First, retrieve the IDs needed for API operations.
2.1 Getting the App 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 Getting Version Information
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 Getting Localization IDs
result = api_request("GET",
f"appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations"
)
for loc in result["data"]:
locale = loc["attributes"]["locale"]
print(f"{locale}: {loc['id']}")
If a localization doesn’t exist, create one:
api_request("POST", "appStoreVersionLocalizations", {
"data": {
"type": "appStoreVersionLocalizations",
"attributes": {"locale": "ja"},
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
})
3. Setting Metadata
3.1 Description, Keywords, and Promotional Text
Set the description for each localization.
JA_LOC_ID = "your-ja-localization-id"
EN_LOC_ID = "your-en-localization-id"
# Japanese metadata
api_request("PATCH", f"appStoreVersionLocalizations/{JA_LOC_ID}", {
"data": {
"type": "appStoreVersionLocalizations",
"id": JA_LOC_ID,
"attributes": {
"description": "Your detailed app description in Japanese...",
"keywords": "keyword1,keyword2,keyword3",
"promotionalText": "Promotional text",
"whatsNew": "Initial release"
}
}
})
# English metadata
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 Support URL and Marketing 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. Uploading Screenshots
Screenshot uploads follow a 3-step process: reserve → upload binary → commit.
4.1 Display Types and Resolutions
The App Store Connect API requires specifying a screenshotDisplayType for each device.
| Display Type | Resolution | Device |
|---|---|---|
APP_IPHONE_67 | 1290 x 2796 | iPhone 14/15/16 Pro Max |
APP_IPHONE_65 | 1284 x 2778 | iPhone 12/13 Pro Max |
APP_IPHONE_61 | 1179 x 2556 | iPhone 14/15 |
APP_IPHONE_55 | 1242 x 2208 | iPhone 8 Plus |
APP_IPAD_PRO_3GEN_129 | 2048 x 2732 | iPad Pro 12.9" (3rd gen+) |
APP_IPAD_PRO_129 | 2048 x 2732 | iPad Pro 12.9" (older) |
Important: The resolution from the simulator may not match the API’s expected display type resolution. For example, iPhone 17 Pro Max screenshots (1320x2868) cannot be directly uploaded to
APP_IPHONE_67(1290x2796). iPad Pro 13-inch (M5) screenshots (2064x2752) also need resizing to 2048x2732. Usesipsto resize.
# Resize for APP_IPHONE_67 from iPhone 17 Pro Max
sips -z 2796 1290 screenshot.png --out screenshot_resized.png
# Resize for APP_IPAD_PRO_3GEN_129 from iPad Pro 13-inch (M5)
sips -z 2732 2048 screenshot.png --out screenshot_resized.png
4.2 Creating a Screenshot Set
def create_screenshot_set(localization_id, display_type="APP_IPHONE_67"):
"""Create a screenshot set for a localization"""
# Check for existing sets
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"]
# Create if not found
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 Uploading Screenshots (3 Steps)
import os
import hashlib
import base64
def upload_screenshot(screenshot_set_id, filepath, filename):
"""Reserve, upload, and commit a screenshot"""
with open(filepath, "rb") as f:
file_data = f.read()
filesize = len(file_data)
# Compute checksum (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: Upload binary
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: Commit
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 Example Usage
# Create screenshot sets
ja_ss_set = create_screenshot_set(JA_LOC_ID)
en_ss_set = create_screenshot_set(EN_LOC_ID)
# Upload
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)
Example output:
screenshot_01.png:
Reserved: 4a21bff6-...
Uploaded: 1 chunk(s)
Committed: UPLOAD_COMPLETE
5. Setting the Age Rating
5.1 Getting the App Info ID
result = api_request("GET", f"apps/{APP_ID}/appInfos")
APP_INFO_ID = result["data"][0]["id"]
5.2 Configuring Rating Content
All fields must be set. For a typical utility app, set everything to "NONE" / False.
api_request("PATCH", f"ageRatingDeclarations/{APP_INFO_ID}", {
"data": {
"type": "ageRatingDeclarations",
"id": APP_INFO_ID,
"attributes": {
# String type ("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",
# Boolean type
"gambling": False,
"lootBox": False,
"unrestrictedWebAccess": False,
"messagingAndChat": False,
"ageAssurance": False,
"advertising": False,
"parentalControls": False,
"userGeneratedContent": False,
"healthOrWellnessTopics": False # Note: Boolean type
}
}
})
print("Age rating set to 4+")
Gotcha:
healthOrWellnessTopicsappears to be an enum type in the documentation, but it actually requires a Boolean (True/False). Sending a string value will result in anENTITY_ERROR.ATTRIBUTE.TYPEerror.
6. Setting the Privacy Policy URL
The privacy policy URL is set on App Info Localizations (at the app info level, not the version level).
# Get 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")
Where URLs are set:
privacyPolicyUrl→ appInfoLocalizations (app info level)supportUrl,marketingUrl→ appStoreVersionLocalizations (version level)Mixing these up will result in 404 or validation errors.
7. Setting Category, Copyright, and Content Rights
7.1 Setting the App Category
Set primary and secondary categories. Category IDs include UTILITIES, REFERENCE, EDUCATION, PRODUCTIVITY, etc.
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 Setting the Copyright
Set the copyright notice on the version. Submission will fail without this.
api_request("PATCH", f"appStoreVersions/{VERSION_ID}", {
"data": {
"type": "appStoreVersions",
"id": VERSION_ID,
"attributes": {
"copyright": "2026 Your Name"
}
}
})
print("Copyright set!")
7.3 Content Rights Declaration
Declare whether the app uses third-party content. Submission will be blocked without this.
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!")
Note: Value must be either
"DOES_NOT_USE_THIRD_PARTY_CONTENT"or"USES_THIRD_PARTY_CONTENT".
8. Associating the Build
Associate an uploaded build with the version.
8.1 Getting the Build 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']}")
Note: Using
apps/{APP_ID}/buildsto list builds may return a 400 error. Usebuilds?filter[app]={APP_ID}instead.
8.2 Linking the Build to the Version
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) on success
9. Setting Encryption Compliance
After uploading a build, you must declare whether the app uses encryption. Without this, review submission will fail.
api_request("PATCH", f"builds/{BUILD_ID}", {
"data": {
"type": "builds",
"id": BUILD_ID,
"attributes": {
"usesNonExemptEncryption": False
}
}
})
print("Encryption compliance set")
Note: If your app only uses HTTPS, set
usesNonExemptEncryption: False. If you use custom encryption, set it toTrueand provide additional declarations.
10. Setting the Price
Set the app’s price. For a free app:
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": "USA"}
}
}
},
"included": [
{
"type": "appPrices",
"id": "${price1}",
"relationships": {
"priceTier": {
"data": {"type": "priceTiers", "id": "0"} # 0 = Free
}
}
}
]
})
print("Price set to free")
11. Setting Review Details
Set contact information and demo account details for the review team. Submission will fail with a 409 error without this.
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!")
Gotcha: Even when
demoAccountRequiredisFalse, you must explicitly providedemoAccountNameanddemoAccountPasswordas empty strings. Omitting them results in anENTITY_ERROR.ATTRIBUTE.REQUIREDerror.
12. Pre-Submission Checklist
A script to verify all settings are in place:
def check_submission_readiness():
# Version state
ver = api_request("GET", f"appStoreVersions/{VERSION_ID}")
attrs = ver["data"]["attributes"]
print(f"Version: {attrs['versionString']}")
print(f"State: {attrs['appStoreState']}")
# Build
build = api_request("GET",
f"appStoreVersions/{VERSION_ID}/build"
)
has_build = bool(build.get("data"))
print(f"Build: {'associated' if has_build else 'MISSING'}")
# Localizations
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')}")
# Screenshots
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}")
# Age rating
info = api_request("GET",
f"appInfos/{APP_INFO_ID}"
f"?include=ageRatingDeclaration"
)
age = info["data"]["attributes"].get("appStoreAgeRating")
print(f"\nAge Rating: {age}")
# Privacy 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. Submitting for Review
Once everything is in place, submit for review via the API. Use the reviewSubmissions API (the older appStoreVersionSubmissions endpoint is deprecated).
# Step 1: Create a review submission
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: Add the version as a submission item
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: Submit for review
api_request("PATCH", f"reviewSubmissions/{SUBMISSION_ID}", {
"data": {
"type": "reviewSubmissions",
"id": SUBMISSION_ID,
"attributes": {
"submitted": True
}
}
})
print("Submitted for review!")
Note: The
appStoreVersionSubmissionsendpoint may return 403 errors. UsereviewSubmissions+reviewSubmissionItemsinstead.
Summary
Here is a summary of all API operations covered in this article:
| Operation | Endpoint | Method |
|---|---|---|
| Search app | apps?filter[bundleId]=... | GET |
| Get version | apps/{id}/appStoreVersions | GET |
| Set metadata | appStoreVersionLocalizations/{id} | PATCH |
| Reserve screenshot | appScreenshots | POST |
| Commit screenshot | appScreenshots/{id} | PATCH |
| Age rating | ageRatingDeclarations/{id} | PATCH |
| Privacy URL | appInfoLocalizations/{id} | PATCH |
| Set category | appInfos/{id} | PATCH |
| Set copyright | appStoreVersions/{id} | PATCH |
| Content rights | apps/{id} | PATCH |
| Associate build | appStoreVersions/{id}/relationships/build | PATCH |
| Encryption compliance | builds/{id} | PATCH |
| Set pricing | apps/{id}/appPriceSchedules | POST |
| Review details | appStoreReviewDetails | POST |
| Create submission | reviewSubmissions | POST |
| Add submission item | reviewSubmissionItems | POST |
| Confirm submission | reviewSubmissions/{id} | PATCH |
What Cannot Be Done via API (as of March 2026)
| Operation | Workaround |
|---|---|
| App Privacy (data usage declarations) | Must be configured in App Store Connect web interface |
Apart from the above, nearly all tasks required for iOS app review submission can be completed via API. This is also applicable to CI/CD pipelines. With the flow xcodebuild archive → xcodebuild -exportArchive (with upload) → the API operations in this article, full automation from build to release becomes possible.
Note:
xcrun altool --upload-appis deprecated. Usexcodebuild -exportArchivewithdestination: uploadin the export options plist instead.