# Two Hours Lost to a Silent 401: Submitting 12 iOS Apps to the App Store With No Human in the Loop (Part 1)

> Source: <https://dev.to/bokuwalily/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-human-in-the-loop-1op5>
> Published: 2026-08-21 05:00:09+00:00

I went from earning about ¥100,000 a month as a university student to ¥600,000 by stacking side gigs — then got laid off and dropped straight back to zero. Six months later, after rebuilding everything around an autonomous Claude Code environment, monthly revenue is past ¥1.2M. The piece of that environment I want to open up here is the one that lets apps go into App Store review without a human ever logging into App Store Connect.

When you try to ship iOS apps in volume, the bottleneck isn't development — it's submission. Open Xcode, click Archive, log into App Store Connect, wait for the 2FA SMS, pick a build, hit "Submit for Review." For a single app it's no big deal. Once you're managing five or ten at once, that sequence becomes pure recurring labor, every week.

There's a more fundamental problem too: **anything that depends on 2FA can't be handed to a bot**. fastlane's `deliver`

is convenient, but every time the session cookie expires, an interactive auth prompt fires. On CI, that's a dead end.

An App Store Connect API key (the `.p8`

file) removes the problem at the root. Issue the key once and **you can hit the API without two-factor authentication**. There's no expiration either — it lives until you explicitly revoke it. Which means that in an environment where this key is present, Claude Code can autonomously run "submit for review" at 2 a.m.

Right now I manage 12 apps. Some of them ship a new version on the same day. The hours a human can sit in front of a screen are finite, but **the API can be hit in parallel**. Once a loop like `for app_id in $(cat app_ids.txt); do python3 ~/.appstoreconnect/asc.py submit "$app_id"; done`

is running, every app gets submitted while I'm drinking coffee.

"Open Xcode every time" is a task. "Anyone (or anything) with the API key can submit" is an environment.

Grinding through tasks caps your income at the number of hours you have. Build the environment and the system runs while you sleep. Most of the reason revenue is 12× what it was in my university days isn't that I increased my own workload — it's that I **increased the number of things that work in my place**. The ASC API key is one emblematic example.

"With an API key, you just generate a JWT and call the API" is technically correct — but if the implementation is off by one step, you get 401 forever. Apple's ES256 JWT requires **the raw r‖s encoding defined by RFC 7518**. Python's crypto library returns DER by default, so using it as-is guarantees a broken JWT. On first encounter, the cause is completely invisible, because the error comes back as "401 Unauthorized" rather than "Invalid signature."

In the next section I'll get concrete about what this trap actually is, and about the code I'm really using.

Start with the big picture. From binary generation to App Store review submission, my environment splits into three layers.

```
┌─────────────────────────────────────────────────────────┐
│ Layer 1: バイナリ生成                                    │
│   xcodebuild archive  (tools/archive.sh)                │
│   または eas build --local  (Expo系アプリ)              │
└──────────────────┬──────────────────────────────────────┘
                   │ .ipa
                   ▼
┌─────────────────────────────────────────────────────────┐
│ Layer 2: バイナリ転送                                    │
│   eas submit  (Transporter相当・クラウド枠消費ゼロ)      │
└──────────────────┬──────────────────────────────────────┘
                   │ processingState: VALID
                   ▼
┌─────────────────────────────────────────────────────────┐
│ Layer 3: 状態確認 / メタ編集 / 審査提出                 │
│   python3 ~/.appstoreconnect/asc.py {apps|status|submit}│
│   2FA不要・JWT認証・アカウント横断で使える              │
└─────────────────────────────────────────────────────────┘
```

Layer 3 is the topic here. `asc.py`

is only 272 lines, but it covers nearly every operation the review lifecycle needs.

```
# 全アプリ一覧
python3 ~/.appstoreconnect/asc.py apps

# 特定アプリの審査状態・ビルド状態を確認
python3 ~/.appstoreconnect/asc.py status <app_id>

# 審査に提出
python3 ~/.appstoreconnect/asc.py submit <app_id>
```

Let's walk through why this runs without 2FA, and how it's implemented internally.

The App Store Connect API key is managed as two files under `~/.appstoreconnect/`

.

`~/.appstoreconnect/keys.json`

```
{
  "key_id":    "XXXXXXXXXX",
  "issuer_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "key_path":  "~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8"
}
```

** ~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8** (the private key itself, downloadable from ASC exactly once)

At the top of `asc.py`

, `keys.json`

is loaded and those three values are held as constants.

