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 + cryptography installed
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:

ItemDescription
Key IDThe API key identifier (alphanumeric, ~10 characters)
Issuer IDYour 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 .p8 file 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 TypeResolutionDevice
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" (3rd gen+)
APP_IPAD_PRO_1292048 x 2732iPad 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. Use sips to 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: healthOrWellnessTopics appears to be an enum type in the documentation, but it actually requires a Boolean (True/False). Sending a string value will result in an ENTITY_ERROR.ATTRIBUTE.TYPE error.

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:

  • privacyPolicyUrlappInfoLocalizations (app info level)
  • supportUrl, marketingUrlappStoreVersionLocalizations (version level)

Mixing these up will result in 404 or validation errors.

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!")

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}/builds to list builds may return a 400 error. Use builds?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 to True and 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 demoAccountRequired is False, you must explicitly provide demoAccountName and demoAccountPassword as empty strings. Omitting them results in an ENTITY_ERROR.ATTRIBUTE.REQUIRED error.

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 appStoreVersionSubmissions endpoint may return 403 errors. Use reviewSubmissions + reviewSubmissionItems instead.

Summary

Here is a summary of all API operations covered in this article:

OperationEndpointMethod
Search appapps?filter[bundleId]=...GET
Get versionapps/{id}/appStoreVersionsGET
Set metadataappStoreVersionLocalizations/{id}PATCH
Reserve screenshotappScreenshotsPOST
Commit screenshotappScreenshots/{id}PATCH
Age ratingageRatingDeclarations/{id}PATCH
Privacy URLappInfoLocalizations/{id}PATCH
Set categoryappInfos/{id}PATCH
Set copyrightappStoreVersions/{id}PATCH
Content rightsapps/{id}PATCH
Associate buildappStoreVersions/{id}/relationships/buildPATCH
Encryption compliancebuilds/{id}PATCH
Set pricingapps/{id}/appPriceSchedulesPOST
Review detailsappStoreReviewDetailsPOST
Create submissionreviewSubmissionsPOST
Add submission itemreviewSubmissionItemsPOST
Confirm submissionreviewSubmissions/{id}PATCH

What Cannot Be Done via API (as of March 2026)

OperationWorkaround
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 archivexcodebuild -exportArchive (with upload) → the API operations in this article, full automation from build to release becomes possible.

Note: xcrun altool --upload-app is deprecated. Use xcodebuild -exportArchive with destination: upload in the export options plist instead.

References