{"slug": "shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta", "title": "Shipping 12 iOS Apps to the App Store Unattended, Part 2 — Every Review Trap (Beta Builds Rejected, Pricing, Name Collisions)", "summary": "An indie developer who was laid off with no savings rebuilt their income around an autonomous App Store submission pipeline powered by Claude Code, now clearing ¥1.2M a month. The pipeline uses the App Store Connect API with JWT authentication to submit 12 iOS apps unattended, and the developer details three review rejection traps encountered in production, including the true cause of ITMS-90111 and how to handle pricing and name collisions.", "body_md": "I got laid off with ¥0 in the bank. Six months later, rebuilt around Claude Code running as an autonomous environment, I'm clearing ¥1.2M a month — and one of the load-bearing pieces is a pipeline that submits apps to the App Store without me touching it.\n\nPart 1 covered how to wire up JWT authentication and the skeleton of `asc.py`\n\n. This time I'm collecting the three \"review rejection traps\" that only showed up once the thing went into real production use: how the true cause of ITMS-90111 turned out to be `BuildMachineOSBuild`\n\n, the correct request shape for `appPriceSchedules`\n\n, and how to dodge the en-US locale name collision. Each one, in order, with the actual code.\n\nDon't automate the work — **build an environment where the work never happens in the first place.** That's the core idea behind this pipeline.\n\nRun 12 iOS apps in parallel and store submission alone generates an absurd amount of busywork. Submitting a single app by hand means: archive in Xcode → upload from Organizer → enter version info in App Store Connect → swap out the screenshots → hit the submit-for-review button. Even once you're used to it, that's 20–30 minutes per app. Twelve apps eats an entire day. Repeat that a few times a month and an indie developer's resources are gone.\n\nBut the App Store Connect API (ASC API from here on) is designed so that **a JWT plus a .p8 private key is all you need — no 2FA.** That changes everything. Because it doesn't require a human login session, Claude Code can run it autonomously at 3 a.m. with exactly the same permissions.\n\nHere's the JWT generation part of `~/.appstoreconnect/asc.py`\n\n.\n\n``` python\ndef _jwt():\n    h = _b64(json.dumps({\"alg\":\"ES256\",\"kid\":KEY_ID,\"typ\":\"JWT\"},separators=(\",\",\":\")).encode())\n    p = _b64(json.dumps({\"iss\":ISSUER,\"iat\":int(time.time())-30,\"exp\":int(time.time())+900,\n                         \"aud\":\"appstoreconnect-v1\"},separators=(\",\",\":\")).encode())\n    signing = h + b\".\" + p\n    key = serialization.load_pem_private_key(open(P8,\"rb\").read(), password=None)\n    der = key.sign(signing, ec.ECDSA(hashes.SHA256()))\n    r,s = decode_dss_signature(der)\n    return (signing + b\".\" + _b64(r.to_bytes(32,\"big\")+s.to_bytes(32,\"big\"))).decode()\n```\n\n`aud:\"appstoreconnect-v1\"`\n\nand an `exp`\n\nof 900 seconds (15 minutes) are the format Apple demands. I hand-rolled this instead of using the standard `PyJWT`\n\nlibrary to keep dependencies minimal so it runs anywhere. The `cryptography`\n\npackage alone is enough to do ES256 signing end to end.\n\n**Credentials are consolidated in one place, ~/.appstoreconnect/keys.json**, so the same script can be reused against any app.\n\n`CFG = json.load(open(os.path.expanduser(\"~/.appstoreconnect/keys.json\")))`\n\nfinishes key loading, and after that you just pass the app ID as an argument.The other important piece is **state management**. The ASC API strictly tracks whether a version is in an editable state or not. The `_EDITABLE`\n\nconstant in `asc.py`\n\nis the criterion.\n\n```\n_EDITABLE = {\"PREPARE_FOR_SUBMISSION\", \"DEVELOPER_REJECTED\", \"REJECTED\",\n            \"METADATA_REJECTED\", \"INVALID_BINARY\"}\n```\n\nOnly in these five states are `versionString`\n\nchanges and build swaps permitted. Throw a PATCH while the app is `IN_REVIEW`\n\nor `WAITING_FOR_REVIEW`\n\nand all you get is an error. Having the script check this up front and branch accordingly eliminates wasted API calls.\n\nThere's one more mechanism for \"not doing work\": **idempotency**. For example, `add_tester()`\n\nchecks `_has_tester()`\n\nbefore adding a tester and skips the operation if they're already registered. `submit()`\n\nlikewise reuses an existing `READY_FOR_REVIEW`\n\nreviewSubmission instead of creating a new one. With that in place, Claude Code running the same command twice produces no side effects.\n\nThe reality behind ¥1.2M a month isn't \"piled-on workload.\" It's **the same machinery running in parallel**. As apps get added, the only thing that grows is the list of app IDs handed to the script — the structure doesn't change.\n\nZoom out and the pipeline splits into four layers.\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ 1. 署名準備                                               │\n│    setup_signing.py                                      │\n│    ・SHA1 で Distribution 証明書を特定                    │\n│    ・App ID（bundleId）を作成または確認                   │\n│    ・.mobileprovision を生成して Keychain に配置           │\n└────────────────────┬────────────────────────────────────┘\n                     │\n┌────────────────────▼────────────────────────────────────┐\n│ 2. ビルド＆アーカイブ                                     │\n│    tools/archive.sh                                      │\n│    ・xcodegen generate でプロジェクト再生成               │\n│    ・xcodebuild archive（Manual Signing）                │\n│    ・xcodebuild -exportArchive → .ipa 生成               │\n└────────────────────┬────────────────────────────────────┘\n                     │\n┌────────────────────▼────────────────────────────────────┐\n│ 3. バイナリ投入                                           │\n│    eas submit / altool                                   │\n│    ・.ipa を App Store Connect へアップロード             │\n│    ・処理完了まで待機（processingState: VALID）           │\n└────────────────────┬────────────────────────────────────┘\n                     │\n┌────────────────────▼────────────────────────────────────┐\n│ 4. メタデータ＆審査提出                                   │\n│    asc.py release → dedup → submit                      │\n│    ・make_version: 編集可能バージョンを確保               │\n│    ・attach_build: VALID ビルドを紐づけ                   │\n│    ・set_whatsnew: 全ロケールの更新情報を設定              │\n│    ・dedup: スクショ重複を除去                            │\n│    ・submit: reviewSubmission 作成→item追加→提出         │\n└─────────────────────────────────────────────────────────┘\n```\n\nThe signing automation handled by `~/dev/auraly-ios/tools/setup_signing.py`\n\nstarts with matching the certificate.\n\n``` python\nLOCAL_SHA1 = \"EC06777A693874E920CECFE390D467670552CCCE\".lower()\n\ndef find_cert():\n    _, d = asc.call(\"GET\", \"/v1/certificates?limit=200\")\n    for c in d.get(\"data\", []):\n        content = c[\"attributes\"].get(\"certificateContent\")\n        if not content:\n            continue\n        der = base64.b64decode(content)\n        sha1 = hashlib.sha1(der).hexdigest()\n        if sha1 == LOCAL_SHA1:\n            return c[\"id\"]\n```\n\nThe `certificateContent`\n\nreturned by `/v1/certificates`\n\nis Base64-encoded DER. We fingerprint it with SHA1 and match it against the Distribution certificate in the local Keychain. I match on fingerprint rather than searching by name specifically to avoid cases where names collide, like having multiple \"Apple Distribution: ...\" entries.\n\nOnce a matching certificate is found, the script verifies or creates the App ID (bundleId) and generates an AppStore-type provisioning profile. The generated profile is written to `~/Library/MobileDevice/Provisioning Profiles/{uuid}.mobileprovision`\n\n, where xcodebuild can pick it up directly. By design, any existing profile with the same name is DELETEd first and then recreated, which prevents the failure mode where a stale profile lingers after a certificate rotation and breaks the build.\n\nThe core of `~/dev/auraly-ios/tools/archive.sh`\n\nis 17 lines.\n\n```\nxcodegen generate\nrm -rf build/Auraly.xcarchive build/export\n\nxcodebuild archive \\\n  -project Auraly.xcodeproj \\\n  -scheme Auraly \\\n  -configuration Release \\\n  -archivePath build/Auraly.xcarchive \\\n  -destination 'generic/platform=iOS' \\\n  CODE_SIGN_STYLE=Manual \\\n  CODE_SIGN_IDENTITY=\"Apple Distribution\" \\\n  PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\" \\\n  -allowProvisioningUpdates\n\nxcodebuild -exportArchive \\\n  -archivePath build/Auraly.xcarchive \\\n  -exportOptionsPlist ExportOptions.plist \\\n  -exportPath build/export\n```\n\nThe combination of `CODE_SIGN_STYLE=Manual`\n\nand `PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\"`\n\nfully disables Xcode's automatic signing and pins the profile generated in the previous layer by name. I've left `-allowProvisioningUpdates`\n\nin, but since the profile is already sitting on disk locally, no query to Apple's servers actually happens.\n\n**Running xcodegen generate at the top every time** is deliberate. It makes\n\n`project.yml`\n\nthe single source of truth and keeps hand-edited `.xcodeproj`\n\ndiffs from sneaking into the build.Delivering the generated `.ipa`\n\nto App Store Connect is handled by either `eas submit`\n\nor `altool`\n\n. This layer is outside the script's control; we wait asynchronously until processing completes (`processingState: VALID`\n\n). `asc.py`\n\n's `_build_for_version()`\n\nis what polls for whether a VALID build exists for the specified version.\n\n``` python\ndef _build_for_version(app_id, version_string):\n    _, b = call(\"GET\", f\"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate\"\n                       f\"&include=preReleaseVersion\")\n    incl = {i[\"id\"]: i for i in b.get(\"included\", []) if i[\"type\"] == \"preReleaseVersions\"}\n    for x in b.get(\"data\", []):\n        if x[\"attributes\"].get(\"processingState\") != \"VALID\":\n            continue\n        pr = x.get(\"relationships\", {}).get(\"preReleaseVersion\", {}).get(\"data\")\n        ver = incl.get(pr[\"id\"], {}).get(\"attributes\", {}).get(\"version\") if pr else None\n        if ver == version_string:\n            return x[\"id\"], x[\"attributes\"].get(\"version\")\n    return None\n```\n\nBuilds whose `processingState`\n\nis anything other than `VALID`\n\n(`PROCESSING`\n\n, `FAILED`\n\n) are skipped as ineligible for attachment. `include=preReleaseVersion`\n\nfetches the marketing version in a single call, and filtering by version string lets you grab exactly the intended version even when multiple builds exist.\n\n`asc.py submit()`\n\nwalks through three steps.\n\n``` python\ndef submit(app_id, platform=\"IOS\"):\n    # 1) reviewSubmission を作成（既存 in-progress があれば再利用）\n    _, rs = call(\"GET\",\n        f\"/v1/reviewSubmissions?filter[app]={app_id}&filter[state]=READY_FOR_REVIEW&limit=1\")\n    sub = (rs.get(\"data\") or [None])[0]\n    if not sub:\n        st, r = call(\"POST\", \"/v1/reviewSubmissions\",\n            {\"data\": {\"type\": \"reviewSubmissions\", \"attributes\": {\"platform\": platform},\n                      \"relationships\": {\"app\": {\"data\": {\"type\": \"apps\", \"id\": app_id}}}}})\n        sub = r[\"data\"]\n    sid = sub[\"id\"]\n    # 2) バージョンを item として追加\n    ver = _latest_version(app_id); vid = ver[\"id\"]\n    st, r = call(\"POST\", \"/v1/reviewSubmissionItems\",\n        {\"data\": {\"type\": \"reviewSubmissionItems\",\n                  \"relationships\": {\n                      \"reviewSubmission\": {\"data\": {\"type\": \"reviewSubmissions\", \"id\": sid}},\n                      \"appStoreVersion\": {\"data\": {\"type\": \"appStoreVersions\", \"id\": vid}}}}})\n    # 3) submitted=true で提出\n    st, r = call(\"PATCH\", f\"/v1/reviewSubmissions/{sid}\",\n        {\"data\": {\"type\": \"reviewSubmissions\", \"id\": sid,\n                  \"attributes\": {\"submitted\": True}}})\n```\n\nRather than the legacy `/v1/apps/{id}/appStoreVersions/{vid}/...`\n\nfamily of endpoints, this uses the **newer reviewSubmissions / reviewSubmissionItems endpoints**. Apple recommends these in the docs, and the old ones are on a path to being phased out. Skipping the first of the three steps — the existing-submission check — causes errors from double-submitting the same app, so the key is to GET and check state before you POST.\n\nScreenshot `dedup`\n\nalways runs immediately before `submit`\n\n. Re-syncing screenshots with `deliver`\n\nor `fastlane`\n\ncan leave you with the previous batch and the current batch both added as identical files. `dedup_screenshots()`\n\nkeys on the combination of `sourceFileChecksum`\n\nand `fileName`\n\nand DELETEs everything after the first copy. It's a dry run with `apply=False`\n\nand only deletes for real with the `--apply`\n\nflag, so the risk of accidental deletion is eliminated by checking first.\n\nThat's the full picture of \"a pipeline that works.\" From the next chapter on, I'll go through the rejection reasons I actually hit when putting this pipeline into production across 12 apps, with the error codes and the fixing code.\n\nRunning 12 apps in parallel guarantees you'll hit the situation where you spot a minor bug while an app is in review and want to fix it *right now*. Throw `make_version()`\n\nor `attach_build()`\n\nat an app in `WAITING_FOR_REVIEW`\n\nor `IN_REVIEW`\n\nand the ASC API just silently returns 422. What you need to get back into an editable state is `reject()`\n\n.\n\n``` python\ndef reject(app_id):\n    _, rs = call(\"GET\", f\"/v1/reviewSubmissions?filter[app]={app_id}&limit=10\")\n    done = False\n    for x in rs.get(\"data\", []):\n        state = x[\"attributes\"].get(\"state\")\n        if state in (\"WAITING_FOR_REVIEW\", \"IN_REVIEW\", \"UNRESOLVED_ISSUES\"):\n            st, r = call(\"PATCH\", f\"/v1/reviewSubmissions/{x['id']}\",\n                {\"data\": {\"type\": \"reviewSubmissions\", \"id\": x[\"id\"],\n                          \"attributes\": {\"canceled\": True}}})\n            print(\"  reject\", x[\"id\"], state, \"->\", st if st < 400 else json.dumps(r)[:400])\n            done = True\n    if not done:\n        print(\"  審査中の submission 無し\")\n```\n\nJust PATCHing `canceled: True`\n\nwithdraws the review and transitions the version to `DEVELOPER_REJECTED`\n\n. Since that state is in `_EDITABLE`\n\n, you can go straight back into the `make_version()`\n\n→ `attach_build()`\n\n→ `submit()`\n\nflow. `UNRESOLVED_ISSUES`\n\nis included in the target states so that the same command can also withdraw a submission when App Review has come back with follow-up questions.\n\nDeveloper Rejection carries no penalty. It gets recorded in the review history, but it doesn't count against your review count. If anything, trying to push a flawed binary through and eating a `REJECTED`\n\ncosts you more waiting time before the next cycle. \"The moment you want to fix something, reject → fix → re-submit\" is the correct way to minimize cycle cost.\n\n``` python\ndef release(app_id, version, whatsnew, platform=\"IOS\"):\n    print(\"release\", app_id, version)\n    vid = make_version(app_id, version, platform)\n    if not vid: return\n    attach_build(app_id, vid, version)\n    set_whatsnew(app_id, whatsnew, vid)\n    print(\"  done. 確認後に `submit` を実行。\")\n```\n\n`release()`\n\nis a thin orchestrator that chains `make_version`\n\n→ `attach_build`\n\n→ `set_whatsnew`\n\nin sequence. `submit()`\n\nis deliberately excluded. The design stops once the build is attached and the metadata is in place, so you can check with the `status`\n\ncommand before firing `submit`\n\n. Fully automating it would flush things into review before you had a chance to catch a mistake, so the last single command is intentionally separated out.\n\nFrom Claude Code, it's these three lines:\n\n```\npython3 ~/.appstoreconnect/asc.py release 6782624281 1.4.0 \"バグ修正と安定性の向上\"\npython3 ~/.appstoreconnect/asc.py dedup 6782624281 --apply\npython3 ~/.appstoreconnect/asc.py submit 6782624281\n```\n\n\"Prepare version → dedupe screenshots → submit for review\" in three lines. Even running this in parallel across 12 apps, it finishes before you get anywhere near the ASC API rate limit (roughly 1,000 requests per minute as a rule of thumb).\n\n``` python\ndef add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first=\"Lily\", last=\"Tester\"):\n    if _has_tester(app_id, email):\n        print(f\"  {email} は既にテスター(skip)\"); return\n    _, g = call(\"GET\", f\"/v1/apps/{app_id}/betaGroups?limit=50\")\n    internal = next((x for x in g.get(\"data\", []) if x[\"attributes\"].get(\"isInternalGroup\")), None)\n    if not internal:\n        st, r = call(\"POST\", \"/v1/betaGroups\",\n            {\"data\": {\"type\": \"betaGroups\",\n                      \"attributes\": {\"name\": \"Internal\", \"isInternalGroup\": True},\n                      \"relationships\": {\"app\": {\"data\": {\"type\": \"apps\", \"id\": app_id}}}}})\n        if st >= 400:\n            print(\"  create internal group failed\", st, json.dumps(r)[:600]); return\n        internal = r[\"data\"]; print(\"  created internal group\", internal[\"id\"])\n    gid = internal[\"id\"]\n    st, r = call(\"POST\", \"/v1/betaTesters\",\n        {\"data\": {\"type\": \"betaTesters\",\n                  \"attributes\": {\"email\": email, \"firstName\": first, \"lastName\": last},\n                  \"relationships\": {\"betaGroups\": {\"data\": [{\"type\": \"betaGroups\", \"id\": gid}]}}}})\n    if st >= 400 and not _has_tester(app_id, email):\n        print(\"  add tester failed\", st, json.dumps(r)[:600]); return\n    print(f\"  テスター {email} を内部グループ {gid} に追加\")\n```\n\nAs the comment says, POSTing directly to `betaGroups/{id}/relationships/betaTesters`\n\ngets rejected with a 409 STATE_ERROR (\"Tester cannot be assigned\"). When adding a tester, the only correct answer is a single operation that **creates the tester and links them to the group at the same time** — a POST to `/v1/betaTesters`\n\nthat includes the `betaGroups`\n\nrelationship. Follow the intuitive procedure of \"create the group, then add the tester\" and all you'll get is an endless stream of 409s.\n\nInternal testers can install without Beta App Review, so you can verify the install on your own iCloud account right after submitting for review. Calling `add_tester`\n\nafter submission has become a standard routine in the pipeline.\n\nOn the way to putting 12 apps into production, three kinds of rejection stopped the pipeline. Here they are in order: symptom → cause → fix.\n\n**Symptom.** I ran `archive.sh`\n\n, uploaded the `.ipa`\n\nto App Store Connect, and the post-processing email carried `ITMS-90111`\n\n.\n\n```\nERROR ITMS-90111: \"Invalid Binary.\nThe value for key BuildMachineOSBuild in the Info.plist\nfile at Payload/Auraly.app/Info.plist is not valid.\"\n```\n\nThe App Store Connect web UI showed the binary as \"invalid,\" and `_build_for_version()`\n\nwould never return a build with `processingState == \"VALID\"`\n\n. Re-uploading gave the same result. Checking the build column with `asc.py status`\n\njust showed `FAILED`\n\n.\n\n**Cause.** When archiving, xcodebuild automatically writes the macOS version the build ran on into `Info.plist`\n\nunder the `BuildMachineOSBuild`\n\nkey. There is nothing wrong with the `archive.sh`\n\ncommand itself.\n\n```\nxcodebuild archive \\\n  -project Auraly.xcodeproj \\\n  -scheme Auraly \\\n  -configuration Release \\\n  -archivePath build/Auraly.xcarchive \\\n  -destination 'generic/platform=iOS' \\\n  CODE_SIGN_STYLE=Manual \\\n  CODE_SIGN_IDENTITY=\"Apple Distribution\" \\\n  PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\" \\\n  -allowProvisioningUpdates\n```\n\nThe problem was that the macOS running this command was a **beta**. I was on a beta (build numbers in the `25A5xxx`\n\nrange) before the GA release of macOS 15 Sequoia, so a string indicating beta got embedded in `BuildMachineOSBuild`\n\nand Apple's validator rejected it. There is no xcodebuild option to override `BuildMachineOSBuild`\n\n; your only option is to put the build machine's OS itself back on a GA release.\n\n**Fix.** I downgraded macOS from beta to the official release and re-archived. In environments where downgrading is hard, archiving on GitHub Actions' `macos-latest`\n\nrunner (always GA) is the practical route. From then on I switched to a rule of \"development can be on beta, archiving only on a GA-only machine.\" So I don't forget the rule and step on it again, I added a line to the top of `archive.sh`\n\nthat prints the output of `sw_vers -buildVersion`\n\n.\n\n**Symptom.** I POSTed to the pricing endpoint to register a new app as Free, and it kept returning 422.\n\n```\n422 {\"errors\":[{\"status\":\"422\",\"code\":\"INVALID_ENTITY\",\n\"detail\":\"The provided entity includes invalid relationship data.\"}]}\n```\n\nThe error message is too generic to tell you what's wrong. Apple's docs only say \"pass `manualPrices`\n\nin relationships,\" with few concrete body-structure examples. This is the shape I tried first.\n\n```\n{\n  \"data\": {\n    \"type\": \"appPriceSchedules\",\n    \"attributes\": {},\n    \"relationships\": {\n      \"app\": {\"data\": {\"type\": \"apps\", \"id\": \"<app_id>\"}},\n      \"manualPrices\": {\"data\": [{\"type\": \"appPrices\", \"id\": \"p1\"}]}\n    }\n  }\n}\n```\n\n**Cause.** `manualPrices`\n\nuses **the inline-resource ( included) format**. Even though it's referenced by\n\n`id: \"p1\"`\n\n, the actual `p1`\n\nobject isn't in the `included`\n\narray, so you get a 422 for \"the referenced data doesn't exist.\" And there's one more gotcha: the `startDate`\n\nthat means \"effective immediately\" must be JSON `null`\n\n, not an empty string `\"\"`\n\n. Putting `\"\"`\n\nin returns a different 422.**Fix.** Explicitly include the `appPrices`\n\nobject in `included`\n\n, and make `startDate`\n\n`null`\n\n.\n\n```\n{\n  \"data\": {\n    \"type\": \"appPriceSchedules\",\n    \"attributes\": {},\n    \"relationships\": {\n      \"app\": {\"data\": {\"type\": \"apps\", \"id\": \"<app_id>\"}},\n      \"manualPrices\": {\"data\": [{\"type\": \"appPrices\", \"id\": \"p1\"}]}\n    }\n  },\n  \"included\": [\n    {\n      \"type\": \"appPrices\",\n      \"id\": \"p1\",\n      \"attributes\": {\n        \"startDate\": null,\n        \"customerPrice\": \"0\"\n      },\n      \"relationships\": {\n        \"territory\": {\"data\": {\"type\": \"territories\", \"id\": \"JPN\"}}\n      }\n    }\n  ]\n}\n```\n\nThat said, enumerating `included`\n\nentries for all 175 countries isn't practical. In the end I dropped the idea of building a pricing function into `asc.py`\n\nand settled on a design where **pricing is set up front in the App Store Connect web UI, and the API sticks to metadata and binary operations.** This division of labor also makes sense because it eliminates the risk of pricing being overwritten on every resubmission.\n\n**Symptom.** Running `set_whatsnew()`\n\n, one specific app started returning 409 and the update stopped.\n\n``` php\nwhatsNew en-US -> 409 {\"errors\":[{\"status\":\"409\",\"code\":\"STATE_ERROR\",...}]}\n```\n\nOther locales before and after it, like `ja`\n\nand `zh-Hans`\n\n, updated fine, so the script itself isn't broken. Only `en-US`\n\nfails, every time.\n\n**Cause.** `set_whatsnew()`\n\nPATCHes every entry in the localization list returned by the ASC API.\n\n```\n_, loc = call(\"GET\", f\"/v1/appStoreVersions/{vid}/appStoreVersionLocalizations\")\nfor x in loc.get(\"data\", []):\n    lid = x[\"id\"]; locale = x[\"attributes\"].get(\"locale\")\n    st, r = call(\"PATCH\", f\"/v1/appStoreVersionLocalizations/{lid}\",\n        {\"data\": {\"type\": \"appStoreVersionLocalizations\", \"id\": lid,\n                  \"attributes\": {\"whatsNew\": text}}})\n    print(\"  whatsNew\", locale, \"->\", st if st < 400 else json.dumps(r)[:300])\n```\n\nThe problem was that this app's version had **both en-US and en**. If you create the English name as\n\n`en`\n\nin the App Store Connect web UI and then add `en-US`\n\nmetadata via `deliver`\n\nor Fastlane, you end up double-registered. The App Store manages `en`\n\nand `en-US`\n\nas separate entities, so the duplicate state is tolerated — but when one of them is set as the \"primary locale,\" a PATCH to the other returns 409.**Fix.** As a stopgap, I added a filter that looks at the locale's language code (everything before the `-`\n\n) and skips it if the same base language has already been processed.\n\n```\nprocessed_base = set()\nfor x in loc.get(\"data\", []):\n    locale = x[\"attributes\"].get(\"locale\", \"\")\n    base = locale.split(\"-\")[0]   # \"en-US\" → \"en\"\n    if base in processed_base:\n        print(f\"  skip {locale} (already processed base {base})\")\n        continue\n    processed_base.add(base)\n    lid = x[\"id\"]\n    st, r = call(\"PATCH\", f\"/v1/appStoreVersionLocalizations/{lid}\",\n        {\"data\": {\"type\": \"appStoreVersionLocalizations\", \"id\": lid,\n                  \"attributes\": {\"whatsNew\": text}}})\n    print(\"  whatsNew\", locale, \"->\", st if st < 400 else json.dumps(r)[:300])\n```\n\nThe real fix is to delete the duplicate locale in the App Store Connect web UI and consolidate on `en-US`\n\n. The skip on the script side is strictly a stopgap, and if you have a mix of apps whose primary is `en`\n\nand apps whose primary is `en-US`\n\n, the skip logic can backfire. I prevent recurrence by adding a routine that fetches the app list with `asc.py apps`\n\nand periodically checks each app's locale state.\n\nIn the next section I'll cover rate-limit handling when spinning 12 apps at once, and the design of the agent loop where Claude Code autonomously decides \"rejection → bug fix → resubmit.\"\n\nSetting aside the three big rejections from the previous chapter (ITMS-90111 / appPriceSchedules / en-US collision), here's a rapid-fire list of the smaller traps I hit in implementation and operation. Rate-limit handling is covered here too.\n\n**1. Don't strip the meaning of the JWT's iat at −30 seconds**\n\n`_jwt()`\n\nin `~/.appstoreconnect/asc.py`\n\nis written like this:\n\n```\np = _b64(json.dumps({\"iss\":ISSUER,\"iat\":int(time.time())-30,\"exp\":int(time.time())+900,\n                     \"aud\":\"appstoreconnect-v1\"},separators=(\",\",\":\")).encode())\n```\n\nThe `-30`\n\non `iat`\n\nis clock-drift protection against Apple's servers. If you issue a JWT while your Mac's clock is slightly off from NTP, Apple decides \"this issue time is in the future\" and returns 401. The frequency goes up the more you run it automatically on a build server, so this `-30`\n\nmust never be removed. The 900 seconds (15 minutes) on `exp`\n\nis also the maximum Apple demands. Put in a value over 900 and you get an immediate 401.\n\n**2. Pagination for /v1/certificates?limit=200 isn't implemented**\n\n`find_cert()`\n\nin `~/dev/auraly-ios/tools/setup_signing.py`\n\nis written like this:\n\n``` python\nLOCAL_SHA1 = \"EC06777A693874E920CECFE390D467670552CCCE\".lower()\n\ndef find_cert():\n    _, d = asc.call(\"GET\", \"/v1/certificates?limit=200\")\n    for c in d.get(\"data\", []):\n        content = c[\"attributes\"].get(\"certificateContent\")\n        if not content:\n            continue\n        der = base64.b64decode(content)\n        sha1 = hashlib.sha1(der).hexdigest()\n        if sha1 == LOCAL_SHA1:\n            return c[\"id\"]\n    print(\"ERROR: no ASC cert matches local SHA1\", LOCAL_SHA1)\n    sys.exit(1)\n```\n\nI haven't implemented ASC API pagination (the `cursor`\n\nparameter). If you have 201 or more certificates in total, the moment the target certificate falls to position 201 or beyond, this halts with `sys.exit(1)`\n\n. With about 12 apps on a personal account it realistically won't happen, but on a team account or a long-lived account it's worth watching. You can prevent it by periodically deleting revoked certificates, or by adding a `cursor`\n\n-based full sweep.\n\n**3. ensure_profile() deletes every profile with the same name**\n\n```\nfor p in d.get(\"data\", []):\n    if p[\"attributes\"].get(\"name\") == PROFILE_NAME:\n        asc.call(\"DELETE\", f\"/v1/profiles/{p['id']}\")\n        print(\"deleted stale profile\", p[\"id\"])\n```\n\nIt DELETEs every profile that exactly matches `PROFILE_NAME = \"Auraly AppStore\"`\n\nand then creates a new one. If you reuse the same profile name across multiple apps, the other apps' profiles get wiped out together and their next build won't pass. You can prevent this by strictly using a unique per-app name like `\"{app name} AppStore\"`\n\n.\n\n**4. _latest_version() blindly returns the single newest entry**\n\n``` python\ndef _latest_version(app_id):\n    _, v = call(\"GET\", f\"/v1/apps/{app_id}/appStoreVersions?limit=1\")\n    return v[\"data\"][0] if v.get(\"data\") else None\n```\n\nSince no sort condition is specified, you get the first entry under the API's default (descending creation date). When \"1.3.0 (in review)\" and \"1.4.0 (editing)\" exist at the same time, `submit()`\n\ngrabs 1.4.0's ID and tries to submit it. Running 12 apps in parallel, this dual-version state lingers surprisingly long. You need the habit of running `asc.py status <app_id>`\n\nto check state before operating.\n\n**5. The script silently ignores builds with processingState: FAILED**\n\n```\nfor x in b.get(\"data\", []):\n    if x[\"attributes\"].get(\"processingState\") != \"VALID\":\n        continue\n```\n\nIt just skips anything that isn't `VALID`\n\n; there's no logic to detect and report `FAILED`\n\n. If Apple received the binary but failed to process it, `_build_for_version()`\n\nbehaves as though nothing were there. Tell Claude Code to \"wait until it becomes VALID\" and it will poll forever against a `FAILED`\n\nbuild. You need a mechanism that sets a timeout (around 30 minutes) and notifies and halts when it's exceeded.\n\n**6. dedup's duplicate check keys on sourceFileChecksum + fileName**\n\n```\nkey = (sh[\"attributes\"].get(\"sourceFileChecksum\"), sh[\"attributes\"].get(\"fileName\"))\nif key in seen:\n```\n\nThe same image under a different file name counts as a different key. If you rename files in a Fastlane metadata folder — `screen_01.png`\n\n→ `screen_1.png`\n\n— identical images won't be detected as duplicates. Before running `--apply`\n\n, check the duplicate count with no arguments (dry run), and if the count is unexpected, cross-check by hand.\n\n**7. set_whatsnew() pushes the same text to every locale**\n\n```\nfor x in loc.get(\"data\", []):\n    lid = x[\"id\"]; locale = x[\"attributes\"].get(\"locale\")\n    st, r = call(\"PATCH\", f\"/v1/appStoreVersionLocalizations/{lid}\",\n        {\"data\": {\"type\": \"appStoreVersionLocalizations\", \"id\": lid,\n                  \"attributes\": {\"whatsNew\": text}}})\n```\n\nIt PATCHes the `text`\n\nyou passed into every entry regardless of locale. Japanese release notes go straight into the English, Chinese, and Korean locales. You need to deal with this before an English-speaking user writes a review saying \"I can't read the release notes.\" The practical designs are either extending `set_whatsnew()`\n\nto accept `{\"ja\": \"...\", \"en-US\": \"...\"}`\n\n, or having Claude Code generate per-language translations and passing in that dictionary.\n\n**8. The whatsNew field caps at 4,000 characters**\n\nThat's the App Store Connect API spec limit. Exceed it and you get a 422. Tell Claude Code to \"auto-generate release notes and run it all the way through submit\" and you easily end up with a long text passed through verbatim. You can prevent it by clipping on the caller side with `whatsnew = whatsnew[:4000]`\n\n, or by writing \"keep it under 4,000 characters\" explicitly in the prompt.\n\n**9. Every PATCH returns 422 while in IN_REVIEW**\n\n`_EDITABLE`\n\nin `asc.py`\n\ncontains only these five states:\n\n```\n_EDITABLE = {\"PREPARE_FOR_SUBMISSION\", \"DEVELOPER_REJECTED\", \"REJECTED\",\n            \"METADATA_REJECTED\", \"INVALID_BINARY\"}\n```\n\n`WAITING_FOR_REVIEW`\n\nand `IN_REVIEW`\n\nare outside the set. Try to change metadata during review and the API just silently returns 422. You have to withdraw with `reject()`\n\nfirst, then edit. Running 12 apps at once means one of them is always in review, which makes it easy to get confused about \"why can't I update just this app?\"\n\n**10. Submitting 12 apps at once hits the rate limit (429)**\n\nThe `call()`\n\nfunction has no retry / backoff.\n\n```\ntry:\n    r = urllib.request.urlopen(req); raw = r.read()\n    return r.status, (json.loads(raw) if raw else None)\nexcept urllib.error.HTTPError as e:\n    return e.code, json.loads(e.read() or b\"{}\")\n```\n\nBuild a loop that runs `dedup → submit`\n\nacross all 12 at once and within a few minutes you approach the limit (roughly 1,000 requests/minute). Since a 429 is just returned upward as an error with no retry, the apps later in the loop fail silently. You can stabilize it with the bare-minimum measure of a `time.sleep(0.1)`\n\nafter each `call()`\n\n, or by adding a wrapper that detects 429 and retries with exponential backoff.\n\n**11. Completion of eas submit / altool is outside asc.py**\n\nBinary upload is handled by `eas submit`\n\nor `altool`\n\n, and `asc.py`\n\nhas no way to sense its completion. It takes 3–10 minutes from upload until Apple's servers mark it VALID. Run `release → submit`\n\nback to back without accounting for that wait and you whiff with \"no VALID build found.\" Either check the build column with `asc.py status`\n\nbefore calling `release`\n\n, or wrap it with polling based on `_build_for_version()`\n\n.\n\n**12. set -euo pipefail in archive.sh is intentional but easy to overlook**\n\n```\nset -euo pipefail\ncd \"$(dirname \"$0\")/..\"\n```\n\nA failure in `xcodegen generate`\n\nor in `xcodebuild archive`\n\nimmediately halts the entire script. When Claude Code runs archive.sh, missing the exit-code-1 log means it may not notice the failure. Adding `echo \"ARCHIVE_SUCCESS: $ROOT/build/export/Auraly.ipa\"`\n\nat the end of archive.sh as a confirmation marker, and stating explicitly in the instructions to Claude Code that \"if this message doesn't appear, report it as a failure,\" prevents the misread.\n\nTwelve principles distilled from going back and forth between implementation and failure.\n\n**1. Archive on a GA-macOS-only machine**\n\nBuild on beta macOS and a beta string gets embedded in `BuildMachineOSBuild`\n\n, and ITMS-90111 rejects you. Even if you develop on a beta environment, restrict the machine that runs `archive.sh`\n\nto official-release macOS only. GitHub Actions' `macos-latest`\n\nrunner always uses a GA release, so archiving in CI is the safest design.\n\n**2. Print the OS build number at the top of archive.sh**\n\n```\necho \"macOS build: $(sw_vers -buildVersion)\"\n```\n\nThe build number stays in the CI log, so if ITMS-90111 recurs you can immediately pin it on \"this was built on beta.\" Just memorizing the pattern — `24G85`\n\nis GA, the `25A5xxx`\n\nrange is beta — speeds up diagnosis.\n\n**3. Set pricing once in the Web UI and don't hand it to the API**\n\nManipulating `appPriceSchedules`\n\nvia the API requires `included`\n\nentries for all 175 countries, which isn't practical. The right design is to set \"Free\" or whatever tier once in the Web UI and never touch it in subsequent submissions. It also drops the risk of pricing being overwritten on every resubmission to zero.\n\n**4. Always run dedup immediately before submit**\n\n```\npython3 ~/.appstoreconnect/asc.py dedup <app_id> --apply\npython3 ~/.appstoreconnect/asc.py submit <app_id>\n```\n\nBake \"don't break this order\" into your instruction template for Claude Code. Reverse the order and duplicate screenshots go straight into review.\n\n**5. Consolidate locales on en-US and don't leave en behind**\n\nDelete the existing `en`\n\nlocale from the Web UI and keep only `en-US`\n\n. The script-side \"skip duplicate base languages\" is a stopgap; the root fix is consolidating locales. If you have a mix of apps whose primary locale is `en`\n\nand apps whose primary is `en-US`\n\n, there are cases where the skip logic backfires.\n\n**6. Check status at two points: before submission and 24 hours after**\n\nBefore submission: verify version state and whether a VALID build exists. 24 hours after submission: verify whether `reviewSubmission.state`\n\nhas changed to `COMPLETE`\n\n. Build this check into Claude Code as a routine and you automate detection of both approvals and rejections.\n\n**7. Developer Rejection has no penalty — reject the moment you notice**\n\nIf you find a defect during review, immediately run `reject()`\n\n→ fix → `submit`\n\n. The \"let's see if it passes review\" posture loses you more in rejection wait time. Developer Rejection doesn't affect the review count.\n\n**8. Check _EDITABLE state before operating**\n\nUse the `status`\n\ncommand to confirm `appStoreState`\n\nis one of the five `_EDITABLE`\n\nstates before running `release`\n\n. Operating during `IN_REVIEW`\n\nonly returns 422 with no side effects, but it wastes time and API calls.\n\n**9. Make profile names app-specific**\n\nName them in the `\"{app name} AppStore\"`\n\nformat so multiple apps never share a name. `ensure_profile()`\n\ndeletes every same-named profile, so a naming collision wipes out another app's profile and breaks its next build.\n\n**10. Add 429-aware exponential backoff to call()**\n\n``` python\ndef call_with_retry(method, path, body=None, max_retry=3):\n    for attempt in range(max_retry):\n        st, data = call(method, path, body)\n        if st == 429:\n            time.sleep(2 ** attempt)\n            continue\n        return st, data\n    return st, data\n```\n\nThis stabilizes the 12-app simultaneous submission loop. You can leave the current `call()`\n\nas is and swap over gradually by routing only the rate-limit-sensitive operations through the wrapper.\n\n**11. Put a timeout on waiting for VALID**\n\nWhen you tell Claude Code to \"wait until `processingState: VALID`\n\n,\" always attach the condition \"if it isn't VALID after 30 minutes, treat it as FAILED, notify, and halt.\" Polling without a timeout is exactly how an overnight autonomous run gets stuck without anyone noticing.\n\n**12. Clip whatsNew to 4,000 characters before calling**\n\n```\nwhatsnew = generated_text[:4000]\n```\n\nPass Claude Code's generated release notes through unchecked and you get a 422 for exceeding the limit. Making the clip the caller's responsibility keeps `set_whatsnew()`\n\nitself clean.\n\nAcross Part 1 and Part 2, I've shown that three scripts — `~/.appstoreconnect/asc.py`\n\n(272 lines), `archive.sh`\n\n(24 lines), and `setup_signing.py`\n\n(72 lines) — are enough to fully automate review submission for 12 iOS apps with no human in the loop.\n\nThe traps that stopped review were a combination of non-obvious Apple-server-specific behavior (the true cause of ITMS-90111, the `included`\n\nformat for appPriceSchedules, the `en`\n\n/ `en-US`\n\ndouble registration) and small assumptions in the implementation (pagination, naming collisions, locale consolidation, timeouts, rate limits). Every one of them I stepped on while actually running 12 apps in parallel. Reading the docs won't prevent them.\n\nThe reality behind ¥1.2M a month isn't \"I increased my workload\" — it's just \"the same three scripts running in parallel across 12 apps.\" If the pipeline is built correctly, it scales by adding one line with an app ID. The three scripts described here are the foundation for that.\n\nNext time I'll cover the design of the agent loop where Claude Code autonomously decides \"detect rejection → fix bug → resubmit.\"\n\nI've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta", "canonical_source": "https://dev.to/bokuwalily/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta-builds-rejected-2jim", "published_at": "2026-08-21 11:00:07+00:00", "updated_at": "2026-08-21 11:16:33.869700+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": ["Claude Code", "App Store Connect", "Apple", "asc.py"], "alternates": {"html": "https://wpnews.pro/news/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta", "markdown": "https://wpnews.pro/news/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta.md", "text": "https://wpnews.pro/news/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta.txt", "jsonld": "https://wpnews.pro/news/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta.jsonld"}}