cd /news/developer-tools/two-hours-lost-to-a-silent-401-submi… · home topics developer-tools article
[ARTICLE · art-105541] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

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.

read30 min views2 publishedAug 21, 2026

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

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

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

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

An App Store Connect API key (the .p8

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

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

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

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

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

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

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

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

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

Layer 3 is the topic here. asc.py

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

python3 ~/.appstoreconnect/asc.py apps

python3 ~/.appstoreconnect/asc.py status <app_id>

python3 ~/.appstoreconnect/asc.py submit <app_id>

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

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

.

~/.appstoreconnect/keys.json

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

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

At the top of asc.py

, keys.json

is loaded and those three values are held as constants.

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

The only secret is the single .p8

file. keys.json

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

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

strictly is enough. In my environment the .p8

sits in ~/.appstoreconnect/

and the whole directory is chmod 700

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

The heart of JWT auth is the _jwt()

function. Here's the actual code, verbatim.

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.

_b64(der)

r, s = decode_dss_signature(der)
_b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))

decode_dss_signature

is a function from the cryptography

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

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

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

Why 32

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

or s

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

handles that automatically.

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

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

iat

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

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

iat

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

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

Here's the full list of subcommands asc.py

provides.

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

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

checks whether a READY_FOR_REVIEW

reviewSubmission already exists and reuses it if so.

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"]
    ...
    st, r = call("PATCH", f"/v1/reviewSubmissions/{sid}",
        {"data": {"type": "reviewSubmissions", "id": sid,
                  "attributes": {"submitted": True}}})

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

Let's also look at tools/archive.sh

, which produces the binary.

xcodegen generate
rm -rf build/Auraly.xcarchive build/export
xcodebuild archive \
  -project Auraly.xcodeproj \
  -scheme Auraly \
  -configuration Release \
  -archivePath build/Auraly.xcarchive \
  -destination 'generic/platform=iOS' \
  CODE_SIGN_STYLE=Manual \
  CODE_SIGN_IDENTITY="Apple Distribution" \
  PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" \
  -allowProvisioningUpdates
xcodebuild -exportArchive \
  -archivePath build/Auraly.xcarchive \
  -exportOptionsPlist ExportOptions.plist \
  -exportPath build/export

The key point is CODE_SIGN_STYLE=Manual

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

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

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

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

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

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

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

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

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

asc.py

has exactly one dependency: the cryptography

library. For HTTP it uses urllib.request

.

def call(method, path, body=None):
    url = path if path.startswith("http") else BASE + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
        headers={"Authorization":"Bearer "+_jwt(), "Content-Type":"application/json"})
    try:
        r = urllib.request.urlopen(req); raw = r.read()
        return r.status, (json.loads(raw) if raw else None)
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"{}")

The reason for not using requests

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

step changes the friction completely.

One more thing: call()

calls _jwt()

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

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

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

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

)."

The /v1/builds

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

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

in the query.

def _build_for_version(app_id, version_string):
    _, b = call("GET", f"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate"
                        f"&include=preReleaseVersion")
    incl = {i["id"]: i for i in b.get("included", []) if i["type"] == "preReleaseVersions"}
    for x in b.get("data", []):
        if x["attributes"].get("processingState") != "VALID":
            continue
        pr = x.get("relationships", {}).get("preReleaseVersion", {}).get("data")
        ver = incl.get(pr["id"], {}).get("attributes", {}).get("version") if pr else None
        if ver == version_string:
            return x["id"], x["attributes"].get("version")
    return None

Adding include=preReleaseVersion

puts objects of type preReleaseVersions

into the response's included

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

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

is the crux of this code.

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

PROCESSING

or INVALID

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

. Filtering to VALID

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

multiple times with sync_screenshots: false

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

dedup_screenshots()

solves it using the combination of sourceFileChecksum

and fileName

as the key.

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

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

asc.py dedup <app_id>

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

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

immediately before submit

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

tools/setup_signing.py

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

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

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

def ensure_profile(cert_id, bundle_internal_id):
    _, d = asc.call("GET", "/v1/profiles?limit=200&filter[profileType]=IOS_APP_STORE")
    for p in d.get("data", []):
        if p["attributes"].get("name") == PROFILE_NAME:
            asc.call("DELETE", f"/v1/profiles/{p['id']}")
            print("deleted stale profile", p["id"])
    st, r = asc.call("POST", "/v1/profiles", {...})
    ...
    uuid = attrs["uuid"]
    content = base64.b64decode(attrs["profileContent"])
    dest_dir = os.path.expanduser("~/Library/MobileDevice/Provisioning Profiles")
    dest = os.path.join(dest_dir, f"{uuid}.mobileprovision")
    with open(dest, "wb") as f:
        f.write(content)

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

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

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

resolve PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"

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

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

.

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

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

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

with relationships.betaGroups

included in the body.

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

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

Also, _has_tester()

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

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

The night I first implemented asc.py

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

.

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

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

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

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

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

value, and the exp

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

What key.sign()

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

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

sig_b64 = _b64(der)

