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 (catappids.txt);dopython3 /.appstoreconnect/asc.pysubmit"(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認証・アカウント横断で使える │ └─────────────────────────────────────────────────────────┘ 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. 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" } ~/.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 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 () 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. Why 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. 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 Check version state, review state, and the processing state of the latest build submit Submit the editable version for review (create reviewSubmission → add item → submitted=true) make-version Prepare or update the version string on the App Store attach-build Attach a processed build to a version whatsnew Set the "What's New" text for all locales at once release Run the three above together (for verification; does not submit) reject Withdraw an in-review submission via Developer Reject dedup [--apply] Detect and delete duplicate screenshots (dry-run / apply toggle) add-tester 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 }}}) 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 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. 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 " {} " ) 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 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 ) 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 ) 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 が唯一通る。 """ 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 }, " relatio