# Shipping 12 iOS Apps to the App Store Unattended, Part 2 — Every Review Trap (Beta Builds Rejected, Pricing, Name Collisions)

> Source: <https://dev.to/bokuwalily/shipping-12-ios-apps-to-the-app-store-unattended-part-2-every-review-trap-beta-builds-rejected-2jim>
> Published: 2026-08-21 11:00:07+00:00

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": "<app_id>"}},
      "manualPrices": {"data": [{"type": "appPrices", "id": "p1"}]}
    }
  }
}
```

**Cause.** `manualPrices`

uses **the inline-resource ( included) format**. Even though it's referenced by

`id: "p1"`

, the actual `p1`

object isn't in the `included`

array, so you get a 422 for "the referenced data doesn't exist." And there's one more gotcha: the `startDate`

that means "effective immediately" must be JSON `null`

, not an empty string `""`

. Putting `""`

in returns a different 422.**Fix.** Explicitly include the `appPrices`

object in `included`

, and make `startDate`

`null`

.

```
{
  "data": {
    "type": "appPriceSchedules",
    "attributes": {},
    "relationships": {
      "app": {"data": {"type": "apps", "id": "<app_id>"}},
      "manualPrices": {"data": [{"type": "appPrices", "id": "p1"}]}
    }
  },
  "included": [
    {
      "type": "appPrices",
      "id": "p1",
      "attributes": {
        "startDate": null,
        "customerPrice": "0"
      },
      "relationships": {
        "territory": {"data": {"type": "territories", "id": "JPN"}}
      }
    }
  ]
}
```

That said, enumerating `included`

entries for all 175 countries isn't practical. In the end I dropped the idea of building a pricing function into `asc.py`

and 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.

**Symptom.** Running `set_whatsnew()`

, one specific app started returning 409 and the update stopped.

``` php
whatsNew en-US -> 409 {"errors":[{"status":"409","code":"STATE_ERROR",...}]}
```

Other locales before and after it, like `ja`

and `zh-Hans`

, updated fine, so the script itself isn't broken. Only `en-US`

fails, every time.

**Cause.** `set_whatsnew()`

PATCHes every entry in the localization list returned by the ASC API.

```
_, loc = call("GET", f"/v1/appStoreVersions/{vid}/appStoreVersionLocalizations")
for x in loc.get("data", []):
    lid = x["id"]; locale = x["attributes"].get("locale")
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})
    print("  whatsNew", locale, "->", st if st < 400 else json.dumps(r)[:300])
```

The problem was that this app's version had **both en-US and en**. If you create the English name as

`en`

in the App Store Connect web UI and then add `en-US`

metadata via `deliver`

or Fastlane, you end up double-registered. The App Store manages `en`

and `en-US`

as 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 `-`

) and skips it if the same base language has already been processed.

```
processed_base = set()
for x in loc.get("data", []):
    locale = x["attributes"].get("locale", "")
    base = locale.split("-")[0]   # "en-US" → "en"
    if base in processed_base:
        print(f"  skip {locale} (already processed base {base})")
        continue
    processed_base.add(base)
    lid = x["id"]
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})
    print("  whatsNew", locale, "->", st if st < 400 else json.dumps(r)[:300])
```

The real fix is to delete the duplicate locale in the App Store Connect web UI and consolidate on `en-US`

. The skip on the script side is strictly a stopgap, and if you have a mix of apps whose primary is `en`

and apps whose primary is `en-US`

, the skip logic can backfire. I prevent recurrence by adding a routine that fetches the app list with `asc.py apps`

and periodically checks each app's locale state.

In 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."

Setting 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.

**1. Don't strip the meaning of the JWT's iat at −30 seconds**

`_jwt()`

in `~/.appstoreconnect/asc.py`

is written like this:

```
p = _b64(json.dumps({"iss":ISSUER,"iat":int(time.time())-30,"exp":int(time.time())+900,
                     "aud":"appstoreconnect-v1"},separators=(",",":")).encode())