```
CFG = json.load(open(os.path.expanduser("~/.appstoreconnect/keys.json")))
KEY_ID, ISSUER = CFG["key_id"], CFG["issuer_id"]
P8 = os.path.expanduser(CFG["key_path"])
```

The only secret is the single `.p8`

file. `keys.json`

contains nothing but the key ID and issuer ID. That separation matters: even if `keys.json`

ends up in Git (not that I recommend it), it isn't an immediate leak incident. Managing just the `.p8`

strictly is enough. In my environment the `.p8`

sits in `~/.appstoreconnect/`

and the whole directory is `chmod 700`

. When putting this on CI/CD, write the file out from a secret store at runtime.

The heart of JWT auth is the `_jwt()`

function. Here's the actual code, verbatim.

``` python
def _b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")

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

The trap is in the last two lines.

`key.sign()`

returns the ECDSA signature in DER format. DER carries an ASN.1 structure, in the form `30 xx 02 xx [r-bytes] 02 xx [s-bytes]`

. **Apple does not accept this DER.**

What Apple's ES256 JWT requires is the "fixed 64-byte raw encoding" defined in RFC 7518 Section 3.4. That is: `r`

as 32 bytes and `s`

as 32 bytes, big-endian, concatenated into 64 bytes total, then Base64URL-encoded.

```
# NG: DERをそのままBase64URLにしても401になる
_b64(der)

# OK: DERをデコードしてr,sを取り出し、生の32バイトで連結する
r, s = decode_dss_signature(der)
_b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
```

`decode_dss_signature`

is a function from the `cryptography`

library that converts a DER-format signature into a Python integer tuple `(r, s)`

. From there, `to_bytes(32, "big")`

turns each into a 32-byte sequence, and you concatenate them and Base64URL-encode — that's the correct procedure.

Why `32`

bytes? Because ES256 uses the NIST P-256 curve, and that curve's order fits in 32 bytes (256 bits). Even when `r`

or `s`

happens to be a small value (leading byte zero), it must still be zero-padded to 32 bytes. `r.to_bytes(32, "big")`

handles that automatically.

Get this implementation wrong and what Apple returns is always `401 Unauthorized`

. It's a signature verification failure, but it comes back as "authentication failed" rather than "bad signature," so it takes a while to realize the problem is in the JWT structure.

`iat`

is 30 seconds in the past
One more small but important point.

```
"iat": int(time.time()) - 30,
```

`iat`

(issued at) is set **30 seconds before** the current time. The reason is clock skew. If Apple's API servers and your local machine are slightly out of sync, the JWT can be rejected as "not yet valid." I actually lost tens of minutes to this once. Leaving a 30-second margin absorbs virtually all environmental differences.

The expiration is set to 900 seconds (15 minutes) from now. That's plenty for a JWT that gets thrown away after one request.

Here's the full list of subcommands `asc.py`

provides.

| Command | Purpose |
|---|---|
`apps` |
Print every managed app (ID, Bundle ID, name) |
`status <id>` |
Check version state, review state, and the processing state of the latest build |
`submit <id>` |
Submit the editable version for review (create reviewSubmission → add item → submitted=true) |
`make-version <id> <ver>` |
Prepare or update the version string on the App Store |
`attach-build <id> <ver>` |
Attach a processed build to a version |
`whatsnew <id> <text>` |
Set the "What's New" text for all locales at once |
`release <id> <ver> <whatsnew>` |
Run the three above together (for verification; does not submit) |
`reject <id>` |
Withdraw an in-review submission via Developer Reject |
`dedup <id> [--apply]` |
Detect and delete duplicate screenshots (dry-run / apply toggle) |
`add-tester <id>` |
Idempotently add a TestFlight internal tester |

The design axis is **idempotency**. For example, before submitting for review, `submit`

checks whether a `READY_FOR_REVIEW`

reviewSubmission already exists and reuses it if so.

``` python
def submit(app_id, platform="IOS"):
    _, 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", {...})
        ...
    sid = sub["id"]
    # バージョンをitemとして追加
    ...
    # submitted=true で提出
    st, r = call("PATCH", f"/v1/reviewSubmissions/{sid}",
        {"data": {"type": "reviewSubmissions", "id": sid,
                  "attributes": {"submitted": True}}})
```

For an automation script, "running the same command twice doesn't break anything" is a hard requirement. When Claude Code retries, or when a network error causes a re-run, double submissions and duplicate errors must not happen. Check current state with a GET before operating — I apply that pattern to every write command without exception.

Let's also look at `tools/archive.sh`

, which produces the binary.

```
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 key point is `CODE_SIGN_STYLE=Manual`

