TL;DR

I submitted an iOS app update for review—build → upload → build association → whatsNew → review submission—entirely from the command line using the App Store Connect REST API. Unlike the initial release, metadata and screenshots carry over from the previous version, so the required operations are minimal.

Prerequisite: This guide assumes you’ve already completed the setup from Complete Guide to Submitting an iOS App for Review Using Only the App Store Connect API (API key, JWT generation, helper functions).

Overview

  1. Increment the build number
  2. Archive, export IPA, and upload (xcodebuild + xcrun altool)
  3. Confirm build processing is complete (API)
  4. Associate build with version (API)
  5. Set encryption compliance (API)
  6. Set whatsNew (release notes) (API)
  7. Submit for review (API)

1. Increment the Build Number

App Store Connect rejects uploads with duplicate build numbers. You need to increment CURRENT_PROJECT_VERSION.

If using XcodeGen, edit project.yml:

# project.yml
settings:
  base:
    MARKETING_VERSION: "1.1.0"
    CURRENT_PROJECT_VERSION: "4"  # Changed from 3 → 4

Note: The marketing version (1.1.0) is the user-facing version number, while the build number (4) is a sequential number within the same version. For bug fix resubmissions with no user-visible changes, you only need to increment the build number.

2. Archive and Upload

# Set environment variables
export APP_STORE_API_KEY="YOUR_KEY_ID"
export APP_STORE_API_ISSUER="YOUR_ISSUER_ID"

# Regenerate project with XcodeGen
xcodegen generate

# Archive
xcodebuild archive \
    -project KotenOCR.xcodeproj \
    -scheme KotenOCR \
    -archivePath build/KotenOCR.xcarchive \
    -destination "generic/platform=iOS" \
    -quiet

# Export IPA
xcodebuild -exportArchive \
    -archivePath build/KotenOCR.xcarchive \
    -exportPath build/export \
    -exportOptionsPlist scripts/ExportOptions.plist \
    -quiet

# Upload to App Store Connect
xcrun altool --upload-app \
    --type ios \
    --file build/export/KotenOCR.ipa \
    --apiKey "$APP_STORE_API_KEY" \
    --apiIssuer "$APP_STORE_API_ISSUER"

Important: Environment variables must be exported for xcrun altool to access them. source .env alone won’t pass them to subprocesses.

Example output on successful upload:

UPLOAD SUCCEEDED with no errors
Delivery UUID: cdf01bf8-...
Transferred 80033738 bytes in 7.487 seconds (10.7MB/s)

3. Confirm Build Processing

After upload, App Store Connect takes a few minutes to process the build. Wait until processingState becomes VALID.

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

build = result["data"][0]
state = build["attributes"]["processingState"]
print(f"Build state: {state}")  # VALID means ready
BUILD_ID = build["id"]

4. Associate Build with Version

Link the new build to the existing version (in PREPARE_FOR_SUBMISSION state).

# Get version ID
result = api_request("GET",
    f"apps/{APP_ID}/appStoreVersions"
    "?filter[versionString]=1.1.0&filter[platform]=IOS")
VERSION_ID = result["data"][0]["id"]
state = result["data"][0]["attributes"]["appStoreState"]
print(f"Version: {VERSION_ID}, state: {state}")

# Associate build
api_request("PATCH",
    f"appStoreVersions/{VERSION_ID}/relationships/build", {
    "data": {
        "type": "builds",
        "id": BUILD_ID
    }
})
print("Build assigned to version")

5. Set Encryption Compliance

If your app only uses standard HTTPS and no custom encryption:

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

Note: If already set, this returns a 409 error. You can safely skip it.

6. Set whatsNew (Release Notes)

The most critical step for update submissions. Each localization must have whatsNew set, or the review submission will be blocked.

# Get localizations
result = api_request("GET",
    f"appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations")

whats_new = {
    "ja": "- チップ(Tip Jar)機能を追加しました\n- 不具合を修正しました",
    "en-US": "- Added Tip Jar feature\n- Bug fixes"
}

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

    if locale in whats_new:
        api_request("PATCH",
            f"appStoreVersionLocalizations/{loc_id}", {
            "data": {
                "type": "appStoreVersionLocalizations",
                "id": loc_id,
                "attributes": {
                    "whatsNew": whats_new[locale]
                }
            }
        })
        print(f"whatsNew set for {locale}")