```

The `-30`

on `iat`

is 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`

must never be removed. The 900 seconds (15 minutes) on `exp`

is also the maximum Apple demands. Put in a value over 900 and you get an immediate 401.

**2. Pagination for /v1/certificates?limit=200 isn't implemented**

`find_cert()`

in `~/dev/auraly-ios/tools/setup_signing.py`

is written like this:

``` 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"]
    print("ERROR: no ASC cert matches local SHA1", LOCAL_SHA1)
    sys.exit(1)
```

I haven't implemented ASC API pagination (the `cursor`

parameter). 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)`

. 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`

-based full sweep.

**3. ensure_profile() deletes every profile with the same name**

```
for p in d.get("data", []):
    if p["attributes"].get("name") == PROFILE_NAME:
        asc.call("DELETE", f"/v1/profiles/{p['id']}")
        print("deleted stale profile", p["id"])
```

It DELETEs every profile that exactly matches `PROFILE_NAME = "Auraly AppStore"`

and 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"`

.

**4. _latest_version() blindly returns the single newest entry**

``` python
def _latest_version(app_id):
    _, v = call("GET", f"/v1/apps/{app_id}/appStoreVersions?limit=1")
    return v["data"][0] if v.get("data") else None
```

Since 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()`

grabs 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>`

to check state before operating.

**5. The script silently ignores builds with processingState: FAILED**

```
for x in b.get("data", []):
    if x["attributes"].get("processingState") != "VALID":
        continue
```

It just skips anything that isn't `VALID`

; there's no logic to detect and report `FAILED`

. If Apple received the binary but failed to process it, `_build_for_version()`

behaves as though nothing were there. Tell Claude Code to "wait until it becomes VALID" and it will poll forever against a `FAILED`

build. You need a mechanism that sets a timeout (around 30 minutes) and notifies and halts when it's exceeded.

**6. dedup's duplicate check keys on sourceFileChecksum + fileName**

```
key = (sh["attributes"].get("sourceFileChecksum"), sh["attributes"].get("fileName"))
if key in seen:
```

The same image under a different file name counts as a different key. If you rename files in a Fastlane metadata folder — `screen_01.png`

→ `screen_1.png`

— identical images won't be detected as duplicates. Before running `--apply`

, check the duplicate count with no arguments (dry run), and if the count is unexpected, cross-check by hand.

**7. set_whatsnew() pushes the same text to every locale**

```
for x in loc.get("data", []):
    lid = x["id"]; locale = x["attributes"].get("locale")
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})
```

It PATCHes the `text`

you 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()`

to accept `{"ja": "...", "en-US": "..."}`

, or having Claude Code generate per-language translations and passing in that dictionary.

**8. The whatsNew field caps at 4,000 characters**

That'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]`

, or by writing "keep it under 4,000 characters" explicitly in the prompt.

**9. Every PATCH returns 422 while in IN_REVIEW**

`_EDITABLE`

in `asc.py`

contains only these five states:

```
_EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED",
            "METADATA_REJECTED", "INVALID_BINARY"}
```

`WAITING_FOR_REVIEW`

and `IN_REVIEW`

are outside the set. Try to change metadata during review and the API just silently returns 422. You have to withdraw with `reject()`

first, 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?"

**10. Submitting 12 apps at once hits the rate limit (429)**

The `call()`

function has no retry / backoff.

```
try:
    r = urllib.request.urlopen(req); raw = r.read()
    return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
    return e.code, json.loads(e.read() or b"{}")
```

Build a loop that runs `dedup → submit`

across 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)`

after each `call()`

, or by adding a wrapper that detects 429 and retries with exponential backoff.

**11. Completion of eas submit / altool is outside asc.py**

Binary upload is handled by `eas submit`

or `altool`

, and `asc.py`

has no way to sense its completion. It takes 3–10 minutes from upload until Apple's servers mark it VALID. Run `release → submit`

back to back without accounting for that wait and you whiff with "no VALID build found." Either check the build column with `asc.py status`

before calling `release`

, or wrap it with polling based on `_build_for_version()`

.

**12. set -euo pipefail in archive.sh is intentional but easy to overlook**

```
set -euo pipefail
cd "$(dirname "$0")/.."
```

A failure in `xcodegen generate`

or in `xcodebuild archive`

immediately 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"`