. With Automatic Signing, Xcode tries to manage provisioning profiles itself, which can pop an auth dialog during headless runs. Setting Manual and specifying the profile by name makes GUI-free builds stable.

The provisioning profile itself is generated and installed automatically from the ASC API by `tools/setup_signing.py`

. It creates an App Store distribution profile via the `/v1/profiles`

API and writes it directly into `~/Library/MobileDevice/Provisioning Profiles/`

, so the signing environment is ready without ever opening Xcode. Certificate matching uses the SHA1 fingerprint:

```
LOCAL_SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower()
...
der = base64.b64decode(content)
sha1 = hashlib.sha1(der).hexdigest()
if sha1 == LOCAL_SHA1:
    return c["id"]
```

This reconciles the local distribution certificate with the certificate on ASC. That removes the need for a human to sit at a screen deciding "which keychain certificate do I use?"

In the next post (Part 2), I'll go into detail on wiring this `asc.py`

into Claude Code's autonomous loop, the full pipeline that manages 12 apps, and the diagnosis and breakthrough procedure for when submissions kept getting rejected with INVALID_BINARY.

`asc.py`

has exactly one dependency: the `cryptography`

library. For HTTP it uses `urllib.request`

.

``` python
def call(method, path, body=None):
    url = path if path.startswith("http") else BASE + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
        headers={"Authorization":"Bearer "+_jwt(), "Content-Type":"application/json"})
    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"{}")
```

The reason for not using `requests`

is simple: **a script that runs on the Python standard library alone can be carried into any environment unconditionally**. Setting up a new Mac, deploying to a CI environment — removing a single `pip install requests`

step changes the friction completely.

One more thing: `call()`

calls `_jwt()`

every time and generates a fresh JWT. Since the JWT has a 900-second (15-minute) lifetime, caching the same JWT within a single script run would be harmless. But I deliberately don't cache it. The reason is to prevent the half-broken failure mode where a JWT expires partway through a long batch and only the later requests come back 401. Regenerating every time is marginally slower, but the cost is one ECDSA signature per request — microseconds. Even running all 12 apps in one pass, there's no perceptible difference.

HTTPError handling is kept minimal too. It returns the status code and response body as-is, and callers stick to the `if st >= 400: ... return`

pattern. Branching control flow with exceptions mixes in stack traces and hurts readability, so I keep a consistent "judge by number, return early" style.

The part that gave me the most trouble when I first implemented this was "identify a processed build by its marketing version (the display version, like `0.3.2`

)."

The `/v1/builds`

endpoint response includes the build number (the integer build number) but not the marketing version string. The marketing version lives on a separate resource called `preReleaseVersion`

, and it isn't returned unless you explicitly pass `include=preReleaseVersion`

in the query.

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

Adding `include=preReleaseVersion`

puts objects of type `preReleaseVersions`

into the response's `included`

array. Turning that into a dict keyed by ID (`incl`

) and looking up each build's `relationships.preReleaseVersion.data.id`

is the crux of this code.

**Skipping processingState != "VALID"** matters too. Builds whose binary processing hasn't finished on Apple's side are in

`PROCESSING`

or `INVALID`

state. Trying to attach one of those to a version returns `409`

. Filtering to `VALID`

automatically excludes builds that are still "processing" right after upload.If you run `fastlane deliver`

multiple times with `sync_screenshots: false`

, screenshots get appended every run. Even if the first run put in the correct 5 screenshots, after the second run there are 10, and after the third, 15. App Store validation rejects anything over "10 per size," so this becomes a quiet cause of submission failure.

`dedup_screenshots()`

solves it using the combination of `sourceFileChecksum`

and `fileName`

as the key.

``` python
def dedup_screenshots(app_id, apply=False):
    ...
    for sh in shots.get("data", []):
        key = (sh["attributes"].get("sourceFileChecksum"),
               sh["attributes"].get("fileName"))
        if key in seen:
            if apply:
                st, _ = call("DELETE", f"/v1/appScreenshots/{sh['id']}")
                print(f"  [{locale}/...] DELETE {sh['id']} -> {st}")
            else:
                print(f"  [{locale}/...] dup {sh['id']} (dry-run)")
            total += 1
        else:
            seen.add(key)
```

The important part is that ** apply=False makes dry-run the default**. Just running

`asc.py dedup <app_id>`

only prints how many duplicates exist; actual deletion happens only when you pass `--apply`

