App Store Submission (API-first)
Submit a native iOS/iPadOS app to the App Store with the App Store Connect (ASC) API and the Xcode command line, doing as much as possible programmatically. This skill captures a complete, repeatable workflow plus the non-obvious blockers that waste hours.
Use the bundled scripts in scripts/. Per-project values and the metadata copy
go in the project's .env (see .env.example) and the template at the end of
this doc. Placeholders below use <ANGLE_BRACKETS> — replace them with your own values.
What the API CAN and CANNOT do
API can: create/read the app record, set category & pricing, set version metadata
(description, keywords, subtitle, promo text, support/marketing URLs, copyright,
privacyPolicyUrl), create the App Review contact, upload builds (via altool),
attach a build, upload screenshots, create a review submission, and submit for review.
API CANNOT (must be done once in the web UI):
- App Privacy "nutrition label" (
appDataUsages). There is no public API — the app resource exposes noappDataUsagesrelationship; every path 404s. Set it in the UI: App Privacy → Get Started → declare what you collect (or "No, we do not collect data") → Publish. - Age rating / content rights declarations are also effectively UI-only.
- Deleting an empty draft review submission returns 403 — harmless, leave or delete in UI.
Plan for one short UI visit per app for the App Privacy publish. Everything else is scriptable.
Prerequisites (one-time per Apple account)
Paid Apple Developer Program membership (accept the latest PLA in the portal).
Generate the App Store Connect API key — the ONE unavoidable portal step. An ASC API key cannot be created via API (chicken-and-egg); the account holder must generate it once in the web UI. After that, this skill drives everything else without touching the portal. The exact clicks:
- Sign in at https://appstoreconnect.apple.com as the Account Holder / Admin.
- Users and Access → top tab Integrations → App Store Connect API → Team Keys.
- Click + (Generate API Key). Name it (e.g. "automation"), set Access = Admin (or at least App Manager), Generate.
- Download the
AuthKey_<ASC_KEY_ID>.p8— this is offered only once. Save it to~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8thenchmod 600it. - Copy the Key ID (the 10-char id in the row) and the Issuer ID (UUID shown above the keys list).
These three values are all the skill needs. If a key is ever lost/leaked, Revoke it in the same screen and generate a new one.
Put the Key ID and Issuer ID in a local
.env(gitignored) and pointASC_PRIVATE_KEY_PATHat the.p8. See .env.example. The.p8lives outside the repo and is never committed (.gitignoreexcludes.envand*.p8).
# .env (gitignored)
ASC_KEY_ID=<ASC_KEY_ID>
ASC_ISSUER_ID=<ASC_ISSUER_ID> # xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ASC_PRIVATE_KEY_PATH=~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8
Load it before running scripts: set -a; source .env; set +a
The workflow
0. Pre-flight code checklist (in the repo)
- App icon 1024×1024, no alpha in the asset catalog.
CFBundleShortVersionString(marketing, e.g.1.0) andCFBundleVersion(build, integer, bump on every upload).ITSAppUsesNonExemptEncryption = falsein Info.plist (skips the export-compliance prompt) — only if you use no non-exempt crypto.- Usage-description strings for every permission (
NSMicrophoneUsageDescription, etc.). UIRequiredDeviceCapabilities = arm64(never the legacyarmv7).PrivacyInfo.xcprivacyprivacy manifest (tracking false, collected types, required-reason APIs).- For iPad-only:
TARGETED_DEVICE_FAMILY = 2. For iPhone-only:1. Universal:1,2. - Per-config entitlements if using CloudKit/push: Debug →
aps-environment=development, Release →production.
1. Archive + upload the build (Xcode CLI)
Replace <YourApp>.xcodeproj and scheme <YourApp> with your project's names.
Optional pattern — XcodeGen. If you generate the Xcode project with XcodeGen from a
project.yml, regenerate it first (xcodegen generate) so version/build/bundle id/device family live in one source of truth, and editproject.ymlinstead of the.pbxproj. This is entirely optional — a hand-managed.xcodeprojworks the same way for everything below.
# xcodegen generate # only if you use XcodeGen (produces <YourApp>.xcodeproj)
xcodebuild -project <YourApp>.xcodeproj -scheme <YourApp> -configuration Release \
-archivePath /tmp/<YourApp>.xcarchive archive
xcodebuild -exportArchive -archivePath /tmp/<YourApp>.xcarchive \
-exportPath /tmp/export -exportOptionsPlist ExportOptions.plist # method: app-store
xcrun altool --validate-app -f /tmp/export/<YourApp>.ipa -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app -f /tmp/export/<YourApp>.ipa -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
altool reads the .p8 from ~/.appstoreconnect/private_keys/ automatically (the file is
AuthKey_<ASC_KEY_ID>.p8). Manual signing: set the <DISTRIBUTION_IDENTITY> signing
identity (e.g. "Apple Distribution: ") and the <PROVISIONING_PROFILE> profile
in ExportOptions.plist. Build processing takes ~5–30 min; poll until state is VALID.
2. Everything else (ASC API)
Use scripts/asc_submit.py — it loads .env, mints a JWT via
scripts/asc_jwt.swift, and exposes subcommands:
python3 scripts/asc_submit.py status # app id, version, build, blockers
python3 scripts/asc_submit.py set-metadata # copyright, privacyPolicyUrl, URLs
python3 scripts/asc_submit.py review-contact # App Review contact (required)
python3 scripts/asc_submit.py attach-build --build 2
python3 scripts/asc_submit.py screenshots --type APP_IPAD_PRO_3GEN_129 a.png b.png
python3 scripts/asc_submit.py submit # create review submission + submit
3. Submit for review
submit creates a reviewSubmission, adds the version as a reviewSubmissionItem, then
PATCHes submitted=true. On success the version state becomes WAITING_FOR_REVIEW. The
command prints any blocker codes returned in associatedErrors.
4. CloudKit Production schema deploy (if the app uses CloudKit/SwiftData+CloudKit)
Not a review blocker, but ships broken sync if skipped. App Store builds use the Production CloudKit environment; the schema you developed against is in Development. In CloudKit Console → your container → Schema → Record Types → Deploy Schema Changes…, review the Development→Production diff and Deploy.
- A record type only exists in the schema after a record of that type was created in the Development environment. Production cannot auto-create new record types. So if a model was never exercised in dev (e.g. a rarely-used record type), its type is absent and that data won't sync until you create one record in a Debug build and re-deploy.
- If your app has no CloudKit (e.g. data lives on a backend REST API), skip this step.
Submission blockers cheat-sheet (the 409 associatedErrors)
| Blocker code / message | Fix |
|---|---|
appInfoLocalizations … privacyPolicyUrl required |
PATCH appInfoLocalizations/{id} privacyPolicyUrl |
appStoreVersions … copyright required |
PATCH appStoreVersions/{id} copyright (e.g. 2026 <Your Org>) |
appStoreReviewDetail … was not found |
POST appStoreReviewDetails with contact name/phone/email, demoAccountRequired |
APP_DATA_USAGES_REQUIRED |
UI-only: App Privacy → publish "Data Not Collected" (or fill labels) |
SCREENSHOT_REQUIRED.APP_IPHONE_65 |
See the iPhone-screenshot quirk below |
Gotchas (the time-savers)
- iPhone 6.5" screenshot demanded for an iPad-only app. The API submission validator
spuriously requires an
APP_IPHONE_65screenshot even when the binary isUIDeviceFamily=2. The web UI usually won't ask, but the API will. Fastest unblock: generate valid 1242×2688 (or 1284×2778) images and upload them to anAPP_IPHONE_65set — scripts/make_iphone_screenshot.swift frames an existing iPad capture on a branded gradient so it looks intentional, not letterboxed. Harmless for an iPad-only listing (the binary still determines device compatibility). - A stale earlier build keeps the app "universal." If build 1 was uploaded universal
(before you set
TARGETED_DEVICE_FAMILY=2) and is stillVALID, expire it (PATCH /v1/builds/{id}expired=true) so it stops influencing device support. - Screenshot upload is a 3-step dance, not a single PUT: (1)
POST /v1/appScreenshotsreserve withfileSize+fileName→ returnsuploadOperations; (2) PUT the bytes to each operation'surlwith itsrequestHeaders; (3)PATCH /v1/appScreenshots/{id}uploaded=true+sourceFileChecksum= MD5 hex of the file. Then pollassetDeliveryState.state == COMPLETE. - Bundle ID already taken → pick a namespaced reverse-DNS id you control
(
com.yourorg.appname); update the project (and the iCloud container, if any) to match. - Device not registered / iCloud container mismatch when test-installing on hardware → register the device UDID in the portal and ensure the iCloud container is created and assigned to the App ID.
- JWT lifetime ≤ 20 min (
exp = iat + 1200),aud = "appstoreconnect-v1", ES256. Regenerate per script run; don't cache. - Empty draft review submissions created during testing can't be deleted via API (403). Ignore them or remove in the UI.
- Replacing screenshots = DELETE then upload (the API appends). To swap a bad set, first
GET /v1/appScreenshotSets/{setid}/appScreenshots,DELETE /v1/appScreenshots/{id}each, then run the 3-step upload. Otherwise you end up with 6 screenshots (3 stale + 3 new). - Resubmitting a REJECTED version →
STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION. The rejectedreviewSubmissionstill "holds" the version. Free it withPATCH /v1/reviewSubmissions/{id}{"canceled": true}, then create a fresh submission, add the version as areviewSubmissionItem, andPATCH submitted=true. A stray empty submission left over from a failed attempt may 409 on cancel — just reuse it (add the item + submit it) instead of creating another. - Attach a reviewer screen recording via the API (works even while
WAITING_FOR_REVIEW): 3-step like screenshots —POST /v1/appStoreReviewAttachments(attrsfileName+fileSize, relationship →appStoreReviewDetails/{id}) → PUT bytes touploadOperations→PATCHuploaded=true+sourceFileChecksum(MD5). PollassetDeliveryState.state == COMPLETE. releaseType: AFTER_APPROVALon the version means approval auto-publishes it — no manual "Release" click needed. Confirm viaGET appStoreVersions/{id}before submitting.- Build must be
processingState == VALIDbeforeattach-build; list withGET /v1/builds?filter[app]={aid}&sort=-uploadedDate. Processing takes ~5–15 min afteraltool.
Screenshot display types (common)
| Device | screenshotDisplayType |
Required size (px) |
|---|---|---|
| iPad 13" / 12.9" | APP_IPAD_PRO_3GEN_129 |
2064×2752 or 2048×2732 (portrait) |
| iPhone 6.9" | APP_IPHONE_67 |
1290×2796 |
| iPhone 6.5" (legacy, the quirk) | APP_IPHONE_65 |
1242×2688 or 1284×2778 |
Only the first 3 screenshots per set appear on the install sheet.
Per-project template
Fill these per app — keep credentials/URLs/contact in the project's gitignored .env and a
short note in the repo (signing identity + the marketing copy). Replace every <PLACEHOLDER>.
App name: <APP_NAME> (App Store display name, if different)
App ID (ASC): <APP_ID> # numeric ASC App ID
Bundle ID: <BUNDLE_ID> # reverse-DNS, e.g. com.yourorg.app
iCloud container: <ICLOUD_CONTAINER or "none">
Team ID: <TEAM_ID>
Platform: iOS, SwiftUI (universal / iPhone-only / iPad-only)
Category: <APP_STORE_CATEGORY>
Price: <PRICE>
Version / Build: 1.0 / 1 # bump CFBundleVersion on every upload
Backend: <YOUR_API_BASE_URL> # if the app talks to a backend
Marketing site: <MARKETING_URL> Support: <SUPPORT_URL>
Privacy: <PRIVACY_POLICY_URL> Delete account: <DELETE_ACCOUNT_URL>
Project-specific notes (fill in for your app):
- Build system: plain
.xcodeproj, or optionally generated by XcodeGen fromproject.yml(xcodegen generate→<YourApp>.xcodeproj, scheme/target<YourApp>). If using XcodeGen, editproject.yml, never the.pbxprojdirectly — it is regenerated.- For a universal app (iPhone + iPad) you need iPhone screenshot sets
APP_IPHONE_67(1290×2796) andAPP_IPHONE_65(1242×2688), plus iPadAPP_IPAD_PRO_3GEN_129. The iPad build must launch without crashing (see lessons below).- CloudKit: only relevant if your app uses CloudKit/SwiftData+CloudKit. If data is served from a backend REST API, skip the "CloudKit Production schema deploy" step entirely.
- If your app has account creation + login, an in-app Delete Account flow is mandatory (see lessons).
- Manual signing: identity
<DISTRIBUTION_IDENTITY>+ profile<PROVISIONING_PROFILE>. ASC automation key: Key ID<ASC_KEY_ID>, Issuer<ASC_ISSUER_ID>, p8 at~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8.- Demo/review account:
<REVIEW_ACCOUNT_EMAIL>/<REVIEW_ACCOUNT_PASSWORD>— must exist and log in on the live backend before every submission.- App Privacy: declare the data your app actually collects (e.g. account email/name, user-generated content) and whether it is used for tracking; review against real backend behavior.
Marketing copy to paste into the version localization (subtitle ≤30 chars, keywords ≤100 chars CSV, promo text ≤170 chars, description ≤4000 chars):
Subtitle: <SUBTITLE, ≤30 chars>
Keywords: <comma,separated,keywords ≤100 chars total>
Promo text: <PROMO TEXT, ≤170 chars>
Description: <DESCRIPTION, ≤4000 chars — explain what the app does and its main features>
Lessons learned / rejection checklist (field-tested)
These items each map to a real App Review rejection on a shipping app. They are written
generically — they apply to any app with the matching characteristics. Run this checklist
before every submit.
Guideline 2.3.3 — Accurate Metadata (screenshots)
A submission was rejected with "the 6.5-inch iPhone screenshots do not show the current version of the app in use."
- Every App Store screenshot is a real capture of the actual current app's working screens (your home / list / detail / main-feature views), taken from the simulator or a device.
- Never reuse another store's assets (e.g. Google Play graphics), marketing mockups, or promotional graphics as screenshots — materials that don't reflect the real app UI are not acceptable.
- No splash screens, no login screens, and no marketing-only graphics in the screenshot set — Apple does not count these as "the app in use."
- The majority of screenshots show the app's main features/functionality.
- Re-capture for every display size you upload (
APP_IPHONE_67,APP_IPHONE_65,APP_IPAD_PRO_3GEN_129) — don't let a stale set ship.
Guideline 5.1.1(v) — Data Collection and Storage (account deletion)
A submission was rejected with "the app supports account creation but does not include an option to initiate account deletion." Any app with login/registration must ship account deletion.
- Ship a working in-app Delete Account flow (e.g. Profile → confirmation → backend
DELETErequest → sign out) before submitting. - Temporary deactivate/disable is not sufficient; it must actually delete the account.
- If a website is needed to finish deletion, deep-link directly to your
<DELETE_ACCOUNT_URL>(not just the homepage). Only highly-regulated apps may require email/phone/customer-service to delete — most apps don't qualify. - Attach a screen recording of the deletion flow in the App Review Notes.
Guideline 2.1 — App Completeness (demo account)
An earlier submission was rejected because the demo review account did not exist on the live backend / the app crashed on the reviewer's device.
- Verify
<REVIEW_ACCOUNT_EMAIL>/<REVIEW_ACCOUNT_PASSWORD>actually logs in against your live backend right before submitting (don't assume). - Confirm a demo/TestFlight build launches without crashing on every device family you support — for a universal app, reviewers test on iPad too.
Resubmission recipe — clearing "screenshots + account-deletion" (2.3.3 + 5.1.1(v))
The full end-to-end fix, in order. Reuse this for any "screenshots + account-deletion" rejection.
1. Real screenshots from the Simulator.
- Build + run for the simulator:
xcodebuild ... -sdk iphonesimulator -destination 'platform=iOS Simulator,name=<Simulator Device>', thenxcrun simctl install booted <App>.appxcrun simctl launch booted <BUNDLE_ID>.
- Capture:
xcrun simctl io booted screenshot out.png(a 6.9" Pro Max renders 1320×2868). - Drive between tabs/screens with
cliclickusing the Simulator window geometry (osascript ... get {position, size} of window 1). Map screen-fraction → window point and allow ~28 pt for the title bar (bottom-of-screen tab taps are insensitive to it; mid-screen taps are not). After each shell call the Simulator can lose focus —activate+ one throwaway click before the real tap. - Resize to the exact slot size with
sips -z <h> <w> in.png --out out.png(e.g. 6.5" = 1284×2778). - Upload by deleting the old set first, then the 3-step reserve/PUT/PATCH (see Gotchas).
2. In-app account deletion (the 5.1.1(v) fix).
- Backend: add an authenticated
DELETE /account(or equivalent) that deactivates + anonymizes — setisActive=false, rewrite the email to a tombstone (deleted+<id>@…), and null outpasswordHash/name/phone/avatar/OAuth ids. Keep the row (don't hard-delete) so legally-required transaction records stay linkable. The login route must already reject inactive accounts so the deleted user cannot sign back in. - App: a clearly-labelled destructive Delete Account button on the Profile screen →
confirmationDialog→ call the endpoint →signOut(). Show progress + error states. - Verify the endpoint is actually LIVE before submitting: register a throwaway account via
the API, call the delete route with its token (expect 200), then try to log in again (expect
401). Don't trust "deploy finished" —
curlthe real route.
3. ⚠️ Deploying the backend can expose LATENT crashes. Adding a new endpoint may force the
first rebuild of the API container in months, which compiles the current source and surfaces
bugs that were committed but never deployed (e.g. a stray top-level route handler registered
outside its plugin → ReferenceError crash-loop; or ESM ERR_MODULE_NOT_FOUND from extensionless
relative imports). Symptoms: container exited:unhealthy, "Stopped after reaching restart limit",
site 503 — while the build shows green "Success" (build success ≠ runtime success). To
diagnose, reproduce the container's exact start command locally (read the start command from
your Dockerfile/process config) and read the runtime logs, not the build log. Keep the hotfix
minimal; verify the route is live before resubmitting.
4. The reviewer screen recording (do everything but the typing).
- Synthetic keystrokes do NOT enter text into SwiftUI
TextFields —cliclick t:and System Eventskeystrokeboth silently fail to focus/fill the field. Two reliable options: (a) have a human type the credentials while you drive everything else, or (b) inject a pre-authenticated session. Pre-create a simple, easy-to-type throwaway account (<TEST_ACCOUNT>/ short password) so whoever types it isn't fighting a long string — and so the real demo account is never deleted in the recording. - Record:
xcrun simctl io booted recordVideo --codec=h264 --force out.mp4(runs until SIGINT; stop withpkill -INT -f "simctl io booted recordVideo"so the file finalizes). - Trim with
ffmpeg -ss <start> -i out.mp4 -c:v libx264 -crf 23 -pix_fmt yuv420p clip.mp4; sanity-check with atile=8x4contact sheet (remembertileonly coversfps×tilesseconds). - Attach via the
appStoreReviewAttachmentsAPI (works whileWAITING_FOR_REVIEW).
5. Submit + auto-publish. Cancel the old rejected reviewSubmission, add the version to a
fresh one, PATCH submitted=true. With releaseType=AFTER_APPROVAL, approval publishes it
automatically — no further action.