Shipping & Infra4 min read

The subject of `version not editable` wasn't the version

After a review rejection I tried to fix product localizations and got 409 'version is not editable'. So I made the version editable — same 409. The 'version' in that error isn't the appStoreVersion; it's a still-open reviewSubmission shell. Part 1 of an App Store Connect API gotchas series.

#app-store-connect#api#ios#gotchas
Concept diagram: a reviewSubmission shell holds 6 REJECTED products; an arrow from outside setting appStoreVersion to PREPARE_FOR_SUBMISSION bounces off. Only one arrow — canceling the shell — reaches inside, and the products auto-restore.
When the error names a different thing than what actually holds the lock, the whole debug session is wasted.

Part 1 of an App Store Connect API gotchas series. Drive the ASC API from scripts and you hit several places where the error message doesn't tell the truth. Here's the first.

The app got rejected in review. The 6 IAPs/subscriptions submitted with it all went REJECTED too. Trying to fix the product localizations and resubmit, I DELETEd the subscriptionLocalizations and got:

409 STATE_ERROR.INVALID_REQUEST_ENTITY_STATE_INVALID
"Cannot delete localization, version is not editable."

Version isn't editable? Then I'll make the version editable. I attached a new build to move the appStoreVersion to PREPARE_FOR_SUBMISSION. Same 409.

The "version" in that error isn't that version

What was actually holding the lock was a still-open reviewSubmission shell. Its state was UNRESOLVED_ISSUES, and the products were bound inside it as REJECTED items. No matter how editable you make the app version, the products stay locked as long as the shell is open.

The only fix was to cancel that shell with canceled=true. The moment I did, the 6 products auto-restored from REJECTEDREADY_TO_SUBMIT. No localization DELETE, no re-creation needed.

When the error names one thing but a different thing holds the lock, the whole debug session spins. Here I first tried the "DELETE localization → recreate" procedure used in a different situation (a first subscription in DEVELOPER_ACTION_NEEDED), and all 12 spun on 409. The procedure itself was correct — the state was different.

I also worried about the cost of canceling; here there was none. "Canceling a submission throws away your review queue position" is the usual warning, but that's when you're waiting for review. An already-rejected shell isn't in the queue, so there's no position to lose.

The second trap the same shell makes — empty shells eat slots

The submission shell causes trouble another way. Create a reviewSubmission, then have add_item fail, and the submission itself stays in ASC as READY_FOR_REVIEW. An empty shell with no items. Stack up 5 of those and you hit the limit:

CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED

Empty submissions count toward the limit. And this state can't be deleted via API.

  • canceled=true PATCH → 409 Resource is not in cancellable state
  • DELETE → 403 FORBIDDEN_ERROR

canceled=true only works from WAITING_FOR_REVIEW. A READY_FOR_REVIEW empty shell only goes away when a human clicks "Cancel Submission" in the ASC web UI. An automated pipeline can't clean up its own garbage.

Honestly — I misread the limit

  • I thought "submit 18 apps concurrently and the 6th onward is blocked." Wrong. The limit is in-flight submissions per app, not per account. One each across 18 different apps isn't blocked at all — I actually hit 18/18 concurrent.
  • So finding a shell isn't automatically an incident. In a 33-app wave, 12 apps had 23 leftover shells, yet all 33/33 creates succeeded — because no single app reached 5. When you find one, don't treat it as a fire; just check whether that app's count is 5.
  • Adding IAPs/subscriptions to a review submission is still impossible via API. The inAppPurchaseV2, inAppPurchase, and subscription relationships all give 409 ENTITY_ERROR.RELATIONSHIP.UNKNOWN. It's exclusive to the "Add to Review" button in the ASC web UI. So this pipeline can't be automated end to end — at least one point requires a human to open a browser.

Cancelability by state

state canceled=true PATCH DELETE
WAITING_FOR_REVIEW works
READY_FOR_REVIEW (empty shell) 409 403
UNRESOLVED_ISSUES (rejected shell) works → products auto-restore

How to not make shells: wrap create → add_item → submit in one function and cancel in place on any exception. Never leaving an empty submission is the only prevention.

If the final submit 500s, don't re-run. When PATCH submitted:true gets a 500 UNEXPECTED_ERROR, re-running the tool creates yet another reviewSubmission and only grows the shell pile — create/add_item already succeeded. The correct recovery is to find that app's submission with state=READY_FOR_REVIEW and items>=1 and re-PATCH submitted:true on that one only. Generalizing: create/add_item/submit are individually retryable, and you have to determine the retry point from state. Re-running the whole command re-executes already-successful steps and leaves side effects.

Series ahead

There's more in this family — swapping only the build while in WAITING (cancel → DEVELOPER_REJECTED → PATCH build), whatsNew backfill (every locale must be filled before an item can be added), needing a new build even for a metadata-only change, new-locale CREATE blocked during WAITING, and escaping DEVELOPER_REJECTED by renaming versionString. Each is thin alone but adds up to real search traffic as a series, so I'll continue.

That API error you're chasing right now — is the resource it named actually the thing that's locked? Or is there a forgotten shell left open behind it?

Related