r, s = decode_dss_signature(der)
sig_b64 = _b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))

Convert DER back to Python integers (r, s)

with decode_dss_signature

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

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

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

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

The fix is simple: set iat

to 30 seconds before the current time.

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

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

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

asc.call("POST", f"/v1/betaGroups/{gid}/relationships/betaTesters",
    {"data": [{"type": "betaTesters", "id": tester_id}]})

This returns 409 STATE_ERROR: Tester cannot be assigned

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

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

in the request body of POST /v1/betaTesters

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

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

When up screenshots with fastlane deliver

, running it multiple times with sync_screenshots: false

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

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

doubles the screenshots — 4 become 8 on the second deliver

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

.

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

dedup_screenshots()

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

and fileName

match. Using sourceFileChecksum

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

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

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

When attaching a build to a version in attach_build()

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

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

.

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

/v1/builds

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

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

it.

_, b = call("GET", f"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate"
                    f"&include=preReleaseVersion")
incl = {i["id"]: i for i in b.get("included", [])
        if i["type"] == "preReleaseVersions"}

Adding include

puts objects of type preReleaseVersions

into the response's included

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

, and you get the marketing version string.

The App Store Connect API docs explain the include

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

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

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

and read them carefully. That's it.

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

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

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

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

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

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

into _b64(der)

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

with decode_dss_signature(der)

from cryptography.hazmat.primitives.asymmetric.utils

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

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

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

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

as plain int(time.time())

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

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

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

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

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

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

comment (the 注意:

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

in the POST /v1/betaTesters

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

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

Running deliver

multiple times with sync_screenshots: false

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

and delete with --apply

. Using only sourceFileChecksum

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

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

in asc.py

).

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

The default /v1/builds

response doesn't include the marketing version string (0.3.2

etc.). Without &include=preReleaseVersion

, the included

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

in asc.py

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

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

or check the sort=-uploadedDate

ordering.

⑥ version is WAITING_FOR_REVIEW (locked) error before submit

In WAITING_FOR_REVIEW

or IN_REVIEW

state you can neither edit the appStoreVersion

nor add new reviewSubmissionItem

s. asc.py

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

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

(UNRESOLVED_ISSUES is also covered by that function).

⑦ I thought INVALID_BINARY meant having to recreate the version

INVALID_BINARY

is in _EDITABLE

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

, then call submit

. It saves the trouble of recreating the version.

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

At the top of setup_signing.py

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

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

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

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

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

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

archive.sh

's PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"

resolves by exact string match against setup_signing.py

's PROFILE_NAME = "Auraly AppStore"

. In an environment where setup_signing.py

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

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

archive.sh

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

is xcodegen generate

, and if you've changed project.yml

, that step regenerates the .xcodeproj

. Skipping it and building against a stale .xcodeproj

can make the archive fail due to scheme configuration mismatches.

⑩ You called release but nothing was submitted

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

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

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

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

separately. It's easy to call release

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

⑪ Screenshots you deleted with dedup --apply reappear

dedup_screenshots()

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

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

(e.g. WAITING_FOR_REVIEW

), even apply=True

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

). Repeating --apply

in that state changes nothing, so use reject

to return it to an editable state first.

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

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

Just creating ~/.appstoreconnect/

and running chmod 700 ~/.appstoreconnect

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

holds only key_id

and issuer_id

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

, and always verify that no .p8

is in the commit history.

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

call()

invokes _jwt()

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

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

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

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

4. Always subtract 30 seconds from iat

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

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

submit()

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

and reuses it if present. add_tester()

first runs an existence check via _has_tester()

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

6. Memorize the five _EDITABLE states

Only in the five states PREPARE_FOR_SUBMISSION

, DEVELOPER_REJECTED

, REJECTED

, METADATA_REJECTED

, and INVALID_BINARY

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

or 422

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

with asc.py status <app_id>

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

first, or — where reject

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

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

Regardless of how many times you've run fastlane deliver

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

immediately before submit

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

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

.

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

ensure_profile()

in setup_signing.py

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

resolves it correctly.

9. Stabilize headless builds with CODE_SIGN_STYLE=Manual

archive.sh

specifies CODE_SIGN_STYLE=Manual

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

resolving, by name, the profile that setup_signing.py

wrote into ~/Library/MobileDevice/Provisioning Profiles/

— no GUI operation whatsoever.

10. Keep external libraries down to just cryptography

asc.py

uses urllib.request

for HTTP and doesn't take requests

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

step changes the real friction substantially. cryptography

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

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

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

Every function in asc.py

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

. Apple's error responses have a detail

field inside the errors

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

without showing detail

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

12. Always call release and submit as separate steps

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

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

after release

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

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

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

only if there's no problem.

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

LOCAL_SHA1

in setup_signing.py

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

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

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

rewrite and a verification run as a set.

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

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

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

The asc.py

introduced here is 272 lines, and the three commands apps

/ status

/ submit

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

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

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

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

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

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

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

I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

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

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @apple 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-hours-lost-to-a-…] indexed:0 read:30min 2026-08-21 ·