. When you're managing 12 apps, there's a real risk of "oops, I deleted every screenshot," so I made it a two-stage design.In actual operation I always slot in `dedup --apply`

immediately before `submit`

. It's just a sequential call inside a batch script, so a human never has to think about it.

`tools/setup_signing.py`

is the script that "prepares the certificate / Bundle ID / provisioning profile trio without opening Xcode."

**Certificate matching** is done by SHA1 fingerprint (the part touched on earlier). The reason: a certificate on ASC carries no direct information about which keychain private key it corresponds to. Recording the SHA1 of the distribution certificate in the local keychain in advance, then pulling all certificates from the ASC API and matching by DER-decode + SHA1 computation, is the most reliable approach.

Provisioning profile management follows a "delete the old one, then recreate" pattern.

``` python
def ensure_profile(cert_id, bundle_internal_id):
    _, d = asc.call("GET", "/v1/profiles?limit=200&filter[profileType]=IOS_APP_STORE")
    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"])
    st, r = asc.call("POST", "/v1/profiles", {...})
    ...
    uuid = attrs["uuid"]
    content = base64.b64decode(attrs["profileContent"])
    dest_dir = os.path.expanduser("~/Library/MobileDevice/Provisioning Profiles")
    dest = os.path.join(dest_dir, f"{uuid}.mobileprovision")
    with open(dest, "wb") as f:
        f.write(content)
```

The reason for deleting the same-named profile first: when you renew a certificate, a leftover old profile creates the inconsistency "the profile exists, but the certificate is stale." Deleting and recreating every time makes it much easier to guarantee "idempotent, and always in the correct state."

Writing directly into `~/Library/MobileDevice/Provisioning Profiles/`

under the UUID filename is important too — that's what lets `xcodebuild`

resolve `PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"`

by name. No need to press Xcode's download button.

Adding my own iCloud address to TestFlight as an internal tester is the first thing I do after submitting for review. That's also a single command: `asc.py add-tester <app_id>`

.

Near the end of the code there's a comment like this:

``` python
def add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first="Lily", last="Tester"):
    """...
    注意: 外部グループや `betaGroups/{id}/relationships/betaTesters` 直リンクは
    409 STATE_ERROR(Tester cannot be assigned)になる。create-with-group が唯一通る。"""
```

That comment is a record of failure (more on that in the next section). The correct pattern is to create via `POST /v1/betaTesters`

with `relationships.betaGroups`

included in the body.

```
st, r = call("POST", "/v1/betaTesters",
    {"data": {"type": "betaTesters",
              "attributes": {"email": email, "firstName": first, "lastName": last},
              "relationships": {"betaGroups": {"data": [{"type": "betaGroups", "id": gid}]}}}})
```

Doing "create the tester" and "assign group membership" in a single request keeps it from colliding with Apple's state management.

Also, `_has_tester()`

runs an existence check first to prevent double registration. Without that idempotency check, running a batch across every app would try to register the same address 12 times and get 12 409s back.

Building automation environments, I've repeatedly hit the pattern where "implementation finishes fairly quickly, and the time goes into killing mysterious 401s and mysterious 409s." Here are the failures I actually hit, in symptom → cause → fix order.

The night I first implemented `asc.py`

, I assembled the JWT, hit the API, and got `401 Unauthorized`

.

Python's `key.sign(signing, ec.ECDSA(hashes.SHA256()))`

appears to work correctly. The Base64URL of the header and payload assembles fine. Eyeballing the JWT structure, nothing looks wrong. And yet every single call returns 401.

Looking at the error response body, all you get is `{"errors":[{"status":"401","code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."}]}`

— a zero-information message. It doesn't tell you "your signature is broken."

After about two hours re-checking header formats, the `aud`

value, and the `exp`

calculation, I happened to look up RFC 7518 Section 3.4 and noticed it: an ES256 JWT signature must be **"a fixed 64 bytes: r (32 bytes) + s (32 bytes)"**.

What `key.sign()`

returns is a variable-length DER byte string. It starts with an ASN.1 encoding like `30 xx 02 xx...`

, and the lengths of r and s vary. I was Base64URL-ing that directly.

```
# 書いていたコード（間違い）
sig_b64 = _b64(der)

# 正しいコード
r, s = decode_dss_signature(der)
sig_b64 = _b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
```

Convert DER back to Python integers `(r, s)`

with `decode_dss_signature`

