DEV Community

Lily
Lily

Posted on Originally published at dev.to

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

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.

Why this setup pays off

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.

"Tasks" versus "environments"

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

Why I called it a "trap"

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


The overall flow

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認証・アカウント横断で使える              │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Where the API key lives and how the config files are structured

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"
}
Enter fullscreen mode Exit fullscreen mode

~/.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"])
Enter fullscreen mode Exit fullscreen mode

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 ES256 JWT trap: raw r‖s, not DER

The heart of JWT auth is the _jwt() function. Here's the actual code, verbatim.

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()
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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.

Why iat is 30 seconds in the past

One more small but important point.

"iat": int(time.time()) - 30,
Enter fullscreen mode Exit fullscreen mode

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.

The design philosophy behind the commands

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.

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}}})
Enter fullscreen mode Exit fullscreen mode

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.

The Xcode archive side (archive.sh)

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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.

Implementation details

call(): an HTTP layer with zero external libraries

asc.py has exactly one dependency: the cryptography library. For HTTP it uses urllib.request.

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"{}")
Enter fullscreen mode Exit fullscreen mode

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.


_build_for_version(): matching on the marketing version

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.

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
Enter fullscreen mode Exit fullscreen mode

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.


dedup_screenshots(): pruning duplicate screenshots before submission

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.

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)
Enter fullscreen mode Exit fullscreen mode

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.


setup_signing.py: fully automating provisioning profile management

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.

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)
Enter fullscreen mode Exit fullscreen mode

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.


add_tester(): idempotently adding internal testers

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:

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 が唯一通る。"""
Enter fullscreen mode Exit fullscreen mode

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}]}}}})
Enter fullscreen mode Exit fullscreen mode

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.


Where I got stuck

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.


Failure 1: 401 forever, with no visible cause

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"))
Enter fullscreen mode Exit fullscreen mode

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.


Failure 2: JWTs that work some hours and not others

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,
Enter fullscreen mode Exit fullscreen mode

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.


Failure 3: betaGroups/{id}/relationships/betaTesters → 409 STATE_ERROR

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}]})
Enter fullscreen mode Exit fullscreen mode

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.


Failure 4: screenshots doubling every run, blocking submit

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


Failure 5: "no VALID build" even though a VALID build exists

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"}
Enter fullscreen mode Exit fullscreen mode

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.

Index of stumbling points

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 reviewSubmissionItems. 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.pyarchive.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.


Best practices

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


Summary

"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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)