Originally published on hexisteme notes.
I shipped seven versions of my apps in thirty days by scripting the App Store Connect API end to end — build metadata, screenshots, submission, the works. Somewhere in that cadence I ran into three completely independent hard limits in the ASC API, each one verified live against my own apps on 2026-06-13. None of the three error messages point at their actual cause. One blames your credentials for what's really a clock problem. One tells you the app is in "the current state" without saying which state, or why that matters. One just says a count is over budget and leaves you to discover, the hard way, that the same API that created the excess item won't let you delete it either. Here's what each wall looks like, and the two-call pre-flight that catches all three before a script finds out about them for you.
The JWT 20-minute cap
Every call to the ASC API runs on a JWT you sign yourself — issuer ID, key ID, ES256 private key. What isn't obvious until you hit it: Apple enforces exp - iat <= 1200 seconds (20 minutes) as a hard cap on that token, no matter how far out you set exp. Set exp to iat + 1200 exactly and the token works. Set it to iat + 1201 — one second past the cap — and every call made with that token fails, even with a perfectly valid ES256 signature:
401 NOT_AUTHORIZED — Authentication credentials are missing or invalid
The message talks about credentials. The actual defect is the clock. Nothing in that string points at exp.
| exp setting | Result |
|---|---|
| iat + 1200 (exactly 20 min) | Works |
| iat + 1180 (as in the example below) | Works — margin for clock skew |
| iat + 1201 or more | 401 NOT_AUTHORIZED, valid signature or not |
| iat + 1800 (30 min) | 401 — whole batch fails |
This cap is specific to the ASC API. Apple's other JWTs — DeviceCheck, App Attest, Apple Pay — use different expiry policies, which is exactly why habits carried over from those bite here: a token lifetime that's perfectly fine for one Apple API is a silent 401 on this one.
import jwt, time
iat = int(time.time())
payload = {
"iss": ISSUER_ID,
"iat": iat,
"exp": iat + 1180, # stay under 1200; leaves clock-skew margin
"aud": "appstoreconnect-v1"
}
token = jwt.encode(payload, PRIVATE_KEY, algorithm="ES256",
headers={"kid": KEY_ID})
The place this actually costs you time is a batch — uploading a full screenshot set is easily 12+ sequential calls. "Some calls succeeded, then the rest started failing with 401" isn't a flaky network or a rate limit; it's this cap expiring mid-batch, on a token that was perfectly valid when the batch started. Two fixes, pick one: re-issue a fresh token per request, or size the batch so it finishes inside the 20-minute window measured from the first iat.
One app version in flight
ASC allows exactly one app version in progress at a time. If a version is sitting in PREPARE_FOR_SUBMISSION, WAITING_FOR_REVIEW, or IN_REVIEW — any one of the three — trying to create a second fails:
POST /v1/appStoreVersions
409 STATE_NOT_SUITABLE
"You cannot create a new version of the App in the current state"
Which state, though? The error doesn't say — you have to go query it yourself. Fix: finish the in-flight version by letting it complete review, or developer-cancel it with PATCH developerRejected=true, then create the next one.
The review-submission ceiling
This is the one that actually stopped my automation, because it's two separate counters wearing one error-shaped costume, and I'd only budgeted for one of them.
In-flight max 2 — WAITING_FOR_REVIEW and IN_REVIEW combined. Go over and you get MAX_IN_REVIEW_SUBMISSIONS_PER_PLATFORM_LIMIT_REACHED. Total max 5 — the same two states, plus every READY_FOR_REVIEW submission sitting around unsubmitted. Go over that and POST /v1/reviewSubmissions returns CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED.
| Limit | States counted | Error on exceed |
|---|---|---|
| in-flight, max 2 | WAITING_FOR_REVIEW + IN_REVIEW | MAX_IN_REVIEW_SUBMISSIONS_PER_PLATFORM_LIMIT_REACHED |
| total, max 5 | above two + READY_FOR_REVIEW orphans | CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED (on POST /v1/reviewSubmissions) |
The orphan trap is the sharp edge here: a READY_FOR_REVIEW submission — created but never actually submitted — counts toward that 5-total ceiling, and there is no API path to remove it. DELETE returns 403. PATCH canceled=true returns 409. The only ways out are a manual cancel in the ASC web UI, or the 7-day auto-expiry (verified as of 2026). If a script creates a reviewSubmission and then dies, hangs, or gets killed before it submits, that orphan sits there burning one of your five slots for up to a week unless a human goes and clicks cancel.
There's a slot-saving move buried in the same API, though: one reviewSubmission can bundle more than one item type — appStoreVersion, appCustomProductPageVersion (CPP), appStoreVersionExperiment (PPO), and appEvent can all ride in the same submission. Folding CPP variants and PPO experiments into the version's submission, instead of filing each as its own reviewSubmission, is the direct way to stop the 5-total budget from disappearing into things that aren't your actual app update.
The two-command pre-flight
Before any release automation touches the API, two GET calls tell you whether you're clear to proceed:
GET /v1/apps/{id}/appStoreVersions?filter[appStoreState]=PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,IN_REVIEW
GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW
The first tells you if a version is already in flight (limit two). The second counts the orphans eating your 5-total budget (limit three). If the second call returns any rows, cancel them in the ASC web UI first — the API that would let a script do it doesn't exist. If both come back clear, proceed.
This checklist exists because I shipped seven versions in thirty days and found all three limits the hard way before I had it. Now it's the first thing that runs, before a single POST.
Why the errors all point the wrong way
None of the three error messages name the constraint that's actually biting you. 401 NOT_AUTHORIZED blames your credentials for a clock problem. 409 STATE_NOT_SUITABLE names a state without saying which one. 409 CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED tells you a count is over budget without telling you the count includes an item the same API won't let you delete. All three are learnable exactly once — after that, the pre-flight above catches them before they cost a release. None of them are obvious from outside; I only got the exact numbers, error strings, and workarounds by hitting each wall against my own apps.
I wrote each of these up in more detail, in Korean, as I hit them: the JWT cap and the in-flight limits.
More notes at hexisteme.github.io/notes.
Top comments (0)