, turn each into 32 big-endian bytes, concatenate — that alone turned 401 into 200. **An "authentication failed" error code hides an encoding-format problem in the signature algorithm.** Knowing that up front would have bought me back two hours.

A while after solving the DER problem, a new phenomenon appeared: "works in the morning, stops working in the early afternoon." Reproducibility was low, and trying again a few hours later it would be fine.

If Apple's API servers and the local machine are off by a few to a dozen-odd seconds, `iat`

(issued at) can get rejected as "a JWT from the future." A Mac's system clock is normally NTP-synced, but right after sleep or under heavy load, a few seconds of drift can occur.

The fix is simple: set `iat`

to **30 seconds before** the current time.

```
"iat": int(time.time()) - 30,
```

That declares "this JWT was issued 30 seconds ago," so from Apple's API server's perspective it's accepted as "a JWT issued sufficiently in the past." It only shaves 30 seconds off the 900-second lifetime, so there's no practical downside.

For adding TestFlight testers, my first approach was "fetch the internal group first, then add the tester to that group."

```
# 失敗したパターン
asc.call("POST", f"/v1/betaGroups/{gid}/relationships/betaTesters",
    {"data": [{"type": "betaTesters", "id": tester_id}]})
```

This returns `409 STATE_ERROR: Tester cannot be assigned`

. Directly linking an external tester to an internal group is treated as a disallowed transition in Apple's state machine.

The correct method is to **specify the group at the moment you create the tester**. Send `relationships.betaGroups`

in the request body of `POST /v1/betaTesters`

. That one shot atomically completes both "create the tester" and "assign group membership."

From the outside, Apple's API looks like a "general-purpose REST API that can do anything," but in reality it's the review lifecycle's state machine exposed via API, so **many operations error out unless you follow the correct transition order**. Much of this isn't in the docs; you have to find it by trial and error.

When uploading screenshots with `fastlane deliver`

, running it multiple times with `sync_screenshots: false`

(or the omitted default) **appends screenshots instead of replacing them**.

The first submission is fine. When you fix something after a rejection and try to resubmit, re-running `deliver`

doubles the screenshots — 4 become 8 on the second `deliver`

. Eight is just under the App Store limit (10 per size), so it's hard to notice, and when another re-run takes you to 12 you get back `METADATA_ERROR: Too many screenshots`

.

From the symptom alone it reads as "the metadata is broken," and I wasted about an hour suspecting image size and format problems. The cause was simply "the same screenshot is in there multiple times."

`dedup_screenshots()`

is what I built in response. It deletes the second and subsequent entries where both `sourceFileChecksum`

and `fileName`

match. Using `sourceFileChecksum`

alone leaves the rare case where different files share a checksum, so the filename is the second key.

Making it verifiable in advance via dry-run without `--apply`

also came from this failure — I learned that "if you wipe everything at once, you can't get it back."

When attaching a build to a version in `attach_build()`

, my first implementation only hit `/v1/builds?filter[app]=xxx&limit=20&sort=-uploadedDate`

. The App Store Connect UI clearly showed a VALID build, yet the script said `no VALID build for version 0.3.2`

.

The cause: **the response doesn't include the marketing version ( 0.3.2)**. The

`/v1/builds`

response returns only build objects; the marketing version corresponding to a build is tied to a separate resource, `preReleaseVersion`

. It doesn't appear in the response unless you `include`

it.

```
# 修正後：include=preReleaseVersion を追加
_, 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"}
```

Adding `include`

puts objects of type `preReleaseVersions`

into the response's `included`

array. Turn that into a dict keyed by ID and look it up via each build's `relationships.preReleaseVersion.data.id`

, and you get the marketing version string.

The App Store Connect API docs explain the `include`

parameter, but there's no exhaustive table of "which endpoint can include which resource," so you don't know until you try. I tried `preReleaseVersion`

because I'd seen builds and version strings displayed as associated in the ASC UI and guessed "that join has to exist somewhere."

What these failures have in common is that **the state of "it looks correct but doesn't work" lasts a long time**. 401 and 409 look superficially similar, but each has its own distinct cause. There's no magic bullet — just dump the entire body of Apple's API responses with `json.dumps(r)[:800]`

and read them carefully. That's it.

In the next post (Part 2), I'll explain wiring this `asc.py`

into Claude Code's autonomous loop and the full pipeline design for managing 12 apps in parallel.

I've covered most of the "why I wrote it that way" reasoning above. But once you start implementing, the real problem is "not recognizing that this symptom is that cause." Below is a comprehensive index in symptom → cause → countermeasure form, so you can look things up when the same symptom hits.

