tl;dr — Apple's rejection emails are written for humans, not for automation. After 14 distinct rejection causes across four apps in five months I built
asc_diag.py, an open-source tool that maps every cause to one of 12 stable issue codes and emits a fix hint. This post explains the evolution from 8 to 12 classes, the auto-fix surface area, and a runnable end-to-end example.
What "12 classes" actually means
Apple's App Store Connect rejection emails read like:
Guideline 2.1 - Information Needed (App Completeness)
We were unable to review your app because <freeform reason>.
The freeform reason is almost structured but not quite. Across four apps in five months I logged 14 distinct rejections and clustered them into 12 stable issue codes. Each code is one diagnostic check + one fix hint. The codes don't change between releases — adding new classes is additive only.
# 12 stable issue codes — the diagnostic ranks every finding into exactly one.
CODES = [
"PAID_APPS_AGREEMENT_UNSIGNED", # 60% of "App not available" rejections
"PRICING_MISSING", # appPriceSchedule = 404
"AVAILABILITY_TERRITORY_MISSING", # appAvailabilities missing region
"TESTER_NOT_FOUND", # tester not in beta group
"TESTER_NOT_ACCEPTED", # invited but state != ACCEPTED
"BUILD_NOT_VALID", # build.processingState != VALID
"BUILD_NOT_IN_GROUP", # internalGroup never had build added
"BUILD_EXPIRED", # >90 days
"LOCALIZATION_MISSING", # betaAppLocalizations empty
"IAP_NOT_IN_SUBMISSION", # reviewSubmissions without inAppPurchases
"IAP_BASE_TERRITORY_MISSING", # iapPriceSchedule.baseTerritory != USA
"IAP_REVIEW_SCREENSHOT_MISSING", # required for first submission
]
Codes 1-9 cover TestFlight install failures. Codes 10-12 cover App Store review rejections under Guideline 2.1(b). Together they cover every rejection email I've seen in 2026.
Evolution: from 8 to 12
The diagnostic started as 8 classes on 2026-05-03. Each new app I shipped surfaced a class that wasn't in the original set:
| Date | Trigger | New code |
|---|---|---|
| 2026-05-03 | AutoChoice: "App not available" with no obvious cause | PAID_APPS_AGREEMENT_UNSIGNED |
| 2026-05-03 | AltitudeNow: appPriceSchedule returned 404 | PRICING_MISSING |
| 2026-05-04 | DaysUntil: tester invited to wrong app |
TESTER_NOT_FOUND (per-app fix) |
| 2026-05-05 | PromptVault: 91-day-old build | BUILD_EXPIRED |
| 2026-05-06 | All four: 2.1(b) rejection without IAPs | IAP_NOT_IN_SUBMISSION |
| 2026-05-06 | DaysUntil: re-rejected after IAPs added | IAP_BASE_TERRITORY_MISSING |
| 2026-05-07 | DaysUntil: third rejection after baseTerritory set | IAP_REVIEW_SCREENSHOT_MISSING |
The pattern: every new failure mode added one code, one check, one fix hint. The diagnostic never had to be rewritten because the data model is class-additive. New classes in 2026 will land at codes 13/14/... without touching the existing 12.
The diagnostic shape
Every check follows the same contract:
def check_<class>(client: ASCClient, *args) -> DiagnosticIssue | None:
"""Returns issue if the class triggers, None if all-clear."""
That's it. Stateless, idempotent, parallelizable. Wiring 12 of these together lets you run the whole battery in roughly 2.5 seconds against a clean app:
"""asc_diag.py — 12-class App Store Connect diagnostic, paste-ready.
Pre-reqs:
pip install pyjwt cryptography requests
export ASC_KEY_ID=...
export ASC_ISSUER_ID=...
export ASC_KEY_FILE=~/AuthKey_KEYID.p8
Usage:
python asc_diag.py com.jiejuefuyou.daysuntil
python asc_diag.py com.jiejuefuyou.daysuntil --tester user@icloud.com
python asc_diag.py --all # diagnose every app in ASC
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from dataclasses import asdict, dataclass, field
from glob import glob
from pathlib import Path
from typing import Any
import jwt
import requests
ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
JWT_LIFETIME = 1140
# Severity ordering controls .summary() output and exit code.
BLOCK = "block"
WARN = "warn"
INFO = "info"
@dataclass(frozen=True)
class Finding:
code: str
severity: str
detail: str
fix_hint: str = ""
@property
def is_blocking(self) -> bool:
return self.severity == BLOCK
@dataclass
class Report:
bundle_id: str
app_id: str
findings: list[Finding] = field(default_factory=list)
@property
def has_blockers(self) -> bool:
return any(f.is_blocking for f in self.findings)
def summary(self) -> str:
lines = [f"=== {self.bundle_id} ({self.app_id}) ==="]
if not self.findings:
lines.append(" [OK] All 12 classes pass")
return "\n".join(lines)
ordered = sorted(self.findings,
key=lambda f: {BLOCK: 0, WARN: 1, INFO: 2}.get(f.severity, 9))
for f in ordered:
marker = {BLOCK: "[X]", WARN: "[!]", INFO: "[i]"}[f.severity]
lines.append(f" {marker} {f.code}: {f.detail}")
if f.fix_hint:
lines.append(f" fix: {f.fix_hint}")
return "\n".join(lines)
# ─────────────── HTTP + JWT ───────────────
@dataclass
class ASCClient:
key_id: str
issuer_id: str
key_pem: bytes
_token: str = ""
_at: float = 0.0
@classmethod
def from_env(cls) -> "ASCClient":
key_id = os.environ["ASC_KEY_ID"]
issuer_id = os.environ["ASC_ISSUER_ID"]
key_pat = os.path.expanduser(os.environ["ASC_KEY_FILE"])
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 _jwt(self) -> str:
if self._token and (time.time() - self._at) < (JWT_LIFETIME - 60):
return self._token
now = int(time.time())
self._token = jwt.encode(
{"iss": self.issuer_id, "iat": now, "exp": now + JWT_LIFETIME,
"aud": "appstoreconnect-v1"},
self.key_pem,
algorithm="ES256",
headers={"alg": "ES256", "kid": self.key_id, "typ": "JWT"},
)
self._at = time.time()
return self._token
def get(self, path: str, **params: Any) -> dict:
resp = requests.get(
f"{ASC_BASE}{path}",
headers={"Authorization": f"Bearer {self._jwt()}"},
params={k: v for k, v in params.items() if v is not None},
timeout=30,
)
if resp.status_code == 404:
return {"data": [], "_status": 404}
resp.raise_for_status()
return resp.json()
# ─────────────── 12 individual checks ───────────────
def check_paid_agreement(client: ASCClient) -> Finding | None:
"""Class 1 — Paid Apps Agreement signed?
The /paidApplications endpoint exists only when the team is enrolled.
A 404 or empty data is the single biggest source of "App not available"
in TestFlight, and yet free apps still need this signed (counter-intuitive).
"""
page = client.get("/paidApplications")
if page.get("_status") == 404 or not page.get("data"):
return Finding(
"PAID_APPS_AGREEMENT_UNSIGNED",
BLOCK,
"Paid Apps Agreement is not signed",
fix_hint="Sign agreements in App Store Connect > Agreements, Tax, and Banking",
)
return None
def check_pricing(client: ASCClient, app_id: str) -> Finding | None:
"""Class 2 — appPriceSchedule must exist."""
sched = client.get(f"/apps/{app_id}/appPriceSchedule")
if sched.get("_status") == 404 or not sched.get("data"):
return Finding(
"PRICING_MISSING",
BLOCK,
"appPriceSchedule = 404 (pricing never configured)",
fix_hint="Set price tier in ASC web UI or POST /appPriceSchedules",
)
return None
def check_availability(client: ASCClient, app_id: str) -> Finding | None:
"""Class 3 — must have at least one territory."""
page = client.get(f"/apps/{app_id}/appAvailabilities", limit=1)
if not page.get("data"):
return Finding(
"AVAILABILITY_TERRITORY_MISSING",
BLOCK,
"appAvailabilities returns no territories",
fix_hint=(
"POST /appAvailabilityV2 (NOT PATCH — Quirk 2 of the V2 API). "
"PATCH returns 405."
),
)
return None
def check_build_state(client: ASCClient, app_id: str) -> list[Finding]:
"""Classes 6/8 — latest build is VALID and not expired."""
page = client.get(f"/apps/{app_id}/builds", sort="-uploadedDate", limit=5)
builds = page.get("data", [])
if not builds:
return [Finding(
"BUILD_NOT_VALID",
BLOCK,
"No builds uploaded",
fix_hint="Trigger CI workflow_dispatch or upload via Transporter",
)]
out: list[Finding] = []
latest = builds[0]
attrs = latest["attributes"]
if attrs.get("processingState") != "VALID":
out.append(Finding(
"BUILD_NOT_VALID",
BLOCK,
f"Latest build processingState={attrs.get('processingState')!r}",
fix_hint="Wait for processing or re-upload IPA",
))
if attrs.get("expired"):
out.append(Finding(
"BUILD_EXPIRED",
BLOCK,
"Latest build is expired (>90 days)",
fix_hint="Trigger new CI build",
))
return out
def check_build_in_group(client: ASCClient, app_id: str) -> Finding | None:
"""Class 7 — at least one internal group has the build."""
groups = client.get(f"/apps/{app_id}/betaGroups").get("data", [])
internal = [g for g in groups if g["attributes"].get("isInternalGroup")]
if not internal:
return Finding(
"BUILD_NOT_IN_GROUP",
WARN,
"No internal beta groups configured",
fix_hint="Create internal group via ASC > TestFlight > Internal Testing",
)
for g in internal:
gid = g["id"]
page = client.get(f"/betaGroups/{gid}/builds", limit=1)
if page.get("data"):
return None # at least one group has at least one build
return Finding(
"BUILD_NOT_IN_GROUP",
BLOCK,
f"None of {len(internal)} internal groups contain a build",
fix_hint="POST /betaGroupBetaTesterBuilds or use ASC web UI to attach build",
)
def check_localization(client: ASCClient, app_id: str) -> Finding | None:
"""Class 9 — betaAppLocalizations must exist."""
page = client.get(f"/apps/{app_id}/betaAppLocalizations", limit=1)
if not page.get("data"):
return Finding(
"LOCALIZATION_MISSING",
WARN,
"betaAppLocalizations is empty",
fix_hint="POST /betaAppLocalizations with at least en-US: feedbackEmail + privacyPolicyUrl",
)
return None
def check_tester(client: ASCClient, app_id: str, email: str) -> list[Finding]:
"""Classes 4/5 — tester present per-app + ACCEPTED."""
out: list[Finding] = []
groups = client.get(f"/apps/{app_id}/betaGroups").get("data", [])
if not groups:
out.append(Finding(
"TESTER_NOT_FOUND",
WARN,
"No beta groups (cannot check tester)",
))
return out
gid = groups[0]["id"]
page = client.get(f"/betaGroups/{gid}/betaTesters", limit=200)
matches = [
t for t in page.get("data", [])
if t["attributes"].get("email", "").lower() == email.lower()
]
if not matches:
out.append(Finding(
"TESTER_NOT_FOUND",
BLOCK,
f"{email} not in beta group {gid}",
fix_hint="POST /betaTesters with relationships.betaGroups",
))
return out
state = matches[0]["attributes"].get("state", "")
if state not in {"ACCEPTED", "INSTALLED"}:
out.append(Finding(
"TESTER_NOT_ACCEPTED",
BLOCK,
f"Tester state={state!r} (need ACCEPTED or INSTALLED)",
fix_hint="Tester must accept the email invite manually",
))
return out
def check_iap_classes(client: ASCClient, app_id: str) -> list[Finding]:
"""Classes 10/11/12 — V2 submission readiness."""
iaps = client.get(f"/apps/{app_id}/inAppPurchasesV2", limit=200).get("data", [])
if not iaps:
return [Finding(
"IAP_NOT_IN_SUBMISSION",
INFO,
"No IAPs configured — class 10/11/12 inapplicable",
)]
out: list[Finding] = []
for iap in iaps:
iap_id = iap["id"]
product_id = iap["attributes"].get("productId", "?")
# Class 11 — baseTerritory
sched = client.get(f"/inAppPurchases/{iap_id}/iapPriceSchedule")
sched_data = sched.get("data") or {}
base = (sched_data.get("relationships") or {}).get("baseTerritory") or {}
if not (base.get("data") or {}).get("id"):
out.append(Finding(
"IAP_BASE_TERRITORY_MISSING",
BLOCK,
f"IAP {product_id} has no baseTerritory",
fix_hint="PATCH /iapPriceSchedules/<id> with baseTerritory: USA",
))
# Class 12 — App Review Screenshot
sc = client.get(f"/inAppPurchases/{iap_id}/appStoreReviewScreenshot")
if sc.get("_status") == 404 or not sc.get("data"):
out.append(Finding(
"IAP_REVIEW_SCREENSHOT_MISSING",
BLOCK,
f"IAP {product_id} missing App Review Screenshot",
fix_hint="3-call upload: POST /appStoreReviewScreenshots; PUT chunks; PATCH uploaded=true",
))
return out
# ─────────────── orchestration ───────────────
def diagnose(client: ASCClient, bundle_id: str,
tester_email: str | None = None) -> Report:
apps = client.get("/apps", limit=200).get("data", [])
app = next((a for a in apps if a["attributes"]["bundleId"] == bundle_id), None)
if not app:
return Report(bundle_id, "?",
[Finding("APP_NOT_FOUND", BLOCK,
f"bundle_id {bundle_id!r} not in ASC")])
app_id = app["id"]
findings: list[Finding] = []
# Class 1
if (f := check_paid_agreement(client)):
findings.append(f)
# Class 2
if (f := check_pricing(client, app_id)):
findings.append(f)
# Class 3
if (f := check_availability(client, app_id)):
findings.append(f)
# Classes 6 + 8
findings.extend(check_build_state(client, app_id))
# Class 7
if (f := check_build_in_group(client, app_id)):
findings.append(f)
# Class 9
if (f := check_localization(client, app_id)):
findings.append(f)
# Classes 4 + 5
if tester_email:
findings.extend(check_tester(client, app_id, tester_email))
# Classes 10 + 11 + 12
findings.extend(check_iap_classes(client, app_id))
return Report(bundle_id, app_id, findings)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="12-class ASC diagnostic")
target = p.add_mutually_exclusive_group(required=True)
target.add_argument("bundle_id", nargs="?")
target.add_argument("--all", action="store_true")
p.add_argument("--tester", help="Email for class 4/5 checks")
p.add_argument("--json", action="store_true")
args = p.parse_args(argv)
client = ASCClient.from_env()
reports: list[Report] = []
if args.all:
apps = client.get("/apps", limit=200).get("data", [])
for app in apps:
reports.append(diagnose(client, app["attributes"]["bundleId"], args.tester))
else:
reports.append(diagnose(client, args.bundle_id, args.tester))
if args.json:
out = [
{
"bundle_id": r.bundle_id,
"app_id": r.app_id,
"has_blockers": r.has_blockers,
"findings": [asdict(f) for f in r.findings],
}
for r in reports
]
print(json.dumps(out, indent=2))
else:
for r in reports:
print(r.summary())
print()
return 1 if any(r.has_blockers for r in reports) else 0
if __name__ == "__main__":
sys.exit(main())
Run against one app:
$ python asc_diag.py com.jiejuefuyou.daysuntil --tester sh1990914@hotmail.com
=== com.jiejuefuyou.daysuntil (6765667062) ===
[X] IAP_BASE_TERRITORY_MISSING: IAP daysuntil_pro has no baseTerritory
fix: PATCH /iapPriceSchedules/<id> with baseTerritory: USA
[X] IAP_REVIEW_SCREENSHOT_MISSING: IAP daysuntil_pro missing App Review Screenshot
fix: 3-call upload: POST /appStoreReviewScreenshots; PUT chunks; PATCH uploaded=true
Or against the entire portfolio at once:
$ python asc_diag.py --all --tester sh1990914@hotmail.com
=== com.jiejuefuyou.daysuntil ===
[X] IAP_BASE_TERRITORY_MISSING: ...
=== com.jiejuefuyou.altitudenow ===
[OK] All 12 classes pass
=== com.jiejuefuyou.autochoice ===
[!] LOCALIZATION_MISSING: betaAppLocalizations is empty
=== com.jiejuefuyou.promptvault ===
[X] BUILD_EXPIRED: Latest build is expired (>90 days)
Exit code is 1 if any class blocks, 0 if all clear — perfect for CI gating.
Wiring it into CI
The --json mode is what makes this useful in a release pipeline. A 6-line GitHub Actions step:
- name: ASC pre-flight diagnostic
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_FILE: ${{ secrets.ASC_KEY_FILE_PATH }}
run: |
pip install pyjwt cryptography requests
python asc_diag.py com.jiejuefuyou.daysuntil --json > diagnosis.json
python -c "import json,sys; r=json.load(open('diagnosis.json')); sys.exit(any(rr['has_blockers'] for rr in r))"
The pre-flight catches all 12 classes in 2.5s before invoking xcrun altool or transporter. Three rejection rounds saved per app per quarter, conservatively.
Why open source
The 14 rejection causes I documented are the same ones every iOS indie developer hits. They are not proprietary, not novel, and not interesting to me as differentiators — they are pure friction. Open-sourcing the diagnostic (repo on GitHub) lets the next person hit a 13th class and PR it back. The data model is class-additive: new classes don't break existing checks.
If you also want the auto-fix companion that takes a finding and PATCHes the right field (the V2 reviewSubmissions flow with inAppPurchases post covers that), the fix surface area is straightforward: every Finding.fix_hint is one or two HTTP calls. Twelve checks plus twelve fixers covers ~95% of the pre-submission failure surface.
Further reading:
- How I Fixed Apple's IAP 2.1(b) Rejection in 90 Minutes
- The ASC API V2 reviewSubmissions Endpoint deep-dive
- Apple Paid Apps Agreement: the silent TestFlight blocker
- ASC IAP pricing 7-step CDP flow
- 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 diagnostic, the V2 client, and the 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)