Difference from initial release: The initial release requires setting description, keywords, screenshots, and more. For updates, these carry over from the previous version. Only whatsNew is newly required.

7. Submit for Review

Use the reviewSubmissions API to submit for review (the older appStoreVersionSubmissions is deprecated).

# Step 1: Create review submission
result = api_request("POST", "reviewSubmissions", {
    "data": {
        "type": "reviewSubmissions",
        "attributes": {
            "platform": "IOS"
        },
        "relationships": {
            "app": {
                "data": {
                    "type": "apps",
                    "id": APP_ID
                }
            }
        }
    }
})
SUBMISSION_ID = result["data"]["id"]

# Step 2: Add version to submission
api_request("POST", "reviewSubmissionItems", {
    "data": {
        "type": "reviewSubmissionItems",
        "relationships": {
            "reviewSubmission": {
                "data": {
                    "type": "reviewSubmissions",
                    "id": SUBMISSION_ID
                }
            },
            "appStoreVersion": {
                "data": {
                    "type": "appStoreVersions",
                    "id": VERSION_ID
                }
            }
        }
    }
})

# Step 3: Submit for review
result = api_request("PATCH", f"reviewSubmissions/{SUBMISSION_ID}", {
    "data": {
        "type": "reviewSubmissions",
        "id": SUBMISSION_ID,
        "attributes": {
            "submitted": True
        }
    }
})
print(f"State: {result['data']['attributes']['state']}")
# => WAITING_FOR_REVIEW

8. Release After Review Approval

If your release method is set to “Manually release this version” in App Store Connect, the status after review approval will be PENDING_DEVELOPER_RELEASE. Use appStoreVersionReleaseRequests to release via API.

# Check version state
result = api_request("GET",
    f"apps/{APP_ID}/appStoreVersions"
    "?filter[versionString]=1.1.0&filter[platform]=IOS")
version = result["data"][0]
VERSION_ID = version["id"]
state = version["attributes"]["appStoreState"]
print(f"State: {state}")  # PENDING_DEVELOPER_RELEASE

# Release
api_request("POST", "appStoreVersionReleaseRequests", {
    "data": {
        "type": "appStoreVersionReleaseRequests",
        "relationships": {
            "appStoreVersion": {
                "data": {
                    "type": "appStoreVersions",
                    "id": VERSION_ID
                }
            }
        }
    }
})
print("Released!")

Note: After releasing, it may take a few hours for the update to appear on the App Store.

9. Setting Territory Availability

By default, your app may only be available in limited territories. To make it available worldwide, use the appAvailabilities API (v2).

# Get all territories
result = api_request("GET", "territories?limit=200")
all_territories = [t["id"] for t in result["data"]]
print(f"Total territories: {len(all_territories)}")

# Build inline creation payload
included = []
territory_refs = []
for tid in all_territories:
    local_id = f"${{territory-{tid}}}"
    territory_refs.append({"type": "territoryAvailabilities", "id": local_id})
    included.append({
        "type": "territoryAvailabilities",
        "id": local_id,
        "attributes": {"available": True},
        "relationships": {
            "territory": {
                "data": {"type": "territories", "id": tid}
            }
        }
    })

# Set availability via v2 API
token = generate_token()
url = "https://api.appstoreconnect.apple.com/v2/appAvailabilities"
data = {
    "data": {
        "type": "appAvailabilities",
        "attributes": {"availableInNewTerritories": True},
        "relationships": {
            "app": {
                "data": {"type": "apps", "id": APP_ID}
            },
            "territoryAvailabilities": {
                "data": territory_refs
            }
        }
    },
    "included": included
}

# v2 endpoint requires direct request
import urllib.request
req = urllib.request.Request(
    url, data=json.dumps(data).encode(), method="POST",
    headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
)
resp = urllib.request.urlopen(req)
print(f"Status: {resp.status}")  # 201 = success