at 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.

Twelve principles distilled from going back and forth between implementation and failure.

**1. Archive on a GA-macOS-only machine**

Build on beta macOS and a beta string gets embedded in `BuildMachineOSBuild`

, and ITMS-90111 rejects you. Even if you develop on a beta environment, restrict the machine that runs `archive.sh`

to official-release macOS only. GitHub Actions' `macos-latest`

runner always uses a GA release, so archiving in CI is the safest design.

**2. Print the OS build number at the top of archive.sh**

```
echo "macOS build: $(sw_vers -buildVersion)"
```

The 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`

is GA, the `25A5xxx`

range is beta — speeds up diagnosis.

**3. Set pricing once in the Web UI and don't hand it to the API**

Manipulating `appPriceSchedules`

via the API requires `included`

entries 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.

**4. Always run dedup immediately before submit**

```
python3 ~/.appstoreconnect/asc.py dedup <app_id> --apply
python3 ~/.appstoreconnect/asc.py submit <app_id>
```

Bake "don't break this order" into your instruction template for Claude Code. Reverse the order and duplicate screenshots go straight into review.

**5. Consolidate locales on en-US and don't leave en behind**

Delete the existing `en`

locale from the Web UI and keep only `en-US`

. 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`

and apps whose primary is `en-US`

, there are cases where the skip logic backfires.

**6. Check status at two points: before submission and 24 hours after**

Before submission: verify version state and whether a VALID build exists. 24 hours after submission: verify whether `reviewSubmission.state`

has changed to `COMPLETE`

. Build this check into Claude Code as a routine and you automate detection of both approvals and rejections.

**7. Developer Rejection has no penalty — reject the moment you notice**

If you find a defect during review, immediately run `reject()`

→ fix → `submit`

. The "let's see if it passes review" posture loses you more in rejection wait time. Developer Rejection doesn't affect the review count.

**8. Check _EDITABLE state before operating**

Use the `status`

command to confirm `appStoreState`

is one of the five `_EDITABLE`

states before running `release`

. Operating during `IN_REVIEW`

only returns 422 with no side effects, but it wastes time and API calls.

**9. Make profile names app-specific**

Name them in the `"{app name} AppStore"`

format so multiple apps never share a name. `ensure_profile()`

deletes every same-named profile, so a naming collision wipes out another app's profile and breaks its next build.

**10. Add 429-aware exponential backoff to call()**

``` python
def call_with_retry(method, path, body=None, max_retry=3):
    for attempt in range(max_retry):
        st, data = call(method, path, body)
        if st == 429:
            time.sleep(2 ** attempt)
            continue
        return st, data
    return st, data
```

This stabilizes the 12-app simultaneous submission loop. You can leave the current `call()`

as is and swap over gradually by routing only the rate-limit-sensitive operations through the wrapper.

**11. Put a timeout on waiting for VALID**

When you tell Claude Code to "wait until `processingState: VALID`

," 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.

**12. Clip whatsNew to 4,000 characters before calling**

```
whatsnew = generated_text[:4000]
```

Pass 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()`

itself clean.

Across Part 1 and Part 2, I've shown that three scripts — `~/.appstoreconnect/asc.py`

(272 lines), `archive.sh`

(24 lines), and `setup_signing.py`

(72 lines) — are enough to fully automate review submission for 12 iOS apps with no human in the loop.

The traps that stopped review were a combination of non-obvious Apple-server-specific behavior (the true cause of ITMS-90111, the `included`

format for appPriceSchedules, the `en`

/ `en-US`

double 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.

The 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.

Next time I'll cover the design of the agent loop where Claude Code autonomously decides "detect rejection → fix bug → resubmit."

I'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.

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
