Shipping 12 iOS Apps to the App Store Unattended, Part 2 — Every Review Trap (Beta Builds Rejected, Pricing, Name Collisions) 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. 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. Part 1 covered how to wire up JWT authentication and the skeleton of asc.py . 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 , the correct request shape for appPriceSchedules , and how to dodge the en-US locale name collision. Each one, in order, with the actual code. Don't automate the work — build an environment where the work never happens in the first place. That's the core idea behind this pipeline. Run 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. But 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. Here's the JWT generation part of ~/.appstoreconnect/asc.py . python def jwt : h = b64 json.dumps {"alg":"ES256","kid":KEY ID,"typ":"JWT"},separators= ",",":" .encode p = b64 json.dumps {"iss":ISSUER,"iat":int time.time -30,"exp":int time.time +900, "aud":"appstoreconnect-v1"},separators= ",",":" .encode signing = h + b"." + p key = serialization.load pem private key open P8,"rb" .read , password=None der = key.sign signing, ec.ECDSA hashes.SHA256 r,s = decode dss signature der return signing + b"." + b64 r.to bytes 32,"big" +s.to bytes 32,"big" .decode aud:"appstoreconnect-v1" and an exp of 900 seconds 15 minutes are the format Apple demands. I hand-rolled this instead of using the standard PyJWT library to keep dependencies minimal so it runs anywhere. The cryptography package alone is enough to do ES256 signing end to end. Credentials are consolidated in one place, ~/.appstoreconnect/keys.json , so the same script can be reused against any app. CFG = json.load open os.path.expanduser "~/.appstoreconnect/keys.json" finishes 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 constant in asc.py is the criterion. EDITABLE = {"PREPARE FOR SUBMISSION", "DEVELOPER REJECTED", "REJECTED", "METADATA REJECTED", "INVALID BINARY"} Only in these five states are versionString changes and build swaps permitted. Throw a PATCH while the app is IN REVIEW or WAITING FOR REVIEW and all you get is an error. Having the script check this up front and branch accordingly eliminates wasted API calls. There's one more mechanism for "not doing work": idempotency . For example, add tester checks has tester before adding a tester and skips the operation if they're already registered. submit likewise reuses an existing READY FOR REVIEW reviewSubmission instead of creating a new one. With that in place, Claude Code running the same command twice produces no side effects. The 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. Zoom out and the pipeline splits into four layers. ┌─────────────────────────────────────────────────────────┐ │ 1. 署名準備 │ │ setup signing.py │ │ ・SHA1 で Distribution 証明書を特定 │ │ ・App ID(bundleId)を作成または確認 │ │ ・.mobileprovision を生成して Keychain に配置 │ └────────────────────┬────────────────────────────────────┘ │ ┌────────────────────▼────────────────────────────────────┐ │ 2. ビルド&アーカイブ │ │ tools/archive.sh │ │ ・xcodegen generate でプロジェクト再生成 │ │ ・xcodebuild archive(Manual Signing) │ │ ・xcodebuild -exportArchive → .ipa 生成 │ └────────────────────┬────────────────────────────────────┘ │ ┌────────────────────▼────────────────────────────────────┐ │ 3. バイナリ投入 │ │ eas submit / altool │ │ ・.ipa を App Store Connect へアップロード │ │ ・処理完了まで待機(processingState: VALID) │ └────────────────────┬────────────────────────────────────┘ │ ┌────────────────────▼────────────────────────────────────┐ │ 4. メタデータ&審査提出 │ │ asc.py release → dedup → submit │ │ ・make version: 編集可能バージョンを確保 │ │ ・attach build: VALID ビルドを紐づけ │ │ ・set whatsnew: 全ロケールの更新情報を設定 │ │ ・dedup: スクショ重複を除去 │ │ ・submit: reviewSubmission 作成→item追加→提出 │ └─────────────────────────────────────────────────────────┘ The signing automation handled by ~/dev/auraly-ios/tools/setup signing.py starts with matching the certificate. python LOCAL SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower def find cert : , d = asc.call "GET", "/v1/certificates?limit=200" for c in d.get "data", : content = c "attributes" .get "certificateContent" if not content: continue der = base64.b64decode content sha1 = hashlib.sha1 der .hexdigest if sha1 == LOCAL SHA1: return c "id" The certificateContent returned by /v1/certificates is 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. Once 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 , 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. The core of ~/dev/auraly-ios/tools/archive.sh is 17 lines. xcodegen generate rm -rf build/Auraly.xcarchive build/export xcodebuild archive \ -project Auraly.xcodeproj \ -scheme Auraly \ -configuration Release \ -archivePath build/Auraly.xcarchive \ -destination 'generic/platform=iOS' \ CODE SIGN STYLE=Manual \ CODE SIGN IDENTITY="Apple Distribution" \ PROVISIONING PROFILE SPECIFIER="Auraly AppStore" \ -allowProvisioningUpdates xcodebuild -exportArchive \ -archivePath build/Auraly.xcarchive \ -exportOptionsPlist ExportOptions.plist \ -exportPath build/export The combination of CODE SIGN STYLE=Manual and PROVISIONING PROFILE SPECIFIER="Auraly AppStore" fully disables Xcode's automatic signing and pins the profile generated in the previous layer by name. I've left -allowProvisioningUpdates in, but since the profile is already sitting on disk locally, no query to Apple's servers actually happens. Running xcodegen generate at the top every time is deliberate. It makes project.yml the single source of truth and keeps hand-edited .xcodeproj diffs from sneaking into the build.Delivering the generated .ipa to App Store Connect is handled by either eas submit or altool . This layer is outside the script's control; we wait asynchronously until processing completes processingState: VALID . asc.py 's build for version is what polls for whether a VALID build exists for the specified version. python def build for version app id, version string : , b = call "GET", f"/v1/builds?filter app ={app id}&limit=20&sort=-uploadedDate" f"&include=preReleaseVersion" incl = {i "id" : i for i in b.get "included", if i "type" == "preReleaseVersions"} for x in b.get "data", : if x "attributes" .get "processingState" = "VALID": continue pr = x.get "relationships", {} .get "preReleaseVersion", {} .get "data" ver = incl.get pr "id" , {} .get "attributes", {} .get "version" if pr else None if ver == version string: return x "id" , x "attributes" .get "version" return None Builds whose processingState is anything other than VALID PROCESSING , FAILED are skipped as ineligible for attachment. include=preReleaseVersion fetches the marketing version in a single call, and filtering by version string lets you grab exactly the intended version even when multiple builds exist. asc.py submit walks through three steps. python def submit app id, platform="IOS" : 1 reviewSubmission を作成(既存 in-progress があれば再利用) , rs = call "GET", f"/v1/reviewSubmissions?filter app ={app id}&filter state =READY FOR REVIEW&limit=1" sub = rs.get "data" or None 0 if not sub: st, r = call "POST", "/v1/reviewSubmissions", {"data": {"type": "reviewSubmissions", "attributes": {"platform": platform}, "relationships": {"app": {"data": {"type": "apps", "id": app id}}}}} sub = r "data" sid = sub "id" 2 バージョンを item として追加 ver = latest version app id ; vid = ver "id" st, r = call "POST", "/v1/reviewSubmissionItems", {"data": {"type": "reviewSubmissionItems", "relationships": { "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": sid}}, "appStoreVersion": {"data": {"type": "appStoreVersions", "id": vid}}}}} 3 submitted=true で提出 st, r = call "PATCH", f"/v1/reviewSubmissions/{sid}", {"data": {"type": "reviewSubmissions", "id": sid, "attributes": {"submitted": True}}} Rather than the legacy /v1/apps/{id}/appStoreVersions/{vid}/... family 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. Screenshot dedup always runs immediately before submit . Re-syncing screenshots with deliver or fastlane can leave you with the previous batch and the current batch both added as identical files. dedup screenshots keys on the combination of sourceFileChecksum and fileName and DELETEs everything after the first copy. It's a dry run with apply=False and only deletes for real with the --apply flag, so the risk of accidental deletion is eliminated by checking first. That'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. Running 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 or attach build at an app in WAITING FOR REVIEW or IN REVIEW and the ASC API just silently returns 422. What you need to get back into an editable state is reject . python def reject app id : , rs = call "GET", f"/v1/reviewSubmissions?filter app ={app id}&limit=10" done = False for x in rs.get "data", : state = x "attributes" .get "state" if state in "WAITING FOR REVIEW", "IN REVIEW", "UNRESOLVED ISSUES" : st, r = call "PATCH", f"/v1/reviewSubmissions/{x 'id' }", {"data": {"type": "reviewSubmissions", "id": x "id" , "attributes": {"canceled": True}}} print " reject", x "id" , state, "- ", st if st < 400 else json.dumps r :400 done = True if not done: print " 審査中の submission 無し" Just PATCHing canceled: True withdraws the review and transitions the version to DEVELOPER REJECTED . Since that state is in EDITABLE , you can go straight back into the make version → attach build → submit flow. UNRESOLVED ISSUES is 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. Developer 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 costs 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. python def release app id, version, whatsnew, platform="IOS" : print "release", app id, version vid = make version app id, version, platform if not vid: return attach build app id, vid, version set whatsnew app id, whatsnew, vid print " done. 確認後に submit を実行。" release is a thin orchestrator that chains make version → attach build → set whatsnew in sequence. submit is deliberately excluded. The design stops once the build is attached and the metadata is in place, so you can check with the status command before firing submit . 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. From Claude Code, it's these three lines: python3 ~/.appstoreconnect/asc.py release 6782624281 1.4.0 "バグ修正と安定性の向上" python3 ~/.appstoreconnect/asc.py dedup 6782624281 --apply python3 ~/.appstoreconnect/asc.py submit 6782624281 "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 . python def add tester app id, email=DEFAULT TESTER EMAIL, first="Lily", last="Tester" : if has tester app id, email : print f" {email} は既にテスター skip " ; return , g = call "GET", f"/v1/apps/{app id}/betaGroups?limit=50" internal = next x for x in g.get "data", if x "attributes" .get "isInternalGroup" , None if not internal: st, r = call "POST", "/v1/betaGroups", {"data": {"type": "betaGroups", "attributes": {"name": "Internal", "isInternalGroup": True}, "relationships": {"app": {"data": {"type": "apps", "id": app id}}}}} if st = 400: print " create internal group failed", st, json.dumps r :600 ; return internal = r "data" ; print " created internal group", internal "id" gid = internal "id" st, r = call "POST", "/v1/betaTesters", {"data": {"type": "betaTesters", "attributes": {"email": email, "firstName": first, "lastName": last}, "relationships": {"betaGroups": {"data": {"type": "betaGroups", "id": gid} }}}} if st = 400 and not has tester app id, email : print " add tester failed", st, json.dumps r :600 ; return print f" テスター {email} を内部グループ {gid} に追加" As the comment says, POSTing directly to betaGroups/{id}/relationships/betaTesters gets 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 that includes the betaGroups relationship. Follow the intuitive procedure of "create the group, then add the tester" and all you'll get is an endless stream of 409s. Internal 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 after submission has become a standard routine in the pipeline. On the way to putting 12 apps into production, three kinds of rejection stopped the pipeline. Here they are in order: symptom → cause → fix. Symptom. I ran archive.sh , uploaded the .ipa to App Store Connect, and the post-processing email carried ITMS-90111 . ERROR ITMS-90111: "Invalid Binary. The value for key BuildMachineOSBuild in the Info.plist file at Payload/Auraly.app/Info.plist is not valid." The App Store Connect web UI showed the binary as "invalid," and build for version would never return a build with processingState == "VALID" . Re-uploading gave the same result. Checking the build column with asc.py status just showed FAILED . Cause. When archiving, xcodebuild automatically writes the macOS version the build ran on into Info.plist under the BuildMachineOSBuild key. There is nothing wrong with the archive.sh command itself. xcodebuild archive \ -project Auraly.xcodeproj \ -scheme Auraly \ -configuration Release \ -archivePath build/Auraly.xcarchive \ -destination 'generic/platform=iOS' \ CODE SIGN STYLE=Manual \ CODE SIGN IDENTITY="Apple Distribution" \ PROVISIONING PROFILE SPECIFIER="Auraly AppStore" \ -allowProvisioningUpdates The problem was that the macOS running this command was a beta . I was on a beta build numbers in the 25A5xxx range before the GA release of macOS 15 Sequoia, so a string indicating beta got embedded in BuildMachineOSBuild and Apple's validator rejected it. There is no xcodebuild option to override BuildMachineOSBuild ; your only option is to put the build machine's OS itself back on a GA release. 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 runner 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 that prints the output of sw vers -buildVersion . Symptom. I POSTed to the pricing endpoint to register a new app as Free, and it kept returning 422. 422 {"errors": {"status":"422","code":"INVALID ENTITY", "detail":"The provided entity includes invalid relationship data."} } The error message is too generic to tell you what's wrong. Apple's docs only say "pass manualPrices in relationships," with few concrete body-structure examples. This is the shape I tried first. { "data": { "type": "appPriceSchedules", "attributes": {}, "relationships": { "app": {"data": {"type": "apps", "id": "