Workflow IA

App Store Connect: submitting an iOS app from the terminal

Every submission, the same time lost clicking and uploading screenshots. This time: a bash script, the App Store Connect API and Chrome driven by Claude Code. One human click.

Goal: submit Parlotte to the App Store without opening App Store Connect by hand. A 34-line bash script, Apple's API, xcodebuild, and Chrome driven by Claude Code for the one screen Apple doesn't expose through an API. One human click in total: signing the agreement.

Every app, the same ritual. Find the right tab in App Store Connect. Fill in the same fields as last time. Upload the screenshots one by one, in the right format, in the right order. Privacy questionnaire, age rating, price, countries. That's not development, it's manual labour. This time, I wanted it to cost me nothing.

The problem: submission isn't code

Parlotte is an iOS app that measures speaking time around a table. The code was ready, tested by friends on TestFlight. What remained was the part nobody enjoys: the App Store listing. It requires a good dozen settings scattered across as many screens, and the web interface never tells you what's missing until you try to submit.

Yet Apple exposes almost all of it through the App Store Connect API. Almost: the privacy questionnaire only exists in the web interface. So the method has two layers. The API for everything it covers. A programmatically driven browser for the rest.

The principle: an API key instead of a password

The API authenticates with a key created in App Store Connect: a .p8 file, a key ID, an issuer ID. No password, no six-digit code, no session expiring at the worst moment. You sign an ES256 JWT, valid for twenty minutes, and pass it as a header. Everything else is curl.

The whole script is 34 lines of bash. The heart of it is the signature: openssl produces a DER-encoded signature, while the JWT expects the two integers R and S concatenated raw. Four lines of conversion, and it's done.

b64() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s); EXP=$((NOW+1200))
H=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$KEY_ID" | b64)
P=$(printf '{"iss":"%s","iat":%s,"exp":%s,"aud":"appstoreconnect-v1"}' "$ISSUER" "$NOW" "$EXP" | b64)
# DER -> raw R||S (64 bytes), otherwise Apple rejects the token
SIG=$(printf '%s.%s' "$H" "$P" | openssl dgst -sha256 -sign "$P8" | \
  openssl asn1parse -inform DER | grep INTEGER | sed 's/.*://' | \
  while read -r hx; do printf '%064s' "$hx" | tr ' ' '0'; done | xxd -r -p | b64)
JWT="$H.$P.$SIG"

curl -sg -X "$METHOD" -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" -d "$BODY" \
  "https://api.appstoreconnect.apple.com/v1/$PATH_"

From then on, every operation is one line: ./asc.sh GET "apps/6801887775/appStoreVersions", ./asc.sh PATCH "appStoreVersions/…" '{…}'. Readable, replayable, versionable. The .p8 key stays out of the repository: it's a secret that grants access to every app on the account.

First trap, silly and silent: curl treats the brackets in fields[apps]=name as ranges to expand. The command returns nothing, with no error. The -g flag turns that globbing off. One letter, and an empty answer.

The build: xcodebuild and the same key

No Xcode window either. The archive is built from the command line, so is the export, with destination=upload in the export plist so the binary goes straight to Apple. The build number is bumped in the project before every upload: Apple refuses the same one twice.

xcodebuild archive -project Parlotte.xcodeproj -scheme Parlotte \
  -configuration Release -destination 'generic/platform=iOS' \
  -archivePath /tmp/Parlotte.xcarchive -allowProvisioningUpdates

xcodebuild -exportArchive -archivePath /tmp/Parlotte.xcarchive \
  -exportOptionsPlist exportOptions.plist -allowProvisioningUpdates \
  -authenticationKeyPath /path/to/AuthKey_XXXXXXXXXX.p8 \
  -authenticationKeyID XXXXXXXXXX -authenticationKeyIssuerID <issuer-id>

Those last three flags are not decorative. Without them, the upload relies on the Apple ID session Xcode has cached. Mine had expired, and the export failed with a terse Failed to Use Accounts. With the API key, the very same one the bash script uses, the upload went through first time. Two minutes later, the build was processed and ready to attach.

The listing, field by field

Here is what had to be set for Apple to accept the submission, and where each setting lives in the API. None of these were filled in at the start, and the web interface only flags them the moment you click "Submit".

SettingAPI resourceTrap encountered
Copyright, release typeappStoreVersionsManual release, to update the website before the app becomes visible
Content rightsappsOpen-licensed models are not "third-party content" in Apple's sense
Age ratingageRatingDeclarations13 enums and 9 booleans; the resource only accepts PATCH, a GET returns 403; read back through appInfos
Review contact and notesappStoreReviewDetailsSeparate from the TestFlight sheet (betaAppReviewDetail): filling one doesn't fill the other
Free priceappPriceSchedulesInline creation: the local ID must keep its braces, ${price}, or you get a 409
Availability, 175 countriesv2/appAvailabilitiesThe response returns an empty list: verify with a second GET on the relationship
Privacy policy URLappInfoLocalizationsNot in the same place as the marketing and support URLs: easy to forget
Build attached to the versionappStoreVersions/…/relationships/buildNone
SubmissionreviewSubmissionsThree calls: create, add the version, set submitted to true

Every write is followed by a GET to read it back. Not out of distrust for the API, out of distrust for myself: a value the server accepted is not necessarily the one you thought you sent.