**① A 401 comes back, but the error body says nothing about "signature"**

`{"code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."}`

is also returned for a mis-encoded ES256 signature. The classic case is passing the DER returned by `key.sign()`

into `_b64(der)`

. From Apple's side it only ever looks like "authentication failed." The correct approach is to convert it into Python integers `(r, s)`

with `decode_dss_signature(der)`

from `cryptography.hazmat.primitives.asymmetric.utils`

, then pass the concatenation `r.to_bytes(32,"big") + s.to_bytes(32,"big")`

to Base64URL. The import path itself is easy to get wrong, so watch out.

**② Works in the morning, 401 in the early afternoon (low reproducibility)**

The cause is NTP drift on the Mac or delayed clock sync right after waking from sleep. If you leave `iat`

as plain `int(time.time())`

, a few seconds of divergence from Apple's server time gets it rejected as "not yet valid." `asc.py`

subtracts 30 seconds with `int(time.time()) - 30`

. Apple's tolerance margin measures out at roughly ±60 seconds, so this value leaves plenty of room.

**③ 409 STATE_ERROR: Tester cannot be assigned (direct betaGroups link)**

Passing an existing tester ID to `POST /v1/betaGroups/{id}/relationships/betaTesters`

is rejected as a "disallowed transition" in Apple's state machine. The error message doesn't tell you the right way. As noted in the `asc.py`

comment (the `注意:`

block within its 272 lines), the only pattern that works is including `relationships.betaGroups`

in the `POST /v1/betaTesters`

body so tester creation and group membership complete in a single request.

**④ METADATA_ERROR: Too many screenshots after re-running fastlane deliver**

Running `deliver`

multiple times with `sync_screenshots: false`

or the omitted default appends screenshots rather than deleting them. Exceed the App Store limit of 10 per size and review submission won't go through. Check duplicates with `asc.py dedup <app_id>`

and delete with `--apply`

. Using only `sourceFileChecksum`

as the dedup key occasionally causes wrong deletions, so both `(checksum, fileName)`

are used as the key (see `key = (sh["attributes"].get("sourceFileChecksum"), sh["attributes"].get("fileName"))`

in `asc.py`

).

**⑤ A VALID build is visible in the UI but you get no VALID build for version X.X.X**

The default `/v1/builds`

response doesn't include the marketing version string (`0.3.2`

etc.). Without `&include=preReleaseVersion`

, the `included`

array comes back empty and you can't match on the version string. That's exactly why `_build_for_version()`

in `asc.py`

spells out that query parameter. The same symptom appears if you've uploaded so many builds in a short window that `limit=20`

isn't enough, so for apps with many builds, raise `limit=`

or check the `sort=-uploadedDate`

ordering.

**⑥ version is WAITING_FOR_REVIEW (locked) error before submit**

In `WAITING_FOR_REVIEW`

or `IN_REVIEW`

state you can neither edit the `appStoreVersion`

nor add new `reviewSubmissionItem`

s. `asc.py`

allows writes only in the five states `_EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED", "INVALID_BINARY"}`

and returns a guard otherwise. To withdraw a review via Developer Reject and resubmit, use `asc.py reject <app_id>`

(UNRESOLVED_ISSUES is also covered by that function).

**⑦ I thought INVALID_BINARY meant having to recreate the version**

`INVALID_BINARY`

is in `_EDITABLE`

. In other words, even in the "Apple rejected the binary during processing" state, you can swap the binary and resubmit under the same version. Just bump the build number, re-upload, overwrite with `asc.py attach-build <app_id> <ver>`

, then call `submit`

. It saves the trouble of recreating the version.

**⑧ setup_signing.py exits with no ASC cert matches local SHA1**

At the top of `setup_signing.py`

there's a constant `LOCAL_SHA1 = "EC06777A...".lower()`

. That's the SHA1 fingerprint of the distribution certificate in your local keychain. Apple Distribution certificates expire after one year, and renewing produces a different SHA1. If you run `setup_signing.py`

after the annual renewal without updating this constant, `find_cert()`

loops through every entry, finds no match, and hits `sys.exit(1)`

. After renewing a certificate, always check the SHA1 in Keychain Access and rewrite the constant.

**⑨ archive.sh fails to build because it can't resolve the provisioning profile**

`archive.sh`

's `PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"`

resolves by exact string match against `setup_signing.py`

's `PROFILE_NAME = "Auraly AppStore"`

. In an environment where `setup_signing.py`

