{"slug": "two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1", "title": "Two Hours Lost to a Silent 401: Submitting 12 iOS Apps to the App Store With No Human in the Loop (Part 1)", "summary": "A developer who rebuilt his income around an autonomous Claude Code environment reports monthly revenue surpassing ¥1.2M after being laid off. He details how an App Store Connect API key enables fully automated app submissions without human interaction, eliminating the 2FA bottleneck. The key technical challenge is correctly generating an ES256 JWT with raw r||s encoding, as Python's default DER encoding causes silent 401 errors.", "body_md": "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.\n\nWhen 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.\n\nThere's a more fundamental problem too: **anything that depends on 2FA can't be handed to a bot**. fastlane's `deliver`\n\nis convenient, but every time the session cookie expires, an interactive auth prompt fires. On CI, that's a dead end.\n\nAn App Store Connect API key (the `.p8`\n\nfile) 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.\n\nRight 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`\n\nis running, every app gets submitted while I'm drinking coffee.\n\n\"Open Xcode every time\" is a task. \"Anyone (or anything) with the API key can submit\" is an environment.\n\nGrinding 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.\n\n\"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.\"\n\nIn the next section I'll get concrete about what this trap actually is, and about the code I'm really using.\n\nStart with the big picture. From binary generation to App Store review submission, my environment splits into three layers.\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Layer 1: バイナリ生成                                    │\n│   xcodebuild archive  (tools/archive.sh)                │\n│   または eas build --local  (Expo系アプリ)              │\n└──────────────────┬──────────────────────────────────────┘\n                   │ .ipa\n                   ▼\n┌─────────────────────────────────────────────────────────┐\n│ Layer 2: バイナリ転送                                    │\n│   eas submit  (Transporter相当・クラウド枠消費ゼロ)      │\n└──────────────────┬──────────────────────────────────────┘\n                   │ processingState: VALID\n                   ▼\n┌─────────────────────────────────────────────────────────┐\n│ Layer 3: 状態確認 / メタ編集 / 審査提出                 │\n│   python3 ~/.appstoreconnect/asc.py {apps|status|submit}│\n│   2FA不要・JWT認証・アカウント横断で使える              │\n└─────────────────────────────────────────────────────────┘\n```\n\nLayer 3 is the topic here. `asc.py`\n\nis only 272 lines, but it covers nearly every operation the review lifecycle needs.\n\n```\n# 全アプリ一覧\npython3 ~/.appstoreconnect/asc.py apps\n\n# 特定アプリの審査状態・ビルド状態を確認\npython3 ~/.appstoreconnect/asc.py status <app_id>\n\n# 審査に提出\npython3 ~/.appstoreconnect/asc.py submit <app_id>\n```\n\nLet's walk through why this runs without 2FA, and how it's implemented internally.\n\nThe App Store Connect API key is managed as two files under `~/.appstoreconnect/`\n\n.\n\n`~/.appstoreconnect/keys.json`\n\n```\n{\n  \"key_id\":    \"XXXXXXXXXX\",\n  \"issuer_id\": \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\",\n  \"key_path\":  \"~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8\"\n}\n```\n\n** ~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8** (the private key itself, downloadable from ASC exactly once)\n\nAt the top of `asc.py`\n\n, `keys.json`\n\nis loaded and those three values are held as constants.\n\n```\nCFG = json.load(open(os.path.expanduser(\"~/.appstoreconnect/keys.json\")))\nKEY_ID, ISSUER = CFG[\"key_id\"], CFG[\"issuer_id\"]\nP8 = os.path.expanduser(CFG[\"key_path\"])\n```\n\nThe only secret is the single `.p8`\n\nfile. `keys.json`\n\ncontains nothing but the key ID and issuer ID. That separation matters: even if `keys.json`\n\nends up in Git (not that I recommend it), it isn't an immediate leak incident. Managing just the `.p8`\n\nstrictly is enough. In my environment the `.p8`\n\nsits in `~/.appstoreconnect/`\n\nand the whole directory is `chmod 700`\n\n. When putting this on CI/CD, write the file out from a secret store at runtime.\n\nThe heart of JWT auth is the `_jwt()`\n\nfunction. Here's the actual code, verbatim.\n\n``` python\ndef _b64(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\ndef _jwt():\n    h = _b64(json.dumps({\"alg\":\"ES256\",\"kid\":KEY_ID,\"typ\":\"JWT\"},\n                         separators=(\",\",\":\")).encode())\n    p = _b64(json.dumps({\"iss\":ISSUER,\n                          \"iat\":int(time.time())-30,\n                          \"exp\":int(time.time())+900,\n                          \"aud\":\"appstoreconnect-v1\"},\n                         separators=(\",\",\":\")).encode())\n    signing = h + b\".\" + p\n    key = serialization.load_pem_private_key(open(P8,\"rb\").read(), password=None)\n    der = key.sign(signing, ec.ECDSA(hashes.SHA256()))\n    r, s = decode_dss_signature(der)\n    return (signing + b\".\" + _b64(r.to_bytes(32,\"big\") + s.to_bytes(32,\"big\"))).decode()\n```\n\nThe trap is in the last two lines.\n\n`key.sign()`\n\nreturns 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]`\n\n. **Apple does not accept this DER.**\n\nWhat Apple's ES256 JWT requires is the \"fixed 64-byte raw encoding\" defined in RFC 7518 Section 3.4. That is: `r`\n\nas 32 bytes and `s`\n\nas 32 bytes, big-endian, concatenated into 64 bytes total, then Base64URL-encoded.\n\n```\n# NG: DERをそのままBase64URLにしても401になる\n_b64(der)\n\n# OK: DERをデコードしてr,sを取り出し、生の32バイトで連結する\nr, s = decode_dss_signature(der)\n_b64(r.to_bytes(32, \"big\") + s.to_bytes(32, \"big\"))\n```\n\n`decode_dss_signature`\n\nis a function from the `cryptography`\n\nlibrary that converts a DER-format signature into a Python integer tuple `(r, s)`\n\n. From there, `to_bytes(32, \"big\")`\n\nturns each into a 32-byte sequence, and you concatenate them and Base64URL-encode — that's the correct procedure.\n\nWhy `32`\n\nbytes? Because ES256 uses the NIST P-256 curve, and that curve's order fits in 32 bytes (256 bits). Even when `r`\n\nor `s`\n\nhappens to be a small value (leading byte zero), it must still be zero-padded to 32 bytes. `r.to_bytes(32, \"big\")`\n\nhandles that automatically.\n\nGet this implementation wrong and what Apple returns is always `401 Unauthorized`\n\n. 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.\n\n`iat`\n\nis 30 seconds in the past\nOne more small but important point.\n\n```\n\"iat\": int(time.time()) - 30,\n```\n\n`iat`\n\n(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.\n\nThe expiration is set to 900 seconds (15 minutes) from now. That's plenty for a JWT that gets thrown away after one request.\n\nHere's the full list of subcommands `asc.py`\n\nprovides.\n\n| Command | Purpose |\n|---|---|\n`apps` |\nPrint every managed app (ID, Bundle ID, name) |\n`status <id>` |\nCheck version state, review state, and the processing state of the latest build |\n`submit <id>` |\nSubmit the editable version for review (create reviewSubmission → add item → submitted=true) |\n`make-version <id> <ver>` |\nPrepare or update the version string on the App Store |\n`attach-build <id> <ver>` |\nAttach a processed build to a version |\n`whatsnew <id> <text>` |\nSet the \"What's New\" text for all locales at once |\n`release <id> <ver> <whatsnew>` |\nRun the three above together (for verification; does not submit) |\n`reject <id>` |\nWithdraw an in-review submission via Developer Reject |\n`dedup <id> [--apply]` |\nDetect and delete duplicate screenshots (dry-run / apply toggle) |\n`add-tester <id>` |\nIdempotently add a TestFlight internal tester |\n\nThe design axis is **idempotency**. For example, before submitting for review, `submit`\n\nchecks whether a `READY_FOR_REVIEW`\n\nreviewSubmission already exists and reuses it if so.\n\n``` python\ndef submit(app_id, platform=\"IOS\"):\n    _, rs = call(\"GET\",\n        f\"/v1/reviewSubmissions?filter[app]={app_id}&filter[state]=READY_FOR_REVIEW&limit=1\")\n    sub = (rs.get(\"data\") or [None])[0]\n    if not sub:\n        # 新規作成\n        st, r = call(\"POST\", \"/v1/reviewSubmissions\", {...})\n        ...\n    sid = sub[\"id\"]\n    # バージョンをitemとして追加\n    ...\n    # submitted=true で提出\n    st, r = call(\"PATCH\", f\"/v1/reviewSubmissions/{sid}\",\n        {\"data\": {\"type\": \"reviewSubmissions\", \"id\": sid,\n                  \"attributes\": {\"submitted\": True}}})\n```\n\nFor 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.\n\nLet's also look at `tools/archive.sh`\n\n, which produces the binary.\n\n```\nxcodegen generate\nrm -rf build/Auraly.xcarchive build/export\nxcodebuild archive \\\n  -project Auraly.xcodeproj \\\n  -scheme Auraly \\\n  -configuration Release \\\n  -archivePath build/Auraly.xcarchive \\\n  -destination 'generic/platform=iOS' \\\n  CODE_SIGN_STYLE=Manual \\\n  CODE_SIGN_IDENTITY=\"Apple Distribution\" \\\n  PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\" \\\n  -allowProvisioningUpdates\nxcodebuild -exportArchive \\\n  -archivePath build/Auraly.xcarchive \\\n  -exportOptionsPlist ExportOptions.plist \\\n  -exportPath build/export\n```\n\nThe key point is `CODE_SIGN_STYLE=Manual`\n\n. 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.\n\nThe provisioning profile itself is generated and installed automatically from the ASC API by `tools/setup_signing.py`\n\n. It creates an App Store distribution profile via the `/v1/profiles`\n\nAPI and writes it directly into `~/Library/MobileDevice/Provisioning Profiles/`\n\n, so the signing environment is ready without ever opening Xcode. Certificate matching uses the SHA1 fingerprint:\n\n```\nLOCAL_SHA1 = \"EC06777A693874E920CECFE390D467670552CCCE\".lower()\n...\nder = base64.b64decode(content)\nsha1 = hashlib.sha1(der).hexdigest()\nif sha1 == LOCAL_SHA1:\n    return c[\"id\"]\n```\n\nThis 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?\"\n\nIn the next post (Part 2), I'll go into detail on wiring this `asc.py`\n\ninto 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.\n\n`asc.py`\n\nhas exactly one dependency: the `cryptography`\n\nlibrary. For HTTP it uses `urllib.request`\n\n.\n\n``` python\ndef call(method, path, body=None):\n    url = path if path.startswith(\"http\") else BASE + path\n    data = json.dumps(body).encode() if body is not None else None\n    req = urllib.request.Request(url, data=data, method=method,\n        headers={\"Authorization\":\"Bearer \"+_jwt(), \"Content-Type\":\"application/json\"})\n    try:\n        r = urllib.request.urlopen(req); raw = r.read()\n        return r.status, (json.loads(raw) if raw else None)\n    except urllib.error.HTTPError as e:\n        return e.code, json.loads(e.read() or b\"{}\")\n```\n\nThe reason for not using `requests`\n\nis 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`\n\nstep changes the friction completely.\n\nOne more thing: `call()`\n\ncalls `_jwt()`\n\nevery 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.\n\nHTTPError handling is kept minimal too. It returns the status code and response body as-is, and callers stick to the `if st >= 400: ... return`\n\npattern. Branching control flow with exceptions mixes in stack traces and hurts readability, so I keep a consistent \"judge by number, return early\" style.\n\nThe 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`\n\n).\"\n\nThe `/v1/builds`\n\nendpoint 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`\n\n, and it isn't returned unless you explicitly pass `include=preReleaseVersion`\n\nin the query.\n\n``` python\ndef _build_for_version(app_id, version_string):\n    _, b = call(\"GET\", f\"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate\"\n                        f\"&include=preReleaseVersion\")\n    incl = {i[\"id\"]: i for i in b.get(\"included\", []) if i[\"type\"] == \"preReleaseVersions\"}\n    for x in b.get(\"data\", []):\n        if x[\"attributes\"].get(\"processingState\") != \"VALID\":\n            continue\n        pr = x.get(\"relationships\", {}).get(\"preReleaseVersion\", {}).get(\"data\")\n        ver = incl.get(pr[\"id\"], {}).get(\"attributes\", {}).get(\"version\") if pr else None\n        if ver == version_string:\n            return x[\"id\"], x[\"attributes\"].get(\"version\")\n    return None\n```\n\nAdding `include=preReleaseVersion`\n\nputs objects of type `preReleaseVersions`\n\ninto the response's `included`\n\narray. Turning that into a dict keyed by ID (`incl`\n\n) and looking up each build's `relationships.preReleaseVersion.data.id`\n\nis the crux of this code.\n\n**Skipping processingState != \"VALID\"** matters too. Builds whose binary processing hasn't finished on Apple's side are in\n\n`PROCESSING`\n\nor `INVALID`\n\nstate. Trying to attach one of those to a version returns `409`\n\n. Filtering to `VALID`\n\nautomatically excludes builds that are still \"processing\" right after upload.If you run `fastlane deliver`\n\nmultiple times with `sync_screenshots: false`\n\n, 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.\n\n`dedup_screenshots()`\n\nsolves it using the combination of `sourceFileChecksum`\n\nand `fileName`\n\nas the key.\n\n``` python\ndef dedup_screenshots(app_id, apply=False):\n    ...\n    for sh in shots.get(\"data\", []):\n        key = (sh[\"attributes\"].get(\"sourceFileChecksum\"),\n               sh[\"attributes\"].get(\"fileName\"))\n        if key in seen:\n            if apply:\n                st, _ = call(\"DELETE\", f\"/v1/appScreenshots/{sh['id']}\")\n                print(f\"  [{locale}/...] DELETE {sh['id']} -> {st}\")\n            else:\n                print(f\"  [{locale}/...] dup {sh['id']} (dry-run)\")\n            total += 1\n        else:\n            seen.add(key)\n```\n\nThe important part is that ** apply=False makes dry-run the default**. Just running\n\n`asc.py dedup <app_id>`\n\nonly prints how many duplicates exist; actual deletion happens only when you pass `--apply`\n\n. 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`\n\nimmediately before `submit`\n\n. It's just a sequential call inside a batch script, so a human never has to think about it.\n\n`tools/setup_signing.py`\n\nis the script that \"prepares the certificate / Bundle ID / provisioning profile trio without opening Xcode.\"\n\n**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.\n\nProvisioning profile management follows a \"delete the old one, then recreate\" pattern.\n\n``` python\ndef ensure_profile(cert_id, bundle_internal_id):\n    _, d = asc.call(\"GET\", \"/v1/profiles?limit=200&filter[profileType]=IOS_APP_STORE\")\n    for p in d.get(\"data\", []):\n        if p[\"attributes\"].get(\"name\") == PROFILE_NAME:\n            asc.call(\"DELETE\", f\"/v1/profiles/{p['id']}\")\n            print(\"deleted stale profile\", p[\"id\"])\n    st, r = asc.call(\"POST\", \"/v1/profiles\", {...})\n    ...\n    uuid = attrs[\"uuid\"]\n    content = base64.b64decode(attrs[\"profileContent\"])\n    dest_dir = os.path.expanduser(\"~/Library/MobileDevice/Provisioning Profiles\")\n    dest = os.path.join(dest_dir, f\"{uuid}.mobileprovision\")\n    with open(dest, \"wb\") as f:\n        f.write(content)\n```\n\nThe 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.\"\n\nWriting directly into `~/Library/MobileDevice/Provisioning Profiles/`\n\nunder the UUID filename is important too — that's what lets `xcodebuild`\n\nresolve `PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\"`\n\nby name. No need to press Xcode's download button.\n\nAdding 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>`\n\n.\n\nNear the end of the code there's a comment like this:\n\n``` python\ndef add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first=\"Lily\", last=\"Tester\"):\n    \"\"\"...\n    注意: 外部グループや `betaGroups/{id}/relationships/betaTesters` 直リンクは\n    409 STATE_ERROR(Tester cannot be assigned)になる。create-with-group が唯一通る。\"\"\"\n```\n\nThat comment is a record of failure (more on that in the next section). The correct pattern is to create via `POST /v1/betaTesters`\n\nwith `relationships.betaGroups`\n\nincluded in the body.\n\n```\nst, r = call(\"POST\", \"/v1/betaTesters\",\n    {\"data\": {\"type\": \"betaTesters\",\n              \"attributes\": {\"email\": email, \"firstName\": first, \"lastName\": last},\n              \"relationships\": {\"betaGroups\": {\"data\": [{\"type\": \"betaGroups\", \"id\": gid}]}}}})\n```\n\nDoing \"create the tester\" and \"assign group membership\" in a single request keeps it from colliding with Apple's state management.\n\nAlso, `_has_tester()`\n\nruns 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.\n\nBuilding 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.\n\nThe night I first implemented `asc.py`\n\n, I assembled the JWT, hit the API, and got `401 Unauthorized`\n\n.\n\nPython's `key.sign(signing, ec.ECDSA(hashes.SHA256()))`\n\nappears 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.\n\nLooking at the error response body, all you get is `{\"errors\":[{\"status\":\"401\",\"code\":\"NOT_AUTHORIZED\",\"title\":\"Authentication credentials are missing or invalid.\"}]}`\n\n— a zero-information message. It doesn't tell you \"your signature is broken.\"\n\nAfter about two hours re-checking header formats, the `aud`\n\nvalue, and the `exp`\n\ncalculation, 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)\"**.\n\nWhat `key.sign()`\n\nreturns is a variable-length DER byte string. It starts with an ASN.1 encoding like `30 xx 02 xx...`\n\n, and the lengths of r and s vary. I was Base64URL-ing that directly.\n\n```\n# 書いていたコード（間違い）\nsig_b64 = _b64(der)\n\n# 正しいコード\nr, s = decode_dss_signature(der)\nsig_b64 = _b64(r.to_bytes(32, \"big\") + s.to_bytes(32, \"big\"))\n```\n\nConvert DER back to Python integers `(r, s)`\n\nwith `decode_dss_signature`\n\n, 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.\n\nA 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.\n\nIf Apple's API servers and the local machine are off by a few to a dozen-odd seconds, `iat`\n\n(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.\n\nThe fix is simple: set `iat`\n\nto **30 seconds before** the current time.\n\n```\n\"iat\": int(time.time()) - 30,\n```\n\nThat 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.\n\nFor adding TestFlight testers, my first approach was \"fetch the internal group first, then add the tester to that group.\"\n\n```\n# 失敗したパターン\nasc.call(\"POST\", f\"/v1/betaGroups/{gid}/relationships/betaTesters\",\n    {\"data\": [{\"type\": \"betaTesters\", \"id\": tester_id}]})\n```\n\nThis returns `409 STATE_ERROR: Tester cannot be assigned`\n\n. Directly linking an external tester to an internal group is treated as a disallowed transition in Apple's state machine.\n\nThe correct method is to **specify the group at the moment you create the tester**. Send `relationships.betaGroups`\n\nin the request body of `POST /v1/betaTesters`\n\n. That one shot atomically completes both \"create the tester\" and \"assign group membership.\"\n\nFrom 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.\n\nWhen uploading screenshots with `fastlane deliver`\n\n, running it multiple times with `sync_screenshots: false`\n\n(or the omitted default) **appends screenshots instead of replacing them**.\n\nThe first submission is fine. When you fix something after a rejection and try to resubmit, re-running `deliver`\n\ndoubles the screenshots — 4 become 8 on the second `deliver`\n\n. 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`\n\n.\n\nFrom 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.\"\n\n`dedup_screenshots()`\n\nis what I built in response. It deletes the second and subsequent entries where both `sourceFileChecksum`\n\nand `fileName`\n\nmatch. Using `sourceFileChecksum`\n\nalone leaves the rare case where different files share a checksum, so the filename is the second key.\n\nMaking it verifiable in advance via dry-run without `--apply`\n\nalso came from this failure — I learned that \"if you wipe everything at once, you can't get it back.\"\n\nWhen attaching a build to a version in `attach_build()`\n\n, my first implementation only hit `/v1/builds?filter[app]=xxx&limit=20&sort=-uploadedDate`\n\n. The App Store Connect UI clearly showed a VALID build, yet the script said `no VALID build for version 0.3.2`\n\n.\n\nThe cause: **the response doesn't include the marketing version ( 0.3.2)**. The\n\n`/v1/builds`\n\nresponse returns only build objects; the marketing version corresponding to a build is tied to a separate resource, `preReleaseVersion`\n\n. It doesn't appear in the response unless you `include`\n\nit.\n\n```\n# 修正後：include=preReleaseVersion を追加\n_, b = call(\"GET\", f\"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate\"\n                    f\"&include=preReleaseVersion\")\nincl = {i[\"id\"]: i for i in b.get(\"included\", [])\n        if i[\"type\"] == \"preReleaseVersions\"}\n```\n\nAdding `include`\n\nputs objects of type `preReleaseVersions`\n\ninto the response's `included`\n\narray. Turn that into a dict keyed by ID and look it up via each build's `relationships.preReleaseVersion.data.id`\n\n, and you get the marketing version string.\n\nThe App Store Connect API docs explain the `include`\n\nparameter, but there's no exhaustive table of \"which endpoint can include which resource,\" so you don't know until you try. I tried `preReleaseVersion`\n\nbecause I'd seen builds and version strings displayed as associated in the ASC UI and guessed \"that join has to exist somewhere.\"\n\nWhat 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]`\n\nand read them carefully. That's it.\n\nIn the next post (Part 2), I'll explain wiring this `asc.py`\n\ninto Claude Code's autonomous loop and the full pipeline design for managing 12 apps in parallel.\n\nI'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.\n\n**① A 401 comes back, but the error body says nothing about \"signature\"**\n\n`{\"code\":\"NOT_AUTHORIZED\",\"title\":\"Authentication credentials are missing or invalid.\"}`\n\nis also returned for a mis-encoded ES256 signature. The classic case is passing the DER returned by `key.sign()`\n\ninto `_b64(der)`\n\n. From Apple's side it only ever looks like \"authentication failed.\" The correct approach is to convert it into Python integers `(r, s)`\n\nwith `decode_dss_signature(der)`\n\nfrom `cryptography.hazmat.primitives.asymmetric.utils`\n\n, then pass the concatenation `r.to_bytes(32,\"big\") + s.to_bytes(32,\"big\")`\n\nto Base64URL. The import path itself is easy to get wrong, so watch out.\n\n**② Works in the morning, 401 in the early afternoon (low reproducibility)**\n\nThe cause is NTP drift on the Mac or delayed clock sync right after waking from sleep. If you leave `iat`\n\nas plain `int(time.time())`\n\n, a few seconds of divergence from Apple's server time gets it rejected as \"not yet valid.\" `asc.py`\n\nsubtracts 30 seconds with `int(time.time()) - 30`\n\n. Apple's tolerance margin measures out at roughly ±60 seconds, so this value leaves plenty of room.\n\n**③ 409 STATE_ERROR: Tester cannot be assigned (direct betaGroups link)**\n\nPassing an existing tester ID to `POST /v1/betaGroups/{id}/relationships/betaTesters`\n\nis 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`\n\ncomment (the `注意:`\n\nblock within its 272 lines), the only pattern that works is including `relationships.betaGroups`\n\nin the `POST /v1/betaTesters`\n\nbody so tester creation and group membership complete in a single request.\n\n**④ METADATA_ERROR: Too many screenshots after re-running fastlane deliver**\n\nRunning `deliver`\n\nmultiple times with `sync_screenshots: false`\n\nor 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>`\n\nand delete with `--apply`\n\n. Using only `sourceFileChecksum`\n\nas the dedup key occasionally causes wrong deletions, so both `(checksum, fileName)`\n\nare used as the key (see `key = (sh[\"attributes\"].get(\"sourceFileChecksum\"), sh[\"attributes\"].get(\"fileName\"))`\n\nin `asc.py`\n\n).\n\n**⑤ A VALID build is visible in the UI but you get no VALID build for version X.X.X**\n\nThe default `/v1/builds`\n\nresponse doesn't include the marketing version string (`0.3.2`\n\netc.). Without `&include=preReleaseVersion`\n\n, the `included`\n\narray comes back empty and you can't match on the version string. That's exactly why `_build_for_version()`\n\nin `asc.py`\n\nspells out that query parameter. The same symptom appears if you've uploaded so many builds in a short window that `limit=20`\n\nisn't enough, so for apps with many builds, raise `limit=`\n\nor check the `sort=-uploadedDate`\n\nordering.\n\n**⑥ version is WAITING_FOR_REVIEW (locked) error before submit**\n\nIn `WAITING_FOR_REVIEW`\n\nor `IN_REVIEW`\n\nstate you can neither edit the `appStoreVersion`\n\nnor add new `reviewSubmissionItem`\n\ns. `asc.py`\n\nallows writes only in the five states `_EDITABLE = {\"PREPARE_FOR_SUBMISSION\", \"DEVELOPER_REJECTED\", \"REJECTED\", \"METADATA_REJECTED\", \"INVALID_BINARY\"}`\n\nand returns a guard otherwise. To withdraw a review via Developer Reject and resubmit, use `asc.py reject <app_id>`\n\n(UNRESOLVED_ISSUES is also covered by that function).\n\n**⑦ I thought INVALID_BINARY meant having to recreate the version**\n\n`INVALID_BINARY`\n\nis in `_EDITABLE`\n\n. 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>`\n\n, then call `submit`\n\n. It saves the trouble of recreating the version.\n\n**⑧ setup_signing.py exits with no ASC cert matches local SHA1**\n\nAt the top of `setup_signing.py`\n\nthere's a constant `LOCAL_SHA1 = \"EC06777A...\".lower()`\n\n. 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`\n\nafter the annual renewal without updating this constant, `find_cert()`\n\nloops through every entry, finds no match, and hits `sys.exit(1)`\n\n. After renewing a certificate, always check the SHA1 in Keychain Access and rewrite the constant.\n\n**⑨ archive.sh fails to build because it can't resolve the provisioning profile**\n\n`archive.sh`\n\n's `PROVISIONING_PROFILE_SPECIFIER=\"Auraly AppStore\"`\n\nresolves by exact string match against `setup_signing.py`\n\n's `PROFILE_NAME = \"Auraly AppStore\"`\n\n. In an environment where `setup_signing.py`\n\nhasn't been run — or hasn't been re-run after a certificate renewal — the stale profile sticks around and `xcodebuild`\n\nfails code signing with either \"no matching profile found\" or \"certificate doesn't match.\" Keep the order `setup_signing.py`\n\n→ `archive.sh`\n\nat the head of the pipeline. Also, line 6 of `archive.sh`\n\nis `xcodegen generate`\n\n, and if you've changed `project.yml`\n\n, that step regenerates the `.xcodeproj`\n\n. Skipping it and building against a stale `.xcodeproj`\n\ncan make the archive fail due to scheme configuration mismatches.\n\n**⑩ You called release but nothing was submitted**\n\n`asc.py release <app_id> <ver> \"<whatsnew>\"`\n\nruns 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 する\"`\n\n(\"submit after verification\"). The design is: prepare with `release`\n\n, confirm on the ASC screen that everything looks right, then call `asc.py submit <app_id>`\n\nseparately. It's easy to call `release`\n\nat the end of an automation and mistakenly assume \"submitted\" — be careful.\n\n**⑪ Screenshots you deleted with dedup --apply reappear**\n\n`dedup_screenshots()`\n\nscans every locale and every size of whatever version `_latest_version()`\n\ncurrently returns. When that version is in a state outside `_EDITABLE`\n\n(e.g. `WAITING_FOR_REVIEW`\n\n), even `apply=True`\n\nreturns early with just a message and deletes nothing (the guard at line 186 of `asc.py`\n\n). Repeating `--apply`\n\nin that state changes nothing, so use `reject`\n\nto return it to an editable state first.\n\nHere are the rules that became standard from actually running 12 apps.\n\n**1. Isolate the .p8 with chmod 700 on the whole directory**\n\nJust creating `~/.appstoreconnect/`\n\nand running `chmod 700 ~/.appstoreconnect`\n\nprevents other users on the same machine from reading it. Since `keys.json`\n\nholds only `key_id`\n\nand `issuer_id`\n\n, 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`\n\n, and always verify that no `.p8`\n\nis in the commit history.\n\n**2. Generate the JWT on every request (don't cache it)**\n\n`call()`\n\ninvokes `_jwt()`\n\non 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.\n\n**3. Always test the decode_dss_signature → to_bytes(32, \"big\") concatenation**\n\nOnce 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] + \"==\"))`\n\nreturns 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.\n\n**4. Always subtract 30 seconds from iat**\n\nEven 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.\n\n**5. Every write operation should GET the current state before POST/PATCH**\n\n`submit()`\n\nfirst checks for an existing submission with `GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW`\n\nand reuses it if present. `add_tester()`\n\nfirst runs an existence check via `_has_tester()`\n\n. 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.\n\n**6. Memorize the five _EDITABLE states**\n\nOnly in the five states `PREPARE_FOR_SUBMISSION`\n\n, `DEVELOPER_REJECTED`\n\n, `REJECTED`\n\n, `METADATA_REJECTED`\n\n, and `INVALID_BINARY`\n\ncan you change version attributes, swap builds, or delete screenshots. Attempting those in any other state returns `409`\n\nor `422`\n\n. When the review flow gets stuck, first check the current `appStoreState`\n\nwith `asc.py status <app_id>`\n\n; if it isn't in _EDITABLE, you can decide either to `reject`\n\nfirst, or — where `reject`\n\nisn't needed — to wait for review to complete.\n\n**7. Slot dedup --apply in right before every submit**\n\nRegardless of how many times you've run `fastlane deliver`\n\n, get in the habit of running `asc.py dedup <app_id> --apply`\n\nimmediately before `submit`\n\n. Even when the dry-run shows zero, the execution cost is low, and it preempts `METADATA_ERROR`\n\nfrom 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`\n\n.\n\n**8. Manage provisioning profiles on a delete-then-recreate cycle**\n\n`ensure_profile()`\n\nin `setup_signing.py`\n\ndeletes 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`\n\nresolves it correctly.\n\n**9. Stabilize headless builds with CODE_SIGN_STYLE=Manual**\n\n`archive.sh`\n\nspecifies `CODE_SIGN_STYLE=Manual`\n\nbecause 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`\n\nresolving, by name, the profile that `setup_signing.py`\n\nwrote into `~/Library/MobileDevice/Provisioning Profiles/`\n\n— no GUI operation whatsoever.\n\n**10. Keep external libraries down to just cryptography**\n\n`asc.py`\n\nuses `urllib.request`\n\nfor HTTP and doesn't take `requests`\n\nas a dependency. Setting up a new Mac, running for the first time in a CI environment — removing one `pip install requests`\n\nstep changes the real friction substantially. `cryptography`\n\nalone 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`\n\neach time you carry the script into another project is a direct benefit of this design decision.\n\n**11. Print the whole response body for API errors with json.dumps(r)[:800]**\n\nEvery function in `asc.py`\n\nuses the uniform pattern `if st >= 400: print(json.dumps(r)[:800]); return`\n\n. Apple's error responses have a `detail`\n\nfield inside the `errors`\n\narray, and that's where the hint about the cause lives. If you print only `status`\n\nwithout showing `detail`\n\n, 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.\n\n**12. Always call release and submit as separate steps**\n\n`release <app_id> <ver> \"<whatsnew>\"`\n\nbundles version preparation, build attachment, and What's New setup, but deliberately does not submit. Calling `asc.py status <app_id>`\n\nafter `release`\n\nto confirm the metadata is set correctly, and only then calling `submit`\n\n, 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`\n\noutput is left in the log in a form Claude Code can read, and it proceeds to `submit`\n\nonly if there's no problem.\n\n**13. Always rewrite the SHA1 fingerprint when the certificate is renewed**\n\n`LOCAL_SHA1`\n\nin `setup_signing.py`\n\nis 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()`\n\nscans all 200 certificates, finds no match, and terminates with `sys.exit(1)`\n\n. The stable practice is to put certificate renewal in your calendar and, on the day you renew, complete the `setup_signing.py`\n\nrewrite and a verification run as a set.\n\n\"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`\n\nneeds a 30-second clock-skew cushion, forget `include=preReleaseVersion`\n\nand 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.\n\nThe `asc.py`\n\nintroduced here is 272 lines, and the three commands `apps`\n\n/ `status`\n\n/ `submit`\n\nare 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.\n\nFinally, a look back at the structure. Layer 1 produces the binary (`archive.sh`\n\nwith Manual Signing), Layer 2 transfers it (`eas submit`\n\n), and Layer 3 submits for review through the ASC API (`asc.py submit`\n\n). 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.\n\nIn the next post (Part 2), I'll explain wiring this `asc.py`\n\ninto Claude Code's autonomous loop, the breakthrough procedure for INVALID_BINARY resubmissions, and the pipeline design for managing 12 apps in parallel.\n\nI've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1", "canonical_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_at": "2026-08-21 05:00:09+00:00", "updated_at": "2026-08-21 05:13:55.579766+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": ["Apple", "App Store Connect", "Claude Code", "fastlane", "Xcode", "Expo"], "alternates": {"html": "https://wpnews.pro/news/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1", "markdown": "https://wpnews.pro/news/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1.md", "text": "https://wpnews.pro/news/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1.txt", "jsonld": "https://wpnews.pro/news/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-1.jsonld"}}