# App Store Submission

> End-to-end submission of a native iOS/iPadOS app to the App Store, driven almost entirely by the App Store Connect (ASC) API + Xcode CLI (no manual portal clicking where avoidable). Use when archiving, uploading a build, setting metadata/screenshots/pricing, and submitting an app for review. Covers the hard-won gotchas plus a field-tested App Review rejection checklist (real-app screenshots, in-app account deletion, working demo account).

- Skill: `alfredang/app-store-submission` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add alfredang/app-store-submission`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alfredang/app-store-submission/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: alfredang (https://skillmd.com/u/alfredang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/alfredang/app-store-submission

---


# 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/](scripts/). Per-project values and the metadata copy
go in the project's `.env` (see [.env.example](.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 no `appDataUsages` relationship; 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)

1. **Paid Apple Developer Program** membership (accept the latest PLA in the portal).
2. **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:

   > 1. Sign in at <https://appstoreconnect.apple.com> as the **Account Holder / Admin**.
   > 2. **Users and Access** → top tab **Integrations** → **App Store Connect API** →
   >    **Team Keys**.
   > 3. Click **+** (Generate API Key). Name it (e.g. "automation"), set **Access = Admin**
   >    (or at least **App Manager**), **Generate**.
   > 4. **Download** the **`AuthKey_<ASC_KEY_ID>.p8`** — this is offered **only once**. Save it to
   >    `~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8` then `chmod 600` it.
   > 5. 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.
3. Put the **Key ID** and **Issuer ID** in a local **`.env`** (gitignored) and point
   `ASC_PRIVATE_KEY_PATH` at the `.p8`. See [.env.example](.env.example). The `.p8` lives
   outside the repo and is **never** committed (`.gitignore` excludes `.env` and `*.p8`).

```bash
# .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`) and `CFBundleVersion` (build, integer,
  **bump on every upload**).
- `ITSAppUsesNonExemptEncryption = false` in 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 legacy `armv7`).
- **`PrivacyInfo.xcprivacy`** privacy 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](https://github.com/yonaskolb/XcodeGen) from a `project.yml`, regenerate it first
> (`xcodegen generate`) so version/build/bundle id/device family live in one source of truth,
> and edit `project.yml` instead of the `.pbxproj`. This is entirely optional — a hand-managed
> `.xcodeproj` works the same way for everything below.

```bash
# 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: <Your Name>") 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](scripts/asc_submit.py) — it loads `.env`, mints a JWT via
[scripts/asc_jwt.swift](scripts/asc_jwt.swift), and exposes subcommands:

```bash
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_65` screenshot even when the binary is `UIDeviceFamily=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 an `APP_IPHONE_65` set —
  [scripts/make_iphone_screenshot.swift](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 still `VALID`, 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/appScreenshots`
  reserve with `fileSize`+`fileName` → returns `uploadOperations`; (2) PUT the bytes to each
  operation's `url` with its `requestHeaders`; (3) `PATCH /v1/appScreenshots/{id}`
  `uploaded=true` + `sourceFileChecksum` = **MD5 hex** of the file. Then poll
  `assetDeliveryState.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
  rejected `reviewSubmission` still "holds" the version. Free it with
  `PATCH /v1/reviewSubmissions/{id}` `{"canceled": true}`, then create a fresh submission, add
  the version as a `reviewSubmissionItem`, and `PATCH 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` (attrs `fileName`+`fileSize`,
  relationship → `appStoreReviewDetails/{id}`) → PUT bytes to `uploadOperations` → `PATCH`
  `uploaded=true` + `sourceFileChecksum` (MD5). Poll `assetDeliveryState.state == COMPLETE`.
- **`releaseType: AFTER_APPROVAL`** on the version means **approval auto-publishes** it — no
  manual "Release" click needed. Confirm via `GET appStoreVersions/{id}` before submitting.
- **Build must be `processingState == VALID`** before `attach-build`; list with
  `GET /v1/builds?filter[app]={aid}&sort=-uploadedDate`. Processing takes ~5–15 min after `altool`.

## 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 from `project.yml`
>   (`xcodegen generate` → `<YourApp>.xcodeproj`, scheme/target `<YourApp>`). If using XcodeGen,
>   edit `project.yml`, never the `.pbxproj` directly — it is regenerated.
> - For a **universal app** (iPhone + iPad) you need iPhone screenshot sets `APP_IPHONE_67`
>   (1290×2796) and `APP_IPHONE_65` (1242×2688), plus iPad `APP_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
      `DELETE` request → 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>'`, then `xcrun simctl install booted <App>.app`
  + `xcrun 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 **`cliclick`** using 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** — set `isActive=false`, rewrite the email to a tombstone
  (`deleted+<id>@…`), and null out `passwordHash`/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" — `curl` the 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 `TextField`s** — `cliclick t:` and
  System Events `keystroke` both 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 with `pkill -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 a `tile=8x4` contact sheet (remember `tile` only covers `fps×tiles` seconds).
- Attach via the **`appStoreReviewAttachments` API** (works while `WAITING_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.

