Skip to content
← Journal
13 min readAta Mohammadi

Automating App Store and Play Submissions: What the APIs Will Do, What They Silently Refuse, and the Guideline That Punishes Success

Shipping thirty-four apps through two stores from a terminal taught me that the build is the easy fifth of the problem. The hard parts are a write that returns 200 and stores nothing, a screenshot rejected after it uploads, and a spam guideline that gets stricter the better your automation works.

The build being green is about a fifth of shipping a mobile app, and it is the fifth every tutorial covers. The other four fifths are a store record only a human can create, a version that refuses review without saying which field is missing, a screenshot that uploads successfully and then fails, and — at the end — a reviewer deciding whether your portfolio is a portfolio or a spam farm.

These are field notes from putting thirty-four Expo apps through App Store Connect and Google Play from a terminal. Where I give an error string it is one I actually received, and where I was wrong about something I have said so. This is the submission half of the problem; the build half is a separate article, Autonomous CI/CD: Letting an Agent Triage the Build Without Letting It Ship.

The three things no API can do

Plan around these first, because they are the only truly immovable parts and everything else schedules around them.

  1. Creating an App Store Connect app record. POST /v1/apps answers 403 FORBIDDEN_ERROR: the resource 'apps' does not allow 'CREATE'. A widely circulated architecture note claims otherwise; it is wrong. The only route that mints an iOS app record is the iris endpoint from inside a signed-in browser session.
  2. Creating a Play Console app. The Play Developer API is anchored on an existing {packageName}. There is no endpoint that creates one.
  3. Creating AdMob apps and ad units. No public write API at all.

Everything else — versions, localisations, pricing, in-app purchases, screenshots, builds, TestFlight, the review submission itself — is scriptable on both stores. So the shape of a working pipeline is: a human does a few minutes of console clicking per app, once, and a machine does everything else, forever.

On not using fastlane

We do not use fastlane. I want to be careful here, because the convenient version of this story is that fastlane is dying, and that is not true. I checked rather than assumed: RubyGems reports 2.240.1, released 2026-09-15, following 2.240.0 on the 14th and 2.239.0 on the 4th. Monthly-or-better releases and a quarter of a billion downloads is not an abandoned project, whatever the louder GitHub issues suggest.

The reason was environmental and boring. This machine's Python is externally managed, so PEP 668 blocks pip install, and the Ruby toolchain was one more thing to own on a box already carrying Xcode, Android Studio, two Gradle daemons and an emulator. Both stores authenticate with asymmetric keys and speak plain HTTPS, and macOS ships openssl and xcrun. So the whole thing is standard-library Python.

That is a defensible trade, not a universal recommendation. If you have a Ruby toolchain and a normal number of apps, deliver and supply will save you the several hundred lines below. What you buy by dropping to the raw APIs is that when something fails you are looking at the actual response body — and, as the Play section shows, that is frequently the only place the truth is written down.

The rule that covers most of the others

A successful write means nothing. Read the field back.

This sounds like paranoia until the first time it saves you, and then it becomes the only way you write store automation. The specific instances:

  • PATCH /v1/apps answers 200 and stores nothing. The same PATCH to /iris/v1/apps/<id> stores it.
  • POST /v1/betaGroups answers 201 with isInternalGroup: false however you ask for an internal one. Asking for an internal group by name gets you an external duplicate sitting beside the real one. Once, that produced ten of them.
  • Uploading a build does not put it on TestFlight. A processed build reports internalBuildState: READY_FOR_BETA_TESTING and reaches nobody. It must be attached to a group, the group needs a tester, and the group App Store Connect creates for you has hasAccessToAllBuilds: false. All three failures are silent, and together they are why an app can sit in App Store review while appearing absent from TestFlight.
  • A list read from page one is not the list. Follow links.next.

Every command in our pipeline reads remote state first and writes only the difference. Running one twice is how you confirm it worked, not how you get two of everything.

Screenshots, which are the actual bottleneck

Of the thirty-four apps tracked in our readiness report, six have even one iPhone screenshot. Every one of them has a working build. That ratio is the honest summary of this whole problem: builds are solved, images are not.

Apple simplified the requirement, which helps. You now supply only the largest device in each family — a 6.9" iPhone set, and a 13" iPad set if you declare tablet support — and Apple scales down for everything else. The display type enums are APP_IPHONE_67 and APP_IPAD_PRO_3GEN_129; the latter is the slot the console now labels 13", despite the 129 in the name. Accepted dimensions:

APP_IPHONE_67          1290x2796  or  1320x2868
APP_IPAD_PRO_3GEN_129  2048x2732  or  2064x2752

Any one of them is fine. All of them must be the same within one set.

Uploading is a three-step dance rather than a POST. You reserve the asset, stream the byte ranges Apple hands back, then commit with a checksum:

reserved = call("POST", "/appScreenshots", json_body={"data": {
    "type": "appScreenshots",
    "attributes": {"fileName": path.name, "fileSize": len(payload)},
    "relationships": {"appScreenshotSet": {
        "data": {"type": "appScreenshotSets", "id": set_id}}},
}})["data"]

for op in reserved["attributes"]["uploadOperations"]:
    request(op["method"], op["url"],
            headers={h["name"]: h["value"] for h in op["requestHeaders"]},
            data=payload[op["offset"]: op["offset"] + op["length"]])

call("PATCH", f"/appScreenshots/{reserved['id']}", json_body={"data": {
    "type": "appScreenshots", "id": reserved["id"],
    "attributes": {"uploaded": True,
                   "sourceFileChecksum": hashlib.md5(payload).hexdigest()},
}})

And now the trap that cost the most time of anything in this article.

A screenshot may not have an alpha channel, and every simulator PNG has one. Apple accepts the upload. The commit returns 200. The asset then sits in FAILED with IMAGE_ALPHA_NOT_ALLOWED, and the console reports only "There are still screenshot uploads in progress" — forever. The submission is blocked and nothing anywhere names the cause.

So the commit is not the end of the operation. Read the state back and refuse anything that is not COMPLETE:

state = (shot["attributes"].get("assetDeliveryState") or {}).get("state", "UNKNOWN")

Stripping the alpha is worth doing properly: composite the image onto white rather than discarding the channel, or every translucent pixel becomes a pale halo. We wrote a PNG encoder against zlib and struct to do it, because the two obvious shortcuts both failed — sips --setProperty hasAlpha no is not a thing sips does (it exits 13 with a usage message), and sharp lived in a sibling repository's node_modules, which is a dependency on a directory that had already moved once.

Two smaller lessons about what to capture:

Drive the walk by accessibility label, not coordinates. The same route has to run on a 6.9" phone and a 13" iPad, where every coordinate differs and every label does not.

The paywall is not a marketing screenshot. It is easy to automate and tempting to include, and a listing whose second image is a price is selling the wrong thing. Apple wants a paywall shot too, but as the IAP review screenshot, a separate asset on a separate route — and its absence is the single thing that keeps a product in MISSING_METADATA with every visible field filled in.

There is a matching horror story on the other side. One app was rejected under 3.1.1 — "we were unable to locate the in-app purchases" — and the binary was innocent; it had a StoreKit sheet with four subscriptions in it. The listing was the problem: every screenshot showed a panel reading "Subscriptions… are managed via your web dashboard", and the iPad frames showed a "Renew Subscription" button with an external-link arrow. A reviewer saw purchases steered to the web and no in-app purchase anywhere. The fix was replacing images, not code.

Ordering traps

Several steps are blocked by earlier ones in ways that are not discoverable from the documentation.

A Play app has no package name until its first bundle is uploaded. Until then the API answers 404 Package not found and no in-app product can exist. The order is: create app → upload an AAB to internal testing → then create the product. Build that AAB from a non-production profile so internal testers do not generate live ad impressions.

Check the package name Play expects before you build. A Play record can already carry one, and the first upload is rejected outright if the bundle disagrees. Neither side is editable afterwards. One of our apps now has iOS bundle id com.altixcode.cubex and Android package com.altixcode.gridlockpop permanently, because that mismatch was discovered by the rejection rather than before it. They are allowed to differ and neither is user-visible, so this is cosmetic — but the fix would have meant deleting a store record, which cascades into RevenueCat and invalidates SDK keys.

Check the upload key too. If the Play record carries an upload key from an earlier build, a fresh EAS keystore is refused: "Your Android App Bundle is signed with the wrong key." Compare the SHA-1 before spending a build:

unzip -o -q app.aab -d /tmp/aab && keytool -printcert -file /tmp/aab/META-INF/*.RSA | grep SHA1

If they differ and the original keystore is gone, Play's upload key reset takes a couple of days.

One open edit per package, ever. A second edit silently invalidates the first and drops everything staged in it. Our edit helper is a context manager that commits on success and deletes on failure, rather than leaving an orphan to expire and poison the next run.

Play prices are in micros. $3.99 is 3990000. A price given in cents is accepted without complaint and is a third of a cent.

CFBundleShortVersionString must equal the version string exactly. To Apple, 1.0 and 1.0.0 are different versions: the build never appears under the version, and nothing says why.

Read the network response, not the toast

The most valuable single lesson was not an API detail.

Play app creation started failing with the console showing "An unexpected error has occurred. Please try again." and a fresh reference code every time — (5E32CCB5), (6A476C89). A changing reference code reads like a transient glitch for as long as you are willing to believe it, and two days went into treating it as a daily creation cap. The wire said something else:

POST .../developers/<id>:createAppV2
  -> 429
  {"1":8,"2":"Resource has been exhausted (e.g. check quota)."}

Code 8 is RESOURCE_EXHAUSTED. The account holds fifteen apps and every further creation returns 429, across three days and a restart. No amount of retrying clears an account quota; it needs a support request. Read the network response before believing a console toast.

A companion: when a step fails, check whether it failed before or after the thing it was doing. An Android job reported failure on changesNotSentForReview must not be set — refused for an app that has never been published — but that error arrived after the bundle transferred successfully. Re-running would have uploaded a second copy of the same version.

The bug that costs money silently

Ten EXPO_PUBLIC_* identifiers — two AdMob app ids, six ad units, two RevenueCat keys — have to reach both the EAS production environment and the repository secrets.

A missing one does not fail anything. The ads SDK falls back to Google's test ad units, and the app runs perfectly, shows ads, and earns nothing. There is no crash, no warning, and no difference a QA pass would catch. The only defence is a release gate that rejects absent, blank, and still-a-test-unit values, run before the build rather than after the launch.

Expect near-zero ad revenue in the first days after launch regardless: every new AdMob app sits in "Requires review — limited ad serving" until it is linked to a live listing and approved. That is not an integration bug, though you will spend an afternoon deciding it is.

The uncomfortable part

Here is the thing none of the tooling posts will tell you, and it is the reason this article exists.

Guideline 4.3(a) — Design: Spam is the constraint your automation is walking towards, and it gets closer the better the automation works. A pipeline that takes an app from repository to review queue without a human in the loop produces submissions faster than a reviewer believes a small developer can produce apps. Several binaries arriving from one account sharing a template, a component library, a build pipeline and an identical monetisation model is precisely the shape the guideline exists to catch. It is possible to optimise your way directly into the signal Apple screens for, and an Extended Review notice is what that looks like when you do.

If it happens, the honest position is not that the apps are unrelated. If they share a codebase, claiming otherwise is false and trivially checked — the reviewer can see the same structure across every binary on the account. The argument that can be made truthfully is narrower: the shared part is infrastructure, and the part that makes each app what it is was written separately. For a puzzle game that means its own rules, its own board generator and its own solver, which is the substance of the app; a repackaged template does not have those. Whether that argument lands is the reviewer's call, and I would not present anyone's appeal as a technique you can copy.

One thing I am confident about, because we got it wrong: verify your own metadata against source before you argue with a reviewer. A first draft reply described two apps incorrectly, because it was written from internal notes rather than from the code — the notes called one a cord-untangling puzzle when it implements Numberlink, and another a one-puzzle-a-day app when it serves three. In a spam review, handing Apple a description that does not match the binary is evidence for the accusation. A reviewer who opens the app and finds a different game than the letter describes has been shown exactly the carelessness the guideline is about.

Know the recovery sequence, because a rejected submission holds the version and the in-app purchase hostage: the API cannot build a new one while it stands, answering STATE_ERROR.ENTITY_STATE_INVALID — this resource cannot be reviewed. Reply first, then cancel the rejected submission (PATCH {"canceled": true}), which passes through CANCELING to COMPLETE in about fifteen seconds and reverts its items to PREPARE_FOR_SUBMISSION — so collect ids after that, not before. Then create the new submission, add the version and the IAP version (on the v2 base; the v1 path 404s), and submit.

When that generic state error tells you to "check associated errors to see why", there are none to check. Open the draft in App Store Connect and read the banner: it names the missing item in one line the API never gives you.

The deeper lesson is strategic rather than technical. Submission throughput is not the constraint anyone thinks it is. It is entirely possible to build a machine that files submissions faster than you can produce genuinely distinct products, and the store's answer to that is not a rate limit — it is a spam review. Once the automation works, the binding constraint moves to whether the things you are shipping deserve to be separate apps, and that is a question no API has an endpoint for.

The compressed version

  • Three things stay manual forever: the App Store Connect record, the Play app record, and AdMob. Schedule around them; automate everything else.
  • fastlane is alive and actively released — drop to the raw APIs for environmental reasons or for visibility of the response bodies, not because you heard it was dead.
  • A 200 is not a write. Read the field back, and treat assetDeliveryState as the real result of a screenshot upload.
  • Strip the alpha channel from every simulator PNG, compositing rather than discarding, or the asset fails after a successful upload with nothing naming the cause.
  • Screenshots, not builds, are the bottleneck. Drive capture by accessibility label so one route survives phone and tablet.
  • Play: no package name before the first upload, one open edit ever, prices in micros, and check the upload key SHA-1 before spending a build.
  • A missing ad identifier silently falls back to test units and earns nothing. Gate the release on it.
  • Read the network response, not the console toast. RESOURCE_EXHAUSTED never looks like a quota from the front end.
  • The better your submission automation, the more likely guideline 4.3 becomes your real constraint. Decide how many apps deserve to exist before you build the machine that can ship all of them.

Sources

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.