hasn't been run — or hasn't been re-run after a certificate renewal — the stale profile sticks around and `xcodebuild`

fails code signing with either "no matching profile found" or "certificate doesn't match." Keep the order `setup_signing.py`

→ `archive.sh`

at the head of the pipeline. Also, line 6 of `archive.sh`

is `xcodegen generate`

, and if you've changed `project.yml`

, that step regenerates the `.xcodeproj`

. Skipping it and building against a stale `.xcodeproj`

can make the archive fail due to scheme configuration mismatches.

**⑩ You called release but nothing was submitted**

`asc.py release <app_id> <ver> "<whatsnew>"`

runs the three steps "prepare version → attach build → set What's New" together, but **it does not submit**. As the comment at the end of the code says, the intent is `"確認後 submit する"`

("submit after verification"). The design is: prepare with `release`

, confirm on the ASC screen that everything looks right, then call `asc.py submit <app_id>`

separately. It's easy to call `release`

at the end of an automation and mistakenly assume "submitted" — be careful.

**⑪ Screenshots you deleted with dedup --apply reappear**

`dedup_screenshots()`

scans every locale and every size of whatever version `_latest_version()`

currently returns. When that version is in a state outside `_EDITABLE`

(e.g. `WAITING_FOR_REVIEW`

), even `apply=True`

returns early with just a message and deletes nothing (the guard at line 186 of `asc.py`

). Repeating `--apply`

in that state changes nothing, so use `reject`

to return it to an editable state first.

Here are the rules that became standard from actually running 12 apps.

**1. Isolate the .p8 with chmod 700 on the whole directory**

Just creating `~/.appstoreconnect/`

and running `chmod 700 ~/.appstoreconnect`

prevents other users on the same machine from reading it. Since `keys.json`

holds only `key_id`

and `issuer_id`

, even if it leaks, the API can't be called without the private key itself. When putting this on CI, write it out from a secret store at runtime as `~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8`

, and always verify that no `.p8`

is in the commit history.