Note: This API uses the v2 endpoint (/v2/appAvailabilities), not v1. The api_request helper uses /v1/ as the base URL, so you need to make the request directly. For inline creation, use the ${local-id} format for IDs.

Important Notes on IAP Review Submission

First IAP Submission Requires Browser

When submitting IAPs for the first time for an app, you must link them to the version via the App Store Connect browser. This cannot be done entirely through the API.

This is an Apple requirement. Calling the inAppPurchaseSubmissions API returns:

STATE_ERROR.FIRST_IAP_MUST_BE_SUBMITTED_ON_VERSION
"The first In-App Purchase for an app must be submitted for review
at the same time that you submit an app version."

Additionally, reviewSubmissionItems has no relationship for IAPs:

# Supported relationships for reviewSubmissionItems (per Apple docs)
- reviewSubmission (required)
- appStoreVersion
- appEvent
- appCustomProductPageVersion
- appStoreVersionExperiment / V2
- gameCenterLeaderboardVersion, etc.
# No IAP-related relationship exists

First IAP Submission Steps

  1. Use the API to create the version, assign the build, and set whatsNew
  2. Open the version page in the App Store Connect browser
  3. In the “In-App Purchases and Subscriptions” section, click “Select In-App Purchases or Subscriptions
  4. Check the IAP products and click “Done”
  5. Use the API to create a reviewSubmission and submit for review
# Step 5: After linking IAPs in browser, submit via API
result = api_request("POST", "reviewSubmissions", {
    "data": {
        "type": "reviewSubmissions",
        "attributes": {"platform": "IOS"},
        "relationships": {
            "app": {"data": {"type": "apps", "id": APP_ID}}
        }
    }
})
SUBMISSION_ID = result["data"]["id"]

api_request("POST", "reviewSubmissionItems", {
    "data": {
        "type": "reviewSubmissionItems",
        "relationships": {
            "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": SUBMISSION_ID}},
            "appStoreVersion": {"data": {"type": "appStoreVersions", "id": VERSION_ID}}
        }
    }
})

api_request("PATCH", f"reviewSubmissions/{SUBMISSION_ID}", {
    "data": {
        "type": "reviewSubmissions",
        "id": SUBMISSION_ID,
        "attributes": {"submitted": True}
    }
})

Subsequent IAP Additions

Once the first IAP is approved, additional IAPs can be submitted individually via the inAppPurchaseSubmissions API (no version submission needed).

api_request("POST", "inAppPurchaseSubmissions", {
    "data": {
        "type": "inAppPurchaseSubmissions",
        "relationships": {
            "inAppPurchaseV2": {
                "data": {"type": "inAppPurchases", "id": IAP_ID}
            }
        }
    }
})

Verification

After review approval, check IAP product states via API:

result = api_request("GET", f"apps/{APP_ID}/inAppPurchasesV2")
for iap in result["data"]:
    state = iap["attributes"]["state"]
    product_id = iap["attributes"]["productId"]
    print(f"{product_id}: {state}")
    # APPROVED = OK, READY_TO_SUBMIT = not included in review

Lesson learned: Verify IAP submission by checking whether state changes to APPROVED after app approval. If it remains READY_TO_SUBMIT, the IAPs were not included. Link them in the browser and resubmit.

Reference: For IAP product registration, see Complete Guide to Adding a Tip Jar Feature Using the App Store Connect API.

Summary

The API operations required for an update submission:

OperationEndpointMethod
Check build statusbuilds?filter[app]={id}GET
Associate buildappStoreVersions/{id}/relationships/buildPATCH
Encryption compliancebuilds/{id}PATCH
Set whatsNewappStoreVersionLocalizations/{id}PATCH
Create submissionreviewSubmissionsPOST
Add submission itemreviewSubmissionItemsPOST
Confirm submissionreviewSubmissions/{id}PATCH
ReleaseappStoreVersionReleaseRequestsPOST
Territory availabilityv2/appAvailabilitiesPOST

Compared to the initial release, the required operations are minimal. From build upload to release, everything can be done from the command line without opening a browser. This makes it straightforward to integrate into a CI/CD pipeline.