tl;dr —
appStoreVersionSubmissionswas deprecated and replaced byreviewSubmissionsplus the per-artifactreviewSubmissionItemsresource. The new API requires you to enumerate IAPs and the version as separaterelationships.itemsentries. There are five other quirks (V2 path vs v1 type,appAvailabilitiesPATCH semantics, per-app tester records, group endpoint for tester state, baseTerritory mandatory on iapPriceSchedule) that the official docs are silent on. This post is the complete cheatsheet plus a runnable 300-line module.
Why this post exists
Between mid-2025 and early 2026 Apple migrated the App Store Connect API from V1 endpoint shapes to V2 resource modelling. Most of the Stack Overflow answers, indie dev blog posts, and "ASC automation" GitHub repos still call appStoreVersionSubmissions — which now returns a 404 on certain payload shapes and a misleading 422 on others. I rewrote my four-app submission pipeline against V2 over two days. This is the post I wish I'd had when I started.
The structure:
- The 3-step flow that actually works
- Six quirks the docs do not mention
- A 300-line runnable module covering JWT, the flow, and reconciliation
- The migration table from V1 to V2
If you only want the working code, skip to section 3. If you want to know why the code looks the way it does, read sections 1 and 2 first.
1. The 3-step flow
A V2 review submission is three sequential calls. There is no atomic "submit everything" endpoint.
POST /reviewSubmissions
body: {
data: {
type: "reviewSubmissions",
attributes: { platform: "IOS" | "MAC_OS" | "TV_OS" | "VISION_OS" },
relationships: {
app: { data: { type: "apps", id: "<app_id>" } }
}
}
}
-> returns { data: { id: "<submission_id>", ... } }
POST /reviewSubmissionItems (call once per artifact)
body: {
data: {
type: "reviewSubmissionItems",
relationships: {
reviewSubmission: { data: { type: "reviewSubmissions", id: "<submission_id>" } },
appStoreVersions: { data: { type: "appStoreVersions", id: "<version_id>" } }
}
}
}
POST /reviewSubmissionItems (call again for each IAP)
body: {
data: {
type: "reviewSubmissionItems",
relationships: {
reviewSubmission: { data: { type: "reviewSubmissions", id: "<submission_id>" } },
inAppPurchases: { data: { type: "inAppPurchases", id: "<iap_id>" } }
}
}
}
PATCH /reviewSubmissions/<submission_id>
body: {
data: {
type: "reviewSubmissions",
id: "<submission_id>",
attributes: { submitted: true }
}
}
Three things to note before you run this:
- The submission is created first, items are added after, and
submitted: trueis the final commit. - An item's
relationships.<type>.data.typemust be the v1 type discriminator (inAppPurchasesnotinAppPurchasesV2) even though the resource is reached via/apps/<id>/inAppPurchasesV2. - The only relationship inside
reviewSubmissionItemsthat's allowed besidesreviewSubmissionis exactly one of:appStoreVersions,inAppPurchases,appCustomProductPages,appEvents,appStoreVersionExperimentsV2. One item, one artifact.
2. Six V2 quirks the docs do not mention
These cost me roughly four hours total. They are unrelated to each other but each can fail an automation pipeline silently.
Quirk 1 — V2 resources via v1 paths in relationships
The IAP V2 resource lives at /apps/<id>/inAppPurchasesV2. The relationship type discriminator inside reviewSubmissionItems is inAppPurchases. Use inAppPurchasesV2 and you get an opaque 409 Conflict.
Quirk 2 — appAvailabilities cannot be UPDATEd
You cannot PATCH appAvailabilities. Trying returns:
{
"errors": [{
"status": "405",
"code": "METHOD_NOT_ALLOWED",
"title": "PATCH not supported on appAvailabilities"
}]
}
The documented workflow is to POST a fresh appAvailabilityV2 resource — Apple replaces the previous availability with the new one server-side. The migration is invisible from the GET response.
Quirk 3 — Tester records are per-app, not global
A tester user@icloud.com invited to four apps has four distinct betaTester resources, each scoped to one app. The attributes.email matches across all four but id does not. If you cache tester ids you must cache them per-app or you will assign the wrong one and silently fail invitations.
Quirk 4 — Tester state must be queried via the group endpoint
GET /betaTesters/<id> -> attributes.state is null
GET /betaGroups/<group>/betaTesters?filter[email]=... -> attributes.state is "ACCEPTED"
This caught me when reconciling. The "state" field does not exist on the standalone betaTesters resource — only on the join through a group. Read it from the group endpoint always.
Quirk 5 — appStoreVersionSubmissions is gone
Calling POST /appStoreVersionSubmissions returns:
{
"errors": [{
"status": "410",
"code": "RESOURCE_GONE",
"title": "appStoreVersionSubmissions is deprecated. Use reviewSubmissions."
}]
}
The deprecation was quiet. Old SDKs (e.g. Fastlane prior to 2.219) still call this endpoint. If your CI pipeline started failing in mid-2025 — that's why.
Quirk 6 — iapPriceSchedule.baseTerritory is mandatory but not documented as such
Submitting an IAP without a baseTerritory set on its iapPriceSchedule causes a 2.1(b) rejection at review time, not at submission time. The submission itself succeeds. The reviewer's TestFlight build cannot render the paywall. Apple sends a rejection email. The cheapest defense is to PATCH baseTerritory: USA (or whatever your primary market is) on every IAP before calling step3_submit.
3. The 300-line runnable module
Drop-in module covering JWT generation, the 3-step submission, the 6 quirks. Saves a state.yml for re-runs so you don't double-submit.
"""asc_v2.py — App Store Connect API V2 client + reviewSubmissions flow.
Encodes the 3-step submission flow + 6 V2 quirks documented at:
https://jiejuefuyou.github.io/posts/asc-api-v2-reviewsubmissions/
Pre-reqs:
pip install pyjwt cryptography requests pyyaml
export ASC_KEY_ID=...
export ASC_ISSUER_ID=...
export ASC_KEY_FILE=~/AuthKey_KEYID.p8
Usage:
python asc_v2.py submit com.jiejuefuyou.daysuntil
python asc_v2.py reconcile-tester com.jiejuefuyou.daysuntil user@icloud.com
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from dataclasses import dataclass, field
from glob import glob
from pathlib import Path
from typing import Any, Iterator
import jwt # PyJWT
import requests
import yaml
ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
JWT_ALG = "ES256"
JWT_AUD = "appstoreconnect-v1"
JWT_LIFETIME = 1140 # 19 minutes; Apple max is 20
# Submission item type discriminators allowed by V2
ITEM_TYPES = {
"appStoreVersions",
"inAppPurchases",
"appCustomProductPages",
"appEvents",
"appStoreVersionExperimentsV2",
}
STATE_FILE = Path(".asc_v2_state.yml")
# ─────────────────────────── JWT ───────────────────────────
@dataclass
class JWTSigner:
"""Caches JWT for 19 min — Apple's hard cap is 20 min, headroom 1 min."""
key_id: str
issuer_id: str
key_pem: bytes
_token: str = ""
_at: float = 0.0
@classmethod
def from_env(cls) -> "JWTSigner":
key_id = os.environ.get("ASC_KEY_ID", "")
issuer_id = os.environ.get("ASC_ISSUER_ID", "")
key_pat = os.path.expanduser(os.environ.get("ASC_KEY_FILE", ""))
if not (key_id and issuer_id and key_pat):
raise RuntimeError(
"Missing one of ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_FILE"
)
# Glob support: ASC_KEY_FILE=~/AuthKey_*.p8
candidates = glob(key_pat) if any(c in key_pat for c in "*?[") else [key_pat]
path = next((Path(p) for p in candidates if Path(p).is_file()), None)
if path is None:
raise FileNotFoundError(f"ASC key file not found: {key_pat}")
return cls(key_id=key_id, issuer_id=issuer_id, key_pem=path.read_bytes())
def token(self) -> str:
if self._token and (time.time() - self._at) < (JWT_LIFETIME - 60):
return self._token
now = int(time.time())
payload = {
"iss": self.issuer_id,
"iat": now,
"exp": now + JWT_LIFETIME,
"aud": JWT_AUD,
}
headers = {"alg": JWT_ALG, "kid": self.key_id, "typ": "JWT"}
self._token = jwt.encode(payload, self.key_pem, algorithm=JWT_ALG, headers=headers)
self._at = time.time()
return self._token
# ─────────────────────────── HTTP layer ───────────────────────────
@dataclass
class ASCClient:
signer: JWTSigner = field(default_factory=JWTSigner.from_env)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.signer.token()}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, *, body: dict | None = None,
params: dict | None = None) -> dict:
url = f"{ASC_BASE}{path}"
for attempt in range(3):
resp = requests.request(
method, url,
headers=self._headers(),
json=body,
params={k: v for k, v in (params or {}).items() if v is not None},
timeout=60,
)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", "5"))
time.sleep(wait)
continue
if resp.status_code >= 500 and attempt < 2:
time.sleep(2 ** attempt)
continue
break
if resp.status_code == 404:
return {"data": [], "_status": 404}
if resp.status_code >= 400:
raise RuntimeError(
f"{method} {path} -> {resp.status_code}: {resp.text[:600]}"
)
return resp.json() if resp.text else {}
def get(self, path: str, **params: Any) -> dict:
return self._request("GET", path, params=params)
def post(self, path: str, body: dict) -> dict:
return self._request("POST", path, body=body)
def patch(self, path: str, body: dict) -> dict:
return self._request("PATCH", path, body=body)
def paginate(self, path: str, **params: Any) -> Iterator[dict]:
params.setdefault("limit", 200)
next_url: str | None = None
first = True
while first or next_url:
if next_url:
# Apple returns full URL incl. ASC_BASE — strip it
tail = next_url.replace(ASC_BASE, "")
page = self._request("GET", tail)
else:
page = self.get(path, **params)
first = False
for item in page.get("data", []):
yield item
next_url = (page.get("links") or {}).get("next")
# ─────────────────────────── lookups ───────────────────────────
def find_app_id(client: ASCClient, bundle_id: str) -> str:
for app in client.paginate("/apps"):
if app["attributes"].get("bundleId") == bundle_id:
return app["id"]
raise RuntimeError(f"bundle_id {bundle_id!r} not found in ASC")
def find_pending_version(client: ASCClient, app_id: str) -> str:
"""Quirk 5 corollary: appStoreVersions still exist; only Submissions changed."""
page = client.get(
f"/apps/{app_id}/appStoreVersions",
sort="-versionString",
limit=10,
)
for v in page.get("data", []):
state = v["attributes"].get("appStoreState")
if state in {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED",
"INVALID_BINARY", "METADATA_REJECTED"}:
return v["id"]
raise RuntimeError(f"No pending version for app {app_id}")
def find_submittable_iaps(client: ASCClient, app_id: str) -> list[str]:
"""Quirk 1: V2 resource via v1 path — but state filter still works."""
out: list[str] = []
for iap in client.paginate(f"/apps/{app_id}/inAppPurchasesV2"):
state = iap["attributes"].get("state", "")
if state in {"READY_TO_SUBMIT", "DEVELOPER_ACTION_NEEDED",
"WAITING_FOR_REVIEW"}:
out.append(iap["id"])
return out
# ─────────────────────────── pre-flight fixers ───────────────────────────
def ensure_iap_base_territory(client: ASCClient, iap_id: str,
territory: str = "USA") -> bool:
"""Quirk 6 — set baseTerritory if missing. Idempotent."""
sched = client.get(f"/inAppPurchases/{iap_id}/iapPriceSchedule")
data = sched.get("data") or {}
sched_id = data.get("id")
if not sched_id:
client.post("/iapPriceSchedules", {
"data": {
"type": "iapPriceSchedules",
"relationships": {
"inAppPurchase": {"data": {"type": "inAppPurchases", "id": iap_id}},
"baseTerritory": {"data": {"type": "territories", "id": territory}},
"manualPrices": {"data": []},
},
}
})
return True
base = ((data.get("relationships") or {}).get("baseTerritory") or {})
if (base.get("data") or {}).get("id") == territory:
return False
client.patch(f"/iapPriceSchedules/{sched_id}", {
"data": {
"type": "iapPriceSchedules",
"id": sched_id,
"relationships": {
"baseTerritory": {"data": {"type": "territories", "id": territory}},
},
}
})
return True
# ─────────────────────────── 3-step submission ───────────────────────────
def submit_with_iaps(client: ASCClient, bundle_id: str,
territory: str = "USA") -> dict:
"""Full V2 reviewSubmissions flow. Returns submission summary dict."""
app_id = find_app_id(client, bundle_id)
version_id = find_pending_version(client, app_id)
iap_ids = find_submittable_iaps(client, app_id)
# Pre-flight quirk 6
fixed_iaps = [
iap for iap in iap_ids
if ensure_iap_base_territory(client, iap, territory)
]
if fixed_iaps:
print(f" fixed baseTerritory on {len(fixed_iaps)} IAP(s)")
# Step 1
sub = client.post("/reviewSubmissions", {
"data": {
"type": "reviewSubmissions",
"attributes": {"platform": "IOS"},
"relationships": {"app": {"data": {"type": "apps", "id": app_id}}},
}
})
submission_id = sub["data"]["id"]
print(f" submission created: {submission_id}")
# Step 2 — version
client.post("/reviewSubmissionItems", {
"data": {
"type": "reviewSubmissionItems",
"relationships": {
"reviewSubmission": {
"data": {"type": "reviewSubmissions", "id": submission_id},
},
"appStoreVersions": {
"data": {"type": "appStoreVersions", "id": version_id},
},
},
}
})
print(f" added version {version_id}")
# Step 2 — each IAP. Quirk 1: type stays "inAppPurchases".
for iap_id in iap_ids:
client.post("/reviewSubmissionItems", {
"data": {
"type": "reviewSubmissionItems",
"relationships": {
"reviewSubmission": {
"data": {"type": "reviewSubmissions", "id": submission_id},
},
"inAppPurchases": {
"data": {"type": "inAppPurchases", "id": iap_id},
},
},
}
})
print(f" added IAP {iap_id}")
# Step 3
client.patch(f"/reviewSubmissions/{submission_id}", {
"data": {
"type": "reviewSubmissions",
"id": submission_id,
"attributes": {"submitted": True},
}
})
print(f" SUBMITTED https://appstoreconnect.apple.com/apps/{app_id}/distribution")
summary = {
"bundle_id": bundle_id,
"app_id": app_id,
"submission_id": submission_id,
"version_id": version_id,
"iap_ids": iap_ids,
"submitted_at": int(time.time()),
}
_save_state(bundle_id, summary)
return summary
def _save_state(bundle_id: str, summary: dict) -> None:
state: dict = {}
if STATE_FILE.is_file():
state = yaml.safe_load(STATE_FILE.read_text()) or {}
state[bundle_id] = summary
STATE_FILE.write_text(yaml.safe_dump(state, sort_keys=False))
# ─────────────────────────── tester reconciliation ───────────────────────────
def reconcile_tester(client: ASCClient, bundle_id: str, email: str) -> dict:
"""Quirks 3 + 4 — tester records are per-app + state lives on the group."""
app_id = find_app_id(client, bundle_id)
groups = client.get(f"/apps/{app_id}/betaGroups").get("data", [])
group_id = next(
(g["id"] for g in groups if g["attributes"].get("isInternalGroup")),
groups[0]["id"] if groups else None,
)
if group_id is None:
return {"error": "no_beta_groups", "app_id": app_id}
# Quirk 4 — query state via the group
page = client.get(f"/betaGroups/{group_id}/betaTesters", limit=200)
matches = [
t for t in page.get("data", [])
if t["attributes"].get("email", "").lower() == email.lower()
]
if not matches:
# Quirk 3 — must POST a *new* per-app tester record, even if the email
# already exists on a different app.
client.post("/betaTesters", {
"data": {
"type": "betaTesters",
"attributes": {"email": email},
"relationships": {
"betaGroups": {
"data": [{"type": "betaGroups", "id": group_id}],
},
},
}
})
return {"action": "invited", "app_id": app_id, "group_id": group_id}
state = matches[0]["attributes"].get("state", "")
return {
"action": "found",
"app_id": app_id,
"group_id": group_id,
"tester_id": matches[0]["id"],
"state": state,
}
# ─────────────────────────── CLI ───────────────────────────
def cmd_submit(args: argparse.Namespace) -> int:
client = ASCClient()
summary = submit_with_iaps(client, args.bundle_id, territory=args.territory)
print(json.dumps(summary, indent=2))
return 0
def cmd_reconcile(args: argparse.Namespace) -> int:
client = ASCClient()
result = reconcile_tester(client, args.bundle_id, args.email)
print(json.dumps(result, indent=2))
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="ASC API V2 client + 3-step submit")
sub = p.add_subparsers(dest="cmd", required=True)
p_submit = sub.add_parser("submit", help="Run 3-step reviewSubmissions flow")
p_submit.add_argument("bundle_id")
p_submit.add_argument("--territory", default="USA")
p_submit.set_defaults(func=cmd_submit)
p_recon = sub.add_parser("reconcile-tester", help="Add or fetch tester per app")
p_recon.add_argument("bundle_id")
p_recon.add_argument("email")
p_recon.set_defaults(func=cmd_reconcile)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
Run:
$ python asc_v2.py submit com.jiejuefuyou.daysuntil
fixed baseTerritory on 1 IAP(s)
submission created: 4f2b...
added version 9c1e...
added IAP 7d3a...
SUBMITTED https://appstoreconnect.apple.com/apps/.../distribution
{
"bundle_id": "com.jiejuefuyou.daysuntil",
"submission_id": "4f2b...",
...
}
4. V1 to V2 migration table
| Old endpoint | New endpoint | Breaking change |
|---|---|---|
POST /appStoreVersionSubmissions |
POST /reviewSubmissions then POST /reviewSubmissionItems per artifact then PATCH /reviewSubmissions with submitted: true
|
3 calls instead of 1; explicit IAP enumeration required |
POST /apps/<id>/inAppPurchases |
POST /apps/<id>/inAppPurchasesV2 |
resource path differs from relationship type |
PATCH /apps/<id>/appAvailabilities |
POST /appAvailabilityV2 |
PATCH no longer allowed |
GET /betaTesters/<id> (state) |
GET /betaGroups/<group>/betaTesters?filter[email]=... |
state field moved to join |
Single shared betaTester per email |
One betaTester per (email, app) pair |
per-app caching required |
POST /apps/<id>/inAppPurchases/<iap>/appStoreReviewScreenshot |
POST /appStoreReviewScreenshots with relationships.inAppPurchaseV2
|
top-level + 3-call upload |
Why this matters
The same four-app submission pipeline that took 90 minutes in my IAP 2.1(b) post-mortem was 4 hours of confused poking the first time I ran into the V2 migration. The lesson: when an Apple endpoint returns a 410, don't retry — go check the migration notes. When it returns a 422 with no useful body, check whether the relationship type discriminator is the V1 name on a V2 resource.
If you want to see the unabridged code (this post's snippet is intentionally minimal), the open-source asc_diag.py ships with the same patterns plus the 12-class diagnostic and the reviewSubmissions example wired together end-to-end.
Further reading on the same migration:
- How I Fixed Apple's IAP 2.1(b) Rejection in 90 Minutes
- A 12-Class Diagnostic Tool for ASC Rejection Debugging
- ASC IAP pricing 7-step CDP flow
- Apple Paid Apps Agreement: the silent TestFlight blocker
- Why TestFlight
appAvailabilitiescannot be UPDATEd in V2
If you ship iOS apps and hit the same wall
I packaged the toolkit + 200 page playbook + cold email templates + lead magnet into four Gumroad SKUs:
- iOS Indie Developer's ASC API Toolkit ($499) — full Python source for the V2 client, diagnostic, and IAP automation: vszsui
- iOS Indie Launch Playbook ($39) — 200-page execution guide for "rejection to approval" loops: hmmzt
- B2B Cold Email Templates for iOS Devs ($19) — pitch the same toolkit to indie clients: gncbck
- 14 Common iOS Rejection Reasons (FREE lead magnet) — the curated list every Apple rejection email maps to: sphytu
Top comments (0)