**2. Generate the JWT on every request (don't cache it)**

`call()`

invokes `_jwt()`

on every call. Regenerating each time despite a 900-second lifetime looks inefficient, but it prevents the problem where the JWT expires mid-batch and only the later requests come back 401. ECDSA signing costs microseconds per request, and even across a 12-app batch there's no perceptible difference. Choosing "behaves the same no matter where it breaks" over "cache it and go slightly faster" is a founding principle of stable API client operation.

**3. Always test the decode_dss_signature → to_bytes(32, "big") concatenation**

Once you implement an ES256 JWT signature, decode the generated JWT string once and confirm the signature part is 64 bytes (after Base64URL decoding). DER would be variable-length (typically 70–72 bytes). If `len(base64.urlsafe_b64decode(jwt.split(".")[2] + "=="))`

returns 64, your r‖s encoding is correct. Leave it untested on "it seems to work" and the signature breaks the moment r or s happens to be small and the zero padding is missing.

**4. Always subtract 30 seconds from iat**

Even when a production machine is NTP-synced, a few seconds of drift can occur right after waking from sleep or under heavy load. Apple's tolerance margin is presumed to be around ±60 seconds, but 30 seconds of headroom is a safety margin you get for free. The 900-second lifetime just becomes 870 seconds — no practical downside.

**5. Every write operation should GET the current state before POST/PATCH**

`submit()`

first checks for an existing submission with `GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW`

and reuses it if present. `add_tester()`

first runs an existence check via `_has_tester()`

. Applying this "check → act" pattern rigorously to every write command means no double submissions or duplicate errors, whether Claude Code retries or you run it twice by hand. Idempotency is the single most important design principle for an API client.

**6. Memorize the five _EDITABLE states**

Only in the five states `PREPARE_FOR_SUBMISSION`

, `DEVELOPER_REJECTED`

, `REJECTED`

, `METADATA_REJECTED`

, and `INVALID_BINARY`

can you change version attributes, swap builds, or delete screenshots. Attempting those in any other state returns `409`

or `422`

. When the review flow gets stuck, first check the current `appStoreState`

with `asc.py status <app_id>`

; if it isn't in _EDITABLE, you can decide either to `reject`

first, or — where `reject`

isn't needed — to wait for review to complete.

**7. Slot dedup --apply in right before every submit**

Regardless of how many times you've run `fastlane deliver`

, get in the habit of running `asc.py dedup <app_id> --apply`

immediately before `submit`

. Even when the dry-run shows zero, the execution cost is low, and it preempts `METADATA_ERROR`

from duplicated screenshots. In a batch script you can guarantee it with a single serial line: `for app_id in ...; do asc.py dedup "$app_id" --apply && asc.py submit "$app_id"; done`

.

**8. Manage provisioning profiles on a delete-then-recreate cycle**

`ensure_profile()`

in `setup_signing.py`

deletes all same-named profiles first, then creates a new one. It's "recreate," not "update." When you renew a certificate, a leftover profile bound to the old certificate ID creates the inconsistency "the profile exists but the certificate doesn't match." With a recreate cycle, exactly one profile with the correct certificate ID always exists, and `xcodebuild`

resolves it correctly.

**9. Stabilize headless builds with CODE_SIGN_STYLE=Manual**

`archive.sh`

specifies `CODE_SIGN_STYLE=Manual`

because with Automatic Signing, Xcode tries to manage provisioning profiles itself — which can surface a Keychain Access authentication dialog during headless runs or attempt to reach the Apple Developer API. Setting Manual and pinning the Profile Specifier by name reduces it to `xcodebuild`

resolving, by name, the profile that `setup_signing.py`

wrote into `~/Library/MobileDevice/Provisioning Profiles/`

— no GUI operation whatsoever.

**10. Keep external libraries down to just cryptography**

`asc.py`

uses `urllib.request`

for HTTP and doesn't take `requests`

as a dependency. Setting up a new Mac, running for the first time in a CI environment — removing one `pip install requests`

step changes the real friction substantially. `cryptography`

alone can't be replaced by the standard library, so it's tolerated as the minimal dependency. Never getting stuck on `pip install -r requirements.txt`

each time you carry the script into another project is a direct benefit of this design decision.

**11. Print the whole response body for API errors with json.dumps(r)[:800]**

Every function in `asc.py`

uses the uniform pattern `if st >= 400: print(json.dumps(r)[:800]); return`

. Apple's error responses have a `detail`

field inside the `errors`

array, and that's where the hint about the cause lives. If you print only `status`

without showing `detail`

, 409, 401, and 422 all look like the same "error." The habit of dumping the entire body cuts your root-cause time by more than half.

**12. Always call release and submit as separate steps**

`release <app_id> <ver> "<whatsnew>"`

bundles version preparation, build attachment, and What's New setup, but deliberately does not submit. Calling `asc.py status <app_id>`

after `release`

to confirm the metadata is set correctly, and only then calling `submit`

, prevents the wasteful cycle of "submit, notice the mistake, then Developer Reject." Even when building this into an automated pipeline, I design it so the `release`

output is left in the log in a form Claude Code can read, and it proceeds to `submit`

only if there's no problem.

**13. Always rewrite the SHA1 fingerprint when the certificate is renewed**

`LOCAL_SHA1`

in `setup_signing.py`

is the SHA1 of the distribution certificate, and Apple Distribution certificates expire after one year. After renewal, select the new distribution certificate in Keychain Access, check the value under "Get Info" → "SHA-1 fingerprint," and rewrite the constant. Forget to rewrite it and `find_cert()`

scans all 200 certificates, finds no match, and terminates with `sys.exit(1)`

. The stable practice is to put certificate renewal in your calendar and, on the day you renew, complete the `setup_signing.py`

rewrite and a verification run as a set.

"With an API key, you just generate a JWT and call the API" is correct — but that JWT has to be ES256 raw r‖s encoding, `iat`

needs a 30-second clock-skew cushion, forget `include=preReleaseVersion`

and builds are invisible, and tester addition returns 409 unless you use create-with-group. You don't reach the state of "hitting the API without 2FA" until you've stepped on every one of these yourself.

The `asc.py`

introduced here is 272 lines, and the three commands `apps`

/ `status`

/ `submit`

are its core — but behind them sit every stumbling point listed above. Implement it after reading what's here and you can clear the DER problem that cost me two hours, and the clock-skew problem that ate tens of minutes, on the first pass.

Finally, a look back at the structure. Layer 1 produces the binary (`archive.sh`

with Manual Signing), Layer 2 transfers it (`eas submit`

), and Layer 3 submits for review through the ASC API (`asc.py submit`

). Only with all three layers in place do you get an environment where "review submissions for 12 apps run to completion while I'm asleep." The code is right there. All that's left is to run it.

In the next post (Part 2), I'll explain wiring this `asc.py`

into Claude Code's autonomous loop, the breakthrough procedure for INVALID_BINARY resubmissions, and the pipeline design for managing 12 apps in parallel.

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)*