Screenshots: a demo mode inside the app

By hand, screenshots are the most tedious part. You open the simulator, navigate the app to the right screen, invent believable data so it isn't empty, set the clock, take the screenshot, and start again for every screen. Then redo everything at the next version, because a button moved. This time, nobody touched the simulator. Three ideas, and the whole thing fits in a few lines.

How the screenshots make themselves

1. The app knows how to stage itself. At launch, it looks at the arguments it was started with. If it sees -demo, it inserts a fictional conversation into its own database: five first names, speaking times, interruptions. With -demo-ouvre, it opens directly on the requested screen: the statistics, voice labelling, consent. Those arguments only exist from a developer's terminal: in normal use, the demo mode is unreachable.

2. The simulator obeys the terminal. The iOS simulator is an iPhone running on the Mac, and everything you do with a finger can also be done by command: boot it, freeze the clock at 9:41 like every Apple screenshot, switch to light mode, install the app, launch it with the right arguments.

3. The screenshot is a command too. Once the screen is showing, one line writes the image to disk, at the native resolution of the simulated device. A 6.9-inch simulator produces 1320 x 2868: exactly what Apple requires for that size, untouched.

In practice, the photo shoot looks like this. One preparation, then two lines per screen: launch the app on the desired screen, wait for the animation to settle, capture.

xcrun simctl status_bar booted override --time 9:41
xcrun simctl ui booted appearance light
xcrun simctl install booted /tmp/parlotte-dd/Build/Products/Debug-iphonesimulator/Parlotte.app

L="-AppleLanguages (fr) -AppleLocale fr_FR"
xcrun simctl launch --terminate-running-process booted eu.elastik.parlotte -demo -demo-ouvre termine $L
sleep 4 && xcrun simctl io booted screenshot 1-stats.png
xcrun simctl launch --terminate-running-process booted eu.elastik.parlotte -demo -demo-ouvre brouillon $L
sleep 3 && xcrun simctl io booted screenshot 4-consentement.png

Five screens, five repetitions, under a minute. When a screen changes, you rerun the sequence instead of redoing the photo shoot. And since the command is the same for every version, the 1.1 screenshots will be framed exactly like the 1.0 ones.

What remains is dropping them off at Apple, through the API, in three steps per image. First you announce the file, its name and size: Apple replies with a drop-off address. You send the bytes there. Finally you confirm with the file's checksum, so Apple can check nothing got damaged on the way. The order of the screenshots is a choice, not an accident: the consent screen comes first. For an app that records, that's the point Apple looks at closely.

Where Apple has no API: the browser, driven

The privacy questionnaire has no public API. It's the only screen that required a browser, and I didn't click through it myself. Chrome Canary was running with its debugging port open, and Claude Code drove it through the Chrome DevTools Protocol: 61 lines of Node to navigate, run JavaScript inside the page and take screenshots.

The technique is deliberately crude. No click coordinates, which break at the first layout change. You look up a button by its text and click it from inside the page:

[...document.querySelectorAll('button')]
  .find(b => /Get Started/i.test(b.textContent))
  .click()

Then a screenshot, checked before the next step. "No, we do not collect data", save, publish, confirm. Four clicks, four screenshots. And along the way, a detail that had been missing from the start: the privacy policy URL was empty in the interface. Set through the API within the minute.

The browser is also what revealed the real blocker. On the agreements page, a banner: the Apple Developer license agreement had been updated, and without acceptance by the account holder, no new submission goes through. The API would only have said so on the very last call, with a refusal.

What I kept for myself

That agreement, I accepted myself. Claude Code opened the page and stopped in front of the button: a legal commitment is not a click you delegate. Same logic for the privacy policy published on the website, which binds me as the data controller, and for choosing manual release over automatic.

The rest ran in parallel: one agent on the code and the build, one on the API, one on the website, then two reviewers tasked with contradicting the first three. One of them found a formal "vous" in an app that uses the informal "tu" everywhere else. Rebuild, next number, new upload. Five agents, 98 tool calls, eight and a half minutes.

Results

MeasureValue
From launching the agents to "Waiting for Review"29 minutes, one rebuild included
Build processing by Apple2 minutes
App Store Connect screens clicked by hand1: the agreement
Cost0 euros beyond the developer account

The limits

  • The privacy questionnaire stays outside the API - browser automation works, but an interface change on Apple's side can break it. You'll find out at the next screenshot, not before.
  • The API key is powerful - it opens every app on the account. Out of the repository, never in a message, never in a screenshot.
  • Errors are sometimes silent - an empty response, an empty list in a successful POST. Without systematic read-back, you believe you're done.

The verdict

  • Submission is a state problem, not a clicking problem. A dozen fields to set, in any order, verifiable one by one. That's exactly what an API does better than a human.
  • Automate up to the boundary, not beyond. An agreement is signed by hand. Knowing that before you start beats discovering it in production.
  • The traps are small and recurring. A -g flag, braces in an identifier, an expired session. Written down, they only cost once.
  • It isn't magic. It's a script, a key, and the discipline to verify. The next app will start from here.

Resources

Apple's API documentation, the protocol that drives Chrome, and the app that served as the guinea pig: Parlotte measures who speaks around the table, without understanding a word, without anything leaving the phone.

App Store Connect API · Chrome DevTools Protocol